From a119427834be2722c21ba7edc8cf3a760c813138 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Sat, 20 Dec 2025 03:22:05 +0100 Subject: [PATCH] docs: rewrite testing docs Those rewrites focus on using Vitest instead of jasmine, drop usages of `fakeAsync`, present modern testing strategy that rely on `whenStable` more than explicit calls to `detectChanges`. fixes #42748, #48510, #64962, #65987, #66029, #66150 (cherry picked from commit 71cde39ff04dec1fee2ae92e953b7b145a996736) --- adev/src/app/routing/sub-navigation-data.ts | 10 +- .../forms-overview/src/app/app.component.ts | 6 +- .../favorite-color.component.spec.ts | 18 +- .../favorite-color.component.ts | 2 +- .../favorite-color.component.spec.ts | 39 +- .../favorite-color.component.ts | 6 +- adev/src/content/guide/forms/overview.md | 2 +- adev/src/content/guide/routing/testing.md | 5 +- .../guide/testing/attribute-directives.md | 126 +- .../guide/testing/components-basics.md | 156 ++- .../guide/testing/components-scenarios.md | 1116 ++++++++++++----- .../testing/creating-component-harnesses.md | 8 +- adev/src/content/guide/testing/debugging.md | 14 +- .../guide/testing/migrating-to-vitest.md | 7 + adev/src/content/guide/testing/pipes.md | 51 +- adev/src/content/guide/testing/services.md | 126 +- .../src/content/guide/testing/utility-apis.md | 40 +- packages/core/testing/src/fake_async.ts | 12 +- 18 files changed, 1230 insertions(+), 514 deletions(-) diff --git a/adev/src/app/routing/sub-navigation-data.ts b/adev/src/app/routing/sub-navigation-data.ts index 8ec683d8e17..2ebf4da2c09 100644 --- a/adev/src/app/routing/sub-navigation-data.ts +++ b/adev/src/app/routing/sub-navigation-data.ts @@ -623,11 +623,6 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [ path: 'guide/testing/utility-apis', contentPath: 'guide/testing/utility-apis', }, - { - label: 'Zone.js Testing Utilities', - path: 'guide/testing/zone-js-testing-utilities', - contentPath: 'guide/testing/zone-js-testing-utilities', - }, { label: 'Component harnesses overview', path: 'guide/testing/component-harnesses-overview', @@ -658,6 +653,11 @@ const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [ path: 'guide/testing/karma', contentPath: 'guide/testing/karma', }, + { + label: 'Zone.js Testing Utilities', + path: 'guide/testing/zone-js-testing-utilities', + contentPath: 'guide/testing/zone-js-testing-utilities', + }, ], }, { diff --git a/adev/src/content/examples/forms-overview/src/app/app.component.ts b/adev/src/content/examples/forms-overview/src/app/app.component.ts index 7ad550270d7..e8248fa7626 100644 --- a/adev/src/content/examples/forms-overview/src/app/app.component.ts +++ b/adev/src/content/examples/forms-overview/src/app/app.component.ts @@ -1,12 +1,12 @@ import {Component} from '@angular/core'; -import {FavoriteColorReactiveComponent} from './reactive/favorite-color/favorite-color.component'; -import {FavoriteColorTemplateComponent} from './template/favorite-color/favorite-color.component'; +import {FavoriteColorReactive} from './reactive/favorite-color/favorite-color.component'; +import {FavoriteColorTemplate} from './template/favorite-color/favorite-color.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], - imports: [FavoriteColorTemplateComponent, FavoriteColorReactiveComponent], + imports: [FavoriteColorTemplate, FavoriteColorReactive], }) export class AppComponent { title = 'forms-intro'; diff --git a/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.spec.ts b/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.spec.ts index 522631e835a..24cbf34fd04 100644 --- a/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.spec.ts +++ b/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.spec.ts @@ -1,22 +1,16 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {createNewEvent} from '../../shared/utils'; -import {FavoriteColorReactiveComponent} from './favorite-color.component'; +import {FavoriteColorReactive} from './favorite-color.component'; describe('Favorite Color Component', () => { - let component: FavoriteColorReactiveComponent; - let fixture: ComponentFixture; + let component: FavoriteColorReactive; + let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [FavoriteColorReactiveComponent], - }); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FavoriteColorReactiveComponent); + beforeEach(async () => { + fixture = TestBed.createComponent(FavoriteColorReactive); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should create', () => { diff --git a/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.ts b/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.ts index 85f0e2f48d7..33efad518b5 100644 --- a/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.ts +++ b/adev/src/content/examples/forms-overview/src/app/reactive/favorite-color/favorite-color.component.ts @@ -6,6 +6,6 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms'; template: ` Favorite Color: `, imports: [ReactiveFormsModule], }) -export class FavoriteColorReactiveComponent { +export class FavoriteColorReactive { favoriteColorControl = new FormControl(''); } diff --git a/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.spec.ts b/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.spec.ts index 9ae630b31ae..de0673652da 100644 --- a/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.spec.ts +++ b/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.spec.ts @@ -1,22 +1,16 @@ -import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {createNewEvent} from '../../shared/utils'; -import {FavoriteColorTemplateComponent} from './favorite-color.component'; +import {FavoriteColorTemplate} from './favorite-color.component'; describe('FavoriteColorComponent', () => { - let component: FavoriteColorTemplateComponent; - let fixture: ComponentFixture; + let component: FavoriteColorTemplate; + let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [FavoriteColorTemplateComponent], - }); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FavoriteColorTemplateComponent); + beforeEach(async () => { + fixture = TestBed.createComponent(FavoriteColorTemplate); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); }); it('should create', () => { @@ -24,30 +18,27 @@ describe('FavoriteColorComponent', () => { }); // #docregion model-to-view - it('should update the favorite color on the input field', fakeAsync(() => { - component.favoriteColor = 'Blue'; + it('should update the favorite color on the input field', async () => { + component.favoriteColor.set('Blue'); - fixture.detectChanges(); - - tick(); + await fixture.whenStable(); const input = fixture.nativeElement.querySelector('input'); - expect(input.value).toBe('Blue'); - })); + }); // #enddocregion model-to-view // #docregion view-to-model - it('should update the favorite color in the component', fakeAsync(() => { + it('should update the favorite color in the component', async () => { const input = fixture.nativeElement.querySelector('input'); const event = createNewEvent('input'); input.value = 'Red'; input.dispatchEvent(event); - fixture.detectChanges(); + await fixture.whenStable(); - expect(component.favoriteColor).toEqual('Red'); - })); + expect(component.favoriteColor()).toEqual('Red'); + }); // #enddocregion view-to-model }); diff --git a/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.ts b/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.ts index f628567b9eb..de0a125415b 100644 --- a/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.ts +++ b/adev/src/content/examples/forms-overview/src/app/template/favorite-color/favorite-color.component.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, signal} from '@angular/core'; import {FormsModule} from '@angular/forms'; @Component({ @@ -6,6 +6,6 @@ import {FormsModule} from '@angular/forms'; template: ` Favorite Color: `, imports: [FormsModule], }) -export class FavoriteColorTemplateComponent { - favoriteColor = ''; +export class FavoriteColorTemplate { + favoriteColor = signal(''); } diff --git a/adev/src/content/guide/forms/overview.md b/adev/src/content/guide/forms/overview.md index 361754a20ce..ef499562ab9 100644 --- a/adev/src/content/guide/forms/overview.md +++ b/adev/src/content/guide/forms/overview.md @@ -304,7 +304,7 @@ Here are the steps performed in the model to view test. 1. Use the component instance to set the value of the `favoriteColor` property. 1. Run change detection through the test fixture. -1. Use the `tick()` method to simulate the passage of time within the `fakeAsync()` task. +1. Use `await fixture.whenStable()` to wait for the next rendering. 1. Query the view for the form input element. 1. Assert that the input value matches the value of the `favoriteColor` property in the component instance. diff --git a/adev/src/content/guide/routing/testing.md b/adev/src/content/guide/routing/testing.md index 3a9d7278395..8684a7dd1ed 100644 --- a/adev/src/content/guide/routing/testing.md +++ b/adev/src/content/guide/routing/testing.md @@ -6,8 +6,7 @@ Testing routing and navigation is essential to ensure your application behaves c This guide assumes you are familiar with the following tools and libraries: -- **[Jasmine](https://jasmine.github.io/)** - JavaScript testing framework that provides the testing syntax (`describe`, `it`, `expect`) -- **[Karma](https://karma-runner.github.io/)** - Test runner that executes tests in browsers +- **[Vitest](https://vitest.dev/)** - JavaScript testing framework that provides the testing syntax (`describe`, `it`, `expect`) - **[Angular Testing Utilities](guide/testing)** - Angular's built-in testing tools ([`TestBed`](api/core/testing/TestBed), [`ComponentFixture`](api/core/testing/ComponentFixture)) - **[`RouterTestingHarness`](api/router/testing/RouterTestingHarness)** - Test harness for testing routed components with built-in navigation and component testing capabilities @@ -326,6 +325,6 @@ export class Search { 1. **Use RouterTestingHarness** - For testing routed components, use [`RouterTestingHarness`](api/router/testing/RouterTestingHarness) which provides a cleaner API and eliminates the need for test host components. It offers direct component access, built-in navigation, and better type safety. However, it isn't as suitable for some scenarios, such as testing named outlets, where you may need to create custom host components. 2. **Handle external dependencies thoughtfully** - Prefer real implementations when possible for more realistic tests. If real implementations aren't feasible (e.g., external APIs), use fakes that approximate the real behavior. Use mocks or stubs only as a last resort, as they can make tests brittle and less reliable. 3. **Test navigation state** - Verify both the navigation action and the resulting application state, including URL changes and component rendering. -4. **Handle asynchronous operations** - Router navigation is asynchronous. Use `async/await` or [`fakeAsync`](api/core/testing/fakeAsync) to properly handle timing in your tests. +4. **Handle asynchronous operations** - Router navigation is asynchronous. Use `async/await` to properly handle timing in your tests. 5. **Test error scenarios** - Include tests for invalid routes, failed navigation, and guard rejections to ensure your application handles edge cases gracefully. 6. **Do not mock Angular Router** - Instead, provide real route configurations and use the harness to navigate. This makes your tests more robust and less likely to break on internal Angular updates, while also ensuring you catch real issues when the router updates since mocks can hide breaking changes. diff --git a/adev/src/content/guide/testing/attribute-directives.md b/adev/src/content/guide/testing/attribute-directives.md index d3b4e91735c..1613929010c 100644 --- a/adev/src/content/guide/testing/attribute-directives.md +++ b/adev/src/content/guide/testing/attribute-directives.md @@ -3,20 +3,65 @@ An _attribute directive_ modifies the behavior of an element, component or another directive. Its name reflects the way the directive is applied: as an attribute on a host element. -## Testing the `HighlightDirective` +## Testing the `Highlight` directive -The sample application's `HighlightDirective` sets the background color of an element based on either a data bound color or a default color \(lightgray\). +The sample application's `Highlight` directive sets the background color of an element based on either a data bound color or a default color \(lightgray\). It also sets a custom property of the element \(`customProperty`\) to `true` for no reason other than to show that it can. - +```ts +import {Directive, inject, input} from '@angular/core'; -It's used throughout the application, perhaps most simply in the `AboutComponent`: +/** + * Set backgroundColor for the attached element to highlight color + * and set the element's customProperty attribute to true + */ +@Directive({ + selector: '[highlight]', + host: { + '[style.backgroundColor]': 'bgColor() || defaultColor', + }, +}) +export class Highlight { + readonly defaultColor = 'rgb(211, 211, 211)'; // lightgray - + readonly bgColor = input('', {alias: 'highlight'}); +} +``` -Testing the specific use of the `HighlightDirective` within the `AboutComponent` requires only the techniques explored in the ["Nested component tests"](guide/testing/components-scenarios#nested-component-tests) section of [Component testing scenarios](guide/testing/components-scenarios). +It's used throughout the application, perhaps most simply in the `About` component: - +```ts +@Component({ + imports: [Twain, Highlight], + template: ` +

About

+

Quote of the day:

+ + `, +}) +export class About {} +``` + +Testing the specific use of the `Highlight` directive within the `About` component requires only the techniques explored in the ["Nested component tests"](guide/testing/components-scenarios#nested-component-tests) section of [Component testing scenarios](guide/testing/components-scenarios). + +```ts +let fixture: ComponentFixture; + +beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [TwainService, UserService], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + fixture = TestBed.createComponent(About); + await fixture.whenStable(); +}); + +it('should have skyblue

', () => { + const h2: HTMLElement = fixture.nativeElement.querySelector('h2'); + const bgColor = h2.style.backgroundColor; + expect(bgColor).toBe('skyblue'); +}); +``` However, testing a single use case is unlikely to explore the full range of a directive's capabilities. Finding and testing all components that use the directive is tedious, brittle, and almost as unlikely to afford full coverage. @@ -26,16 +71,75 @@ Isolated unit tests don't touch the DOM and, therefore, do not inspire confidenc A better solution is to create an artificial test component that demonstrates all ways to apply the directive. - +```angular-ts +@Component({ + imports: [Highlight], + template: ` +

Something Yellow

+

The Default (Gray)

+

No Highlight

+ + `, +}) +class Test {} +``` HighlightDirective spec in action -HELPFUL: The `` case binds the `HighlightDirective` to the name of a color value in the input box. +HELPFUL: The `` case binds the `Highlight` to the name of a color value in the input box. The initial value is the word "cyan" which should be the background color of the input box. Here are some tests of this component: - +```ts +let fixture: ComponentFixture; +let des: DebugElement[]; // the three elements w/ the directive + +beforeEach(async () => { + fixture = TestBed.createComponent(Test); + await fixture.whenStable(); + + // all elements with an attached Highlight + des = fixture.debugElement.queryAll(By.directive(Highlight)); +}); + +// color tests +it('should have three highlighted elements', () => { + expect(des.length).toBe(3); +}); + +it('should color 1st

background "yellow"', () => { + const bgColor = des[0].nativeElement.style.backgroundColor; + expect(bgColor).toBe('yellow'); +}); + +it('should color 2nd

background w/ default color', () => { + const dir = des[1].injector.get(Highlight); + const bgColor = des[1].nativeElement.style.backgroundColor; + expect(bgColor).toBe(dir.defaultColor); +}); + +it('should bind background to value color', async () => { + // easier to work with nativeElement + const input = des[2].nativeElement as HTMLInputElement; + expect(input.style.backgroundColor, 'initial backgroundColor').toBe('cyan'); + + input.value = 'green'; + + // Dispatch a DOM event so that Angular responds to the input value change. + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + + expect(input.style.backgroundColor, 'changed backgroundColor').toBe('green'); +}); + +it('bare

should not have a backgroundColor', () => { + // the h2 without the Highlight directive + const bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])')); + + expect(bareH2.styles.backgroundColor).toBeUndefined(); +}); +``` A few techniques are noteworthy: @@ -47,6 +151,6 @@ A few techniques are noteworthy: But feel free to exploit the `nativeElement` when that seems easier or more clear than the abstraction. - Angular adds a directive to the injector of the element to which it is applied. - The test for the default color uses the injector of the second `

` to get its `HighlightDirective` instance and its `defaultColor`. + The test for the default color uses the injector of the second `

` to get its `Highlight` instance and its `defaultColor`. - `DebugElement.properties` affords access to the artificial custom property that is set by the directive diff --git a/adev/src/content/guide/testing/components-basics.md b/adev/src/content/guide/testing/components-basics.md index 4341bdd006f..82dce45e089 100644 --- a/adev/src/content/guide/testing/components-basics.md +++ b/adev/src/content/guide/testing/components-basics.md @@ -17,9 +17,9 @@ Classes alone cannot tell you if the component is going to render properly, resp - Is `Lightswitch.clicked()` bound to anything such that the user can invoke it? - Is the `Lightswitch.message` displayed? -- Can the user actually select the hero displayed by `DashboardHeroComponent`? +- Can the user actually select the hero displayed by the `DashboardHero` component? - Is the hero name displayed as expected \(such as uppercase\)? -- Is the welcome message displayed by the template of `WelcomeComponent`? +- Is the welcome message displayed by the template of the `Welcome` component? These might not be troubling questions for the preceding simple components illustrated. But many components have complex interactions with the DOM elements described in their templates, causing HTML to appear and disappear as the component state changes. @@ -32,19 +32,37 @@ To write these kinds of test, you'll use additional features of the `TestBed` as The CLI creates an initial test file for you by default when you ask it to generate a new component. -For example, the following CLI command generates a `BannerComponent` in the `app/banner` folder \(with inline template and styles\): +For example, the following CLI command generates a `Banner` component in the `app/banner` folder \(with inline template and styles\): ```shell -ng generate component banner --inline-template --inline-style --module app +ng generate component banner --inline-template --inline-style ``` -It also generates an initial test file for the component, `banner-external.component.spec.ts`, that looks like this: +It also generates an initial test file for the component, `banner.spec.ts`, that looks like this: - +```ts +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Banner} from './banner'; -HELPFUL: Because `compileComponents` is asynchronous, it uses the [`waitForAsync`](api/core/testing/waitForAsync) utility function imported from `@angular/core/testing`. +describe('Banner', () => { + let component: Banner; + let fixture: ComponentFixture; -Refer to the [waitForAsync](guide/testing/components-scenarios#waitForAsync) section for more details. + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Banner], + }).compileComponents(); + + fixture = TestBed.createComponent(Banner); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +``` ### Reduce the setup @@ -55,25 +73,30 @@ The rest of the file is boilerplate setup code anticipating more advanced tests You'll learn about these advanced test features in the following sections. For now, you can radically reduce this test file to a more manageable size: - - -In this example, the metadata object passed to `TestBed.configureTestingModule` simply declares `BannerComponent`, the component to test. - - - -HELPFUL: There's no need to declare or import anything else. -The default test module is pre-configured with something like the `BrowserModule` from `@angular/platform-browser`. +```ts +describe('Banner (minimal)', () => { + it('should create', () => { + const fixture = TestBed.createComponent(Banner); + const component = fixture.componentInstance; + expect(component).toBeDefined(); + }); +}); +``` Later you'll call `TestBed.configureTestingModule()` with imports, providers, and more declarations to suit your testing needs. Optional `override` methods can further fine-tune aspects of the configuration. +NOTE: `TestBed.compileComponents` is only required when `@defer` blocks are used in the tested components. + ### `createComponent()` After configuring `TestBed`, you call its `createComponent()` method. - +```ts +const fixture = TestBed.createComponent(Banner); +``` -`TestBed.createComponent()` creates an instance of the `BannerComponent`, adds a corresponding element to the test-runner DOM, and returns a [`ComponentFixture`](#componentfixture). +`TestBed.createComponent()` creates an instance of the `Banner` component, adds a corresponding element to the test-runner DOM, and returns a [`ComponentFixture`](#componentfixture). IMPORTANT: Do not re-configure `TestBed` after calling `createComponent`. @@ -84,22 +107,62 @@ If you try, `TestBed` throws an error. ### `ComponentFixture` -The [ComponentFixture](api/core/testing/ComponentFixture) is a test harness for interacting with the created component and its corresponding element. +The [`ComponentFixture`](api/core/testing/ComponentFixture) is a test harness for interacting with the created component and its corresponding element. -Access the component instance through the fixture and confirm it exists with a Jasmine expectation: +Access the component instance through the fixture and confirm it exists with an expectation: - +```ts +const component = fixture.componentInstance; +expect(component).toBeDefined(); +``` ### `beforeEach()` You will add more tests as this component evolves. -Rather than duplicate the `TestBed` configuration for each test, you refactor to pull the setup into a Jasmine `beforeEach()` and some supporting variables: +Rather than duplicate the `TestBed` configuration for each test, you refactor to pull the setup into a `beforeEach()` and some supporting variables: - +```ts +describe('Banner (with beforeEach)', () => { + let component: Banner; + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(Banner); + component = fixture.componentInstance; + + await fixture.whenStable(); // necessary to wait for the initial rendering + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); +}); +``` + +HELPFUL: By awaiting the initial rendering in the `beforeEach` with `await fixture.whenStable` the single tests synchronous. Now add a test that gets the component's element from `fixture.nativeElement` and looks for the expected text. - +```ts +it('should contain "banner works!"', () => { + const bannerElement: HTMLElement = fixture.nativeElement; + expect(bannerElement.textContent).toContain('banner works!'); +}); +``` + +### create a `setup` function + +As an alternative to `beforeEach`, you can also create a setup function that you will call in every test. +A setup function has the advantage of being customizable via parameters. + +Here is an example of what a setup function could look like: + +```ts +function setup(providers?: StaticProviders[]): ComponentFixture { + TestBed.configureTestingModule({providers}); + return TestBed.createComponent(Banner); +} +``` ### `nativeElement` @@ -107,7 +170,7 @@ The value of `ComponentFixture.nativeElement` has the `any` type. Later you'll encounter the `DebugElement.nativeElement` and it too has the `any` type. Angular can't know at compile time what kind of HTML element the `nativeElement` is or if it even is an HTML element. -The application might be running on a _non-browser platform_, such as the server or a [Web Worker](https://developer.mozilla.org/docs/Web/API/Web_Workers_API), where the element might have a diminished API or not exist at all. +The application might be running on a _non-browser platform_, such as the server or a node environment, where the element might have a diminished API or not exist at all. The tests in this guide are designed to run in a browser so a `nativeElement` value will always be an `HTMLElement` or one of its derived classes. @@ -115,17 +178,28 @@ Knowing that it is an `HTMLElement` of some sort, use the standard HTML `querySe Here's another test that calls `HTMLElement.querySelector` to get the paragraph element and look for the banner text: - +```ts +it('should have

with "banner works!"', () => { + const bannerElement: HTMLElement = fixture.nativeElement; + const p = bannerElement.querySelector('p')!; + expect(p.textContent).toEqual('banner works!'); +}); +``` ### `DebugElement` The Angular _fixture_ provides the component's element directly through the `fixture.nativeElement`. - +```ts +const bannerElement: HTMLElement = fixture.nativeElement; +``` This is actually a convenience method, implemented as `fixture.debugElement.nativeElement`. - +```ts +const bannerDe: DebugElement = fixture.debugElement; +const bannerEl: HTMLElement = bannerDe.nativeElement; +``` There's a good reason for this circuitous path to the element. @@ -140,13 +214,22 @@ Because the sample tests for this guide are designed to run only in a browser, a Here's the previous test, re-implemented with `fixture.debugElement.nativeElement`: - +```ts +it('should find the

with fixture.debugElement.nativeElement', () => { + const bannerDe: DebugElement = fixture.debugElement; + const bannerEl: HTMLElement = bannerDe.nativeElement; + const p = bannerEl.querySelector('p')!; + expect(p.textContent).toEqual('banner works!'); +}); +``` The `DebugElement` has other methods and properties that are useful in tests, as you'll see elsewhere in this guide. You import the `DebugElement` symbol from the Angular core library. - +```ts +import {DebugElement} from '@angular/core'; +``` ### `By.css()` @@ -162,11 +245,20 @@ These query methods take a _predicate_ function that returns `true` when a node You create a _predicate_ with the help of a `By` class imported from a library for the runtime platform. Here's the `By` import for the browser platform: - +```ts +import {By} from '@angular/platform-browser'; +``` The following example re-implements the previous test with `DebugElement.query()` and the browser's `By.css` method. - +```ts +it('should find the

with fixture.debugElement.query(By.css)', () => { + const bannerDe: DebugElement = fixture.debugElement; + const paragraphDe = bannerDe.query(By.css('p')); + const p: HTMLElement = paragraphDe.nativeElement; + expect(p.textContent).toEqual('banner works!'); +}); +``` Some noteworthy observations: diff --git a/adev/src/content/guide/testing/components-scenarios.md b/adev/src/content/guide/testing/components-scenarios.md index 8cc4023f015..a2699bd4b3d 100644 --- a/adev/src/content/guide/testing/components-scenarios.md +++ b/adev/src/content/guide/testing/components-scenarios.md @@ -4,11 +4,22 @@ This guide explores common component testing use cases. ## Component binding -In the example application, the `BannerComponent` presents static title text in the HTML template. +In the example application, the `Banner` component presents static title text in the HTML template. -After a few changes, the `BannerComponent` presents a dynamic title by binding to the component's `title` property like this. +After a few changes, the `Banner` component presents a dynamic title by binding to the component's `title` property like this. - +```angular-ts {header="banner.ts"} +import {Component, signal} from '@angular/core'; + +@Component({ + selector: 'app-banner', + template: '

{{ title() }}

', + styles: ['h1 { color: green; font-size: 350%}'], +}) +export class Banner { + title = signal('Test Tour of Heroes'); +} +``` As minimal as this is, you decide to add a test to confirm that component actually displays the right content where you think it should. @@ -18,75 +29,86 @@ You'll write a sequence of tests that inspect the value of the `

` element th You update the `beforeEach` to find that element with a standard HTML `querySelector` and assign it to the `h1` variable. - +```ts {header: "banner.component.spec.ts"} +let component: Banner; +let fixture: ComponentFixture; +let h1: HTMLElement; + +beforeEach(() => { + fixture = TestBed.createComponent(Banner); + component = fixture.componentInstance; // Banner test instance + h1 = fixture.nativeElement.querySelector('h1'); +}); +``` ### `createComponent()` does not bind data For your first test you'd like to see that the screen displays the default `title`. Your instinct is to write a test that immediately inspects the `

` like this: - +```ts +it('should display original title', () => { + expect(h1.textContent).toContain(component.title()); +}); +``` _That test fails_ with the message: - - +```shell {hideCopy} expected '' to contain 'Test Tour of Heroes'. - - +``` Binding happens when Angular performs **change detection**. In production, change detection kicks in automatically when Angular creates a component or the user enters a keystroke, for example. -The `TestBed.createComponent` does not trigger change detection by default; a fact confirmed in the revised test: +The `TestBed.createComponent` does not trigger change detection synchronously; a fact confirmed in the revised test: - +```ts +it('no title in the DOM after createComponent()', () => { + expect(h1.textContent).toEqual(''); +}); +``` -### `detectChanges()` +### `whenStable()` -You can tell the `TestBed` to perform data binding by calling `fixture.detectChanges()`. +You can tell the `TestBed` to wait for change detection to run with `await fixture.whenStable()`. Only then does the `

` have the expected title. - +```ts +it('should display original title', async () => { + await fixture.whenStable(); + expect(h1.textContent).toContain(component.title()); +}); +``` Delayed change detection is intentional and useful. It gives the tester an opportunity to inspect and change the state of the component _before Angular initiates data binding and calls [lifecycle hooks](guide/components/lifecycle)_. -Here's another test that changes the component's `title` property _before_ calling `fixture.detectChanges()`. +Here's another test that changes the component's `title` property _before_ calling `fixture.whenStable()`. - +```ts +it('should display a different test title', async () => { + component.title.set('Test Title'); + await fixture.whenStable(); + expect(h1.textContent).toContain('Test Title'); +}); +``` -### Automatic change detection +### Binding signals to inputs -The `BannerComponent` tests frequently call `detectChanges`. -Many testers prefer that the Angular test environment run change detection automatically like it does in production. +To reflect changes to inputs and listen to outputs you can dynamically bind signals to inputs and functions to outputs. -That's possible by configuring the `TestBed` with the `ComponentFixtureAutoDetect` provider. -First import it from the testing utility library: +```ts +import {inputBinding, outputBinding} from '@angular/core'; - - -Then add it to the `providers` array of the testing module configuration: - - - -HELPFUL: You can also use the `fixture.autoDetectChanges()` function instead if you only want to enable automatic change detection -after making updates to the state of the fixture's component. In addition, automatic change detection is on by default -when using `provideZonelessChangeDetection` and turning it off is not recommended. - -Here are three tests that illustrate how automatic change detection works. - - - -The first test shows the benefit of automatic change detection. - -The second and third test reveal an important limitation. -The Angular testing environment does not run change detection synchronously when updates happen inside the test case that changed the component's `title`. -The test must call `await fixture.whenStable` to wait for another round of change detection. - -HELPFUL: Angular does not know about direct updates to values that are not signals. The easiest way to ensure that -change detection will be scheduled is to use signals for values read in the template. +const fixture = TestBed.createComponent(ValueDisplay, { + bindings: [ + inputBinding('value', value), + outputBinding('valueChange', () => (/* ... */) ), + ], +}); +``` ### Change an input value with `dispatchEvent()` @@ -97,59 +119,63 @@ But there is an essential, intermediate step. Angular doesn't know that you set the input element's `value` property. It won't read that property until you raise the element's `input` event by calling `dispatchEvent()`. -The following example demonstrates the proper sequence. +The following example of a component using the `TitleCasePipe` demonstrates the proper sequence. - +```ts +it('should convert hero name to Title Case', async () => { + const hostElement = fixture.nativeElement; + const nameInput: HTMLInputElement = hostElement.querySelector('input')!; + const nameDisplay: HTMLElement = hostElement.querySelector('span')!; -## Component with external files + // simulate user entering a new name into the input box + nameInput.value = 'quick BROWN fOx'; -The preceding `BannerComponent` is defined with an _inline template_ and _inline css_, specified in the `@Component.template` and `@Component.styles` properties respectively. + // Dispatch a DOM event so that Angular learns of input value change. + nameInput.dispatchEvent(new Event('input')); -Many components specify _external templates_ and _external css_ with the `@Component.templateUrl` and `@Component.styleUrls` properties respectively, as the following variant of `BannerComponent` does. - - - -This syntax tells the Angular compiler to read the external files during component compilation. - -That's not a problem when you run the CLI `ng test` command because it _compiles the application before running the tests_. - -However, if you run the tests in a **non-CLI environment**, tests of this component might fail. -For example, if you run the `BannerComponent` tests in a web coding environment such as [plunker](https://plnkr.co), you'll see a message like this one: - -```shell {hideCopy} - -Error: This test module uses the component BannerComponent -which is using a "templateUrl" or "styleUrls", but they were never compiled. -Please call "TestBed.compileComponents" before your test. + // Wait for Angular to update the display binding through the title pipe + await fixture.whenStable(); + expect(nameDisplay.textContent).toBe('Quick Brown Fox'); +}); ``` -You get this test failure message when the runtime environment compiles the source code _during the tests themselves_. - -To correct the problem, call `compileComponents()`. - ## Component with a dependency Components often have service dependencies. -The `WelcomeComponent` displays a welcome message to the logged-in user. -It knows who the user is based on a property of the injected `UserService`: +The `Welcome` component displays a welcome message to the logged-in user. +It knows who the user is based on a property of the injected `UserAuthentication`: - +```angular-ts +import {Component, inject, OnInit, signal} from '@angular/core'; +import {UserAuthentication} from '../model/user.authentication'; -The `WelcomeComponent` has decision logic that interacts with the service, logic that makes this component worth testing. +@Component({ + selector: 'app-welcome', + template: '

{{ welcome() }}

', +}) +export class Welcome { + private userAuth = inject(UserAuthentication); + welcome = signal( + this.userAuth.isLoggedIn() ? `Welcome, ${this.userAuth.user().name}` : 'Please log in.', + ); +} +``` + +The `Welcome` component has decision logic that interacts with the service, logic that makes this component worth testing. ### Provide service test doubles A _component-under-test_ doesn't have to be provided with real services. -Injecting the real `UserService` could be difficult. +Injecting the real `UserAuthentication` could be difficult. The real service might ask the user for login credentials and attempt to reach an authentication server. These behaviors can be hard to intercept. Be aware that using test doubles makes the test behave differently from production so use them sparingly. ### Get injected services -The tests need access to the `UserService` injected into the `WelcomeComponent`. +The tests need access to the `UserAuthentication` injected into the `Welcome` component. Angular has a hierarchical injection system. There can be injectors at multiple levels, from the root injector created by the `TestBed` down through the component tree. @@ -158,7 +184,10 @@ The safest way to get the injected service, the way that **_always works_**, is to **get it from the injector of the _component-under-test_**. The component injector is a property of the fixture's `DebugElement`. - +```ts +// UserAuthentication actually injected into the component +userAuth = fixture.debugElement.injector.get(UserAuthentication); +``` HELPFUL: This is _usually_ not necessary. Services are often provided in the root or the TestBed overrides and can be retrieved more easily with `TestBed.inject()` (see below). @@ -166,9 +195,11 @@ HELPFUL: This is _usually_ not necessary. Services are often provided in the roo This is easier to remember and less verbose than retrieving a service using the fixture's `DebugElement`. -In this test suite, the _only_ provider of `UserService` is the root testing module, so it is safe to call `TestBed.inject()` as follows: +In this test suite, the _only_ provider of `UserAuthentication` is the root testing module, so it is safe to call `TestBed.inject()` as follows: - +```ts +userAuth = TestBed.inject(UserAuthentication); +``` HELPFUL: For a use case in which `TestBed.inject()` does not work, see the [_Override component providers_](#override-component-providers) section that explains when and why you must get the service from the component's injector instead. @@ -176,16 +207,56 @@ HELPFUL: For a use case in which `TestBed.inject()` does not work, see the [_Ove Here's the complete `beforeEach()`, using `TestBed.inject()`: - +```ts +let fixture: ComponentFixture; +let comp: Welcome; +let userAuth: UserAuthentication; // the TestBed injected service +let el: HTMLElement; // the DOM element with the welcome message + +beforeEach(() => { + fixture = TestBed.createComponent(Welcome); + comp = fixture.componentInstance; + + // UserAuthentication from the root injector + userAuth = TestBed.inject(UserAuthentication); + + // get the "welcome" element by CSS selector (e.g., by class name) + el = fixture.nativeElement.querySelector('.welcome'); +}); +``` And here are some tests: - +```ts +it('should welcome the user', async () => { + await fixture.whenStable(); + const content = el.textContent; -The first is a sanity test; it confirms that the `UserService` is called and working. + expect(content, '"Welcome ..."').toContain('Welcome'); + expect(content, 'expected name').toContain('Test User'); +}); -HELPFUL: The withContext function \(for example, `'expected name'`\) is an optional failure label. -If the expectation fails, Jasmine appends this label to the expectation failure message. +it('should welcome "Bubba"', async () => { + userAuth.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet + await fixture.whenStable(); + + expect(el.textContent).toContain('Bubba'); +}); + +it('should request login if not logged in', async () => { + userAuth.isLoggedIn.set(false); // welcome message hasn't been shown yet + await fixture.whenStable(); + const content = el.textContent; + + expect(content, 'not welcomed').not.toContain('Welcome'); + expect(content, '"log in"').toMatch(/log in/i); +}); +``` + +The first is a sanity test; it confirms that the `UserAuthentication` is called and working. + +HELPFUL: The 2nd argument of `expect` \(for example, `'expected name'`\) is an optional failure label. +If the expectation fails, Vitest appends this label to the expectation failure message. In a spec with multiple expectations, it can help clarify what went wrong and which expectation failed. The remaining tests confirm the logic of the component when the service returns different values. @@ -194,216 +265,188 @@ The third test checks that the component displays the proper message when there ## Component with async service -In this sample, the `AboutComponent` template hosts a `TwainComponent`. -The `TwainComponent` displays Mark Twain quotes. +In this sample, the `About` component template hosts a `Twain` component. +The `Twain` component displays Mark Twain quotes. - +```angular-html +

+ {{ quote | async }} +

+ +@if (errorMessage()) { +

{{ errorMessage() }}

+} +``` HELPFUL: The value of the component's `quote` property passes through an `AsyncPipe`. That means the property returns either a `Promise` or an `Observable`. -In this example, the `TwainComponent.getQuote()` method tells you that the `quote` property returns an `Observable`. +In this example, the `TwainQuotes.getQuote()` method tells you that the `quote` property returns an `Observable`. - +```ts +getQuote() { + this.errorMessage.set(''); + this.quote = this.twainQuotes.getQuote().pipe( + startWith('...'), + catchError((err: any) => { + this.errorMessage.set(err.message || err.toString()); + return of('...'); // reset message to placeholder + }), + ); +} +``` -The `TwainComponent` gets quotes from an injected `TwainService`. +The `Twain` component gets quotes from an injected `TwainQuotes`. The component starts the returned `Observable` with a placeholder value \(`'...'`\), before the service can return its first quote. The `catchError` intercepts service errors, prepares an error message, and returns the placeholder value on the success channel. These are all features you'll want to test. -### Testing with a spy +### Testing by mocking http requests with the `HttpTestingController`. When testing a component, only the service's public API should matter. In general, tests themselves should not make calls to remote servers. They should emulate such calls. -The setup in this `app/twain/twain.component.spec.ts` shows one way to do that: - +In the case your async service relies on the `HttpClient` to load remote data, it is recommended to return mock responses at the HTTP level with the `HttpTestingController`. -Focus on the spy. +For more details on mocking the `HttpBackend`, refer to the [dedicated guide](guide/http/testing). - +### Testing by providing a stubbed implementation of a service. -The spy is designed such that any call to `getQuote` receives an observable with a test quote. -Unlike the real `getQuote()` method, this spy bypasses the server and returns a synchronous observable whose value is available immediately. +When mocking async request at the http level isn't possible, an alternative is to leverage spies. -You can write many useful tests with this spy, even though its `Observable` is synchronous. +The setup in this `app/twain/twain-quotes.spec.ts` shows one way to do that: -HELPFUL: It is best to limit the usage of spies to only what is necessary for the test. Creating mocks or spies for more than what's necessary can be brittle. As the component and injectable evolves, the unrelated tests can fail because they no longer mock enough behaviors that would otherwise not affect the test. +```ts {header: "twain.spec.ts"} +class TwainQuotesStub implements TwainQuotes { + private testQuote = 'Test Quote'; -### Async test with `fakeAsync()` + getQuote() { + return of(this.testQuote); + } -To use `fakeAsync()` functionality, you must import `zone.js/testing` in your test setup file. -If you created your project with the Angular CLI, `zone-testing` is already imported in `src/test.ts`. + // ... Implement everything to conform to the API +} -The following test confirms the expected behavior when the service returns an `ErrorObservable`. +beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}], + }); - - -HELPFUL: The `it()` function receives an argument of the following form. - - - -fakeAsync(() => { /_test body_/ }) - - - -The `fakeAsync()` function enables a linear coding style by running the test body in a special `fakeAsync test zone`. -The test body appears to be synchronous. -There is no nested syntax \(like a `Promise.then()`\) to disrupt the flow of control. - -HELPFUL: Limitation: The `fakeAsync()` function won't work if the test body makes an `XMLHttpRequest` \(XHR\) call. -XHR calls within a test are rare, but if you need to call XHR, use `waitForAsync()`. - -IMPORTANT: Be aware that asynchronous tasks that happen inside the `fakeAsync` zone need to be manually executed with `flush` or `tick`. If you attempt to -wait for them to complete (i.e. using `fixture.whenStable`) without using the -`fakeAsync` test helpers to advance time, your test will likely fail. See below for more information. - -### The `tick()` function - -You do have to call [tick()](api/core/testing/tick) to advance the virtual clock. - -Calling [tick()](api/core/testing/tick) simulates the passage of time until all pending asynchronous activities finish. -In this case, it waits for the observable's `setTimeout()`. - -The [tick()](api/core/testing/tick) function accepts `millis` and `tickOptions` as parameters. The `millis` parameter specifies how much the virtual clock advances and defaults to `0` if not provided. -For example, if you have a `setTimeout(fn, 100)` in a `fakeAsync()` test, you need to use `tick(100)` to trigger the fn callback. -The optional `tickOptions` parameter has a property named `processNewMacroTasksSynchronously`. The `processNewMacroTasksSynchronously` property represents whether to invoke new generated macro tasks when ticking and defaults to `true`. - - - -The [tick()](api/core/testing/tick) function is one of the Angular testing utilities that you import with `TestBed`. -It's a companion to `fakeAsync()` and you can only call it within a `fakeAsync()` body. - -### tickOptions - -In this example, you have a new macro task, the nested `setTimeout` function. By default, when the `tick` is setTimeout, `outside` and `nested` will both be triggered. - - - -In some case, you don't want to trigger the new macro task when ticking. You can use `tick(millis, {processNewMacroTasksSynchronously: false})` to not invoke a new macro task. - - - -### Comparing dates inside fakeAsync() - -`fakeAsync()` simulates passage of time, which lets you calculate the difference between dates inside `fakeAsync()`. - - - -### jasmine.clock with fakeAsync() - -Jasmine also provides a `clock` feature to mock dates. -Angular automatically runs tests that are run after `jasmine.clock().install()` is called inside a `fakeAsync()` method until `jasmine.clock().uninstall()` is called. -`fakeAsync()` is not needed and throws an error if nested. - -By default, this feature is disabled. -To enable it, set a global flag before importing `zone-testing`. - -If you use the Angular CLI, configure this flag in `src/test.ts`. - -```ts -[window as any]('__zone_symbol__fakeAsyncPatchLock') = true; -import 'zone.js/testing'; + fixture = TestBed.createComponent(Twain); + component = fixture.componentInstance; + await fixture.whenStable(); + quoteEl = fixture.nativeElement.querySelector('.twain'); +}); ``` - +Focus on the how the stub implementation replaces the original one. -### Using the RxJS scheduler inside fakeAsync() +```ts +TestBed.configureTestingModule({ + providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}], +}); +``` -You can also use RxJS scheduler in `fakeAsync()` just like using `setTimeout()` or `setInterval()`, but you need to import `zone.js/plugins/zone-patch-rxjs-fake-async` to patch RxJS scheduler. +The stub is designed in such a way that any component or service that injects it will receive the stubbed implementation. +It means that any call to `getQuote` receives an observable with a test quote. - +Unlike the real `getQuote()` method, this spy bypasses the server and returns a synchronous observable whose value is available immediately. -### Support more macroTasks +### Async test with a Vitest fake timers -By default, `fakeAsync()` supports the following macro tasks. +To mock async functions like `setTimeout` or `Promise`s, you can leverage Vitest fake timers to controle whenever the fire. -- `setTimeout` -- `setInterval` -- `requestAnimationFrame` -- `webkitRequestAnimationFrame` -- `mozRequestAnimationFrame` +```ts +it('should display error when TwainQuotes service fails', async () => { + class TwainQuotesStub implements TwainQuotes { + getQuote() { + return defer(() => { + return new Promise((_, reject) => { + setTimeout(() => reject('TwainService test failure')); + }); + }); + } -If you run other macro tasks such as `HTMLCanvasElement.toBlob()`, an _"Unknown macroTask scheduled in fake async test"_ error is thrown. + // ... Implement everything to conform to the API + } - - - - + TestBed.configureTestingModule({ + providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}], + }); -If you want to support such a case, you need to define the macro task you want to support in `beforeEach()`. -For example: + vi.useFakeTimers(); // setting up the fake timers + const fixture = TestBed.createComponent(TwainComponent); - + // rendering isn't async, we need to flush + await vi.runAllTimersAsync(); -HELPFUL: In order to make the `` element Zone.js-aware in your app, you need to import the `zone-patch-canvas` patch \(either in `polyfills.ts` or in the specific file that uses ``\): + await expect(fixture.nativeElement.querySelector('.error')!.textContent).toMatch(/test failure/); + expect(fixture.nativeElement.querySelector('.twain')!.textContent).toBe('...'); - - -### Async observables - -You might be satisfied with the test coverage of these tests. - -However, you might be troubled by the fact that the real service doesn't quite behave this way. -The real service sends requests to a remote server. -A server takes time to respond and the response certainly won't be available immediately as in the previous two tests. - -Your tests will reflect the real world more faithfully if you return an _asynchronous_ observable from the `getQuote()` spy like this. - - - -### Async observable helpers - -The async observable was produced by an `asyncData` helper. -The `asyncData` helper is a utility function that you'll have to write yourself, or copy this one from the sample code. - - - -This helper's observable emits the `data` value in the next turn of the JavaScript engine. - -The [RxJS `defer()` operator](http://reactivex.io/documentation/operators/defer.html) returns an observable. -It takes a factory function that returns either a promise or an observable. -When something subscribes to _defer_'s observable, it adds the subscriber to a new observable created with that factory. - -The `defer()` operator transforms the `Promise.resolve()` into a new observable that, like `HttpClient`, emits once and completes. -Subscribers are unsubscribed after they receive the data value. - -There's a similar helper for producing an async error. - - + vi.useRealTimers(); // resets to regular async execution +}); +``` ### More async tests -Now that the `getQuote()` spy is returning async observables, most of your tests will have to be async as well. +With the stubbe service returning async observables, most of your tests will have to be async as well. -Here's a `fakeAsync()` test that demonstrates the data flow you'd expect in the real world. +Here's a test that demonstrates the data flow you'd expect in the real world. - +```ts +it('should show quote after getQuote', async () => { + class MockTwainQuotes implements TwainQuotes { + private subject = new Subject(); -Notice that the quote element displays the placeholder value \(`'...'`\) after `ngOnInit()`. + getQuote() { + return this.subject.asObservable(); + } + + emit(val: string) { + this.subject.next(val); + } + } + + it('should show quote after getQuote (success)', async () => { + vi.useFakeTimers(); + + TestBed.configureTestingModule({ + providers: [{provide: TwainQuotes, useClass: MockTwainQuotes}], + }); + + const fixture = TestBed.createComponent(TwainComponent); + const twainQuotes = TestBed.inject(TwainQuotes) as MockTwainQuotes; + await vi.runAllTimersAsync(); // render before the quote is recieved + + const quoteEl = fixture.nativeElement.querySelector('.twain'); + expect(quoteEl.textContent).toBe('...'); + + twainQuotes.emit('Twain Quote'); // emits the quote + await vi.runAllTimersAsync(); // render with the quote received + + expect(quoteEl.textContent).toBe('Twain Quote'); + expect(fixture.nativeElement.querySelector('.error')).toBeNull(); + + vi.useRealTimers(); + }); +}); +``` + +Notice that the quote element displays the placeholder value \(`'...'`\) on first rendering. The first quote hasn't arrived yet. -To flush the first quote from the observable, you call [tick()](api/core/testing/tick). -Then call `detectChanges()` to tell Angular to update the screen. - Then you can assert that the quote element displays the expected text. -### Async test without `fakeAsync()` +### Async tests with `zone.js` and `fakeAsync` -Here's the previous `fakeAsync()` test, re-written with the `async`. +The `fakeAsync` helper function is another mock clock that relies on patching asynchronous APIs with `zone.js`. It was commonly used in `zone.js` based applications for testing. The use of `fakeAsync` is no longer recommended. - +TIP: Prefer using native async testing strategies or other fake timers (also called mock clocks) like those from Vitest or Jasmine. -### `whenStable` - -The test must wait for the `getQuote()` observable to emit the next quote. -Instead of calling [tick()](api/core/testing/tick), it calls `fixture.whenStable()`. - -The `fixture.whenStable()` returns a promise that resolves when the JavaScript engine's task queue becomes empty. -In this example, the task queue becomes empty when the observable emits the first quote. +IMPORTANT: `fakeAsync` cannot be used with the Vitest test runner as no `zone.js` patch is applied for this runner. ## Component with inputs and outputs @@ -413,48 +456,107 @@ The host uses a property binding to set the input property and an event binding The testing goal is to verify that such bindings work as expected. The tests should set input values and listen for output events. -The `DashboardHeroComponent` is a tiny example of a component in this role. -It displays an individual hero provided by the `DashboardComponent`. -Clicking that hero tells the `DashboardComponent` that the user has selected the hero. +The `DashboardHero` component is a tiny example of a component in this role. +It displays an individual hero provided by the `Dashboard` component. +Clicking that hero tells the `Dashboard` component that the user has selected the hero. -The `DashboardHeroComponent` is embedded in the `DashboardComponent` template like this: +The `DashboardHero` component is embedded in the `Dashboard` component template like this: - +```angular-html +@for (hero of heroes; track hero) { + +} +``` -The `DashboardHeroComponent` appears in an `@for` block, which sets each component's `hero` input property to the looping value and listens for the component's `selected` event. +The `DashboardHero` component appears in an `@for` block, which sets each component's `hero` input property to the looping value and listens for the component's `selected` event. Here's the component's full definition: - +```angular-ts +@Component({ + selector: 'dashboard-hero', + imports: [UpperCasePipe], + template: ` + + `, +}) +export class DashboardHero { + readonly hero = input.required(); + readonly selected = output(); + + click() { + this.selected.emit(this.hero()); + } +} +``` While testing a component this simple has little intrinsic value, it's worth knowing how. Use one of these approaches: -- Test it as used by `DashboardComponent` +- Test it as used by the `Dashboard` component - Test it as a standalone component -- Test it as used by a substitute for `DashboardComponent` +- Test it as used by a substitute for the `Dashboard` component -The immediate goal is to test the `DashboardHeroComponent`, not the `DashboardComponent`, so, try the second and third options. +The immediate goal is to test the `DashboardHero` component, not the `Dashboard` component, so, try the second and third options. -### Test `DashboardHeroComponent` standalone +### Test the `DashboardHero` component standalone Here's the meat of the spec file setup. - +```ts +let fixture: ComponentFixture; +let comp: DashboardHero; +let heroDe: DebugElement; +let heroEl: HTMLElement; +let expectedHero: Hero; -Notice how the setup code assigns a test hero \(`expectedHero`\) to the component's `hero` property, emulating the way the `DashboardComponent` would set it using the property binding in its repeater. +beforeEach(async () => { + fixture = TestBed.createComponent(DashboardHero); + comp = fixture.componentInstance; + + // find the hero's DebugElement and element + heroDe = fixture.debugElement.query(By.css('.hero')); + heroEl = heroDe.nativeElement; + + // mock the hero supplied by the parent component + expectedHero = {id: 42, name: 'Test Name'}; + + // simulate the parent setting the input property with that hero + fixture.componentRef.setInput('hero', expectedHero); + + // wait for initial data binding + await fixture.whenStable(); +}); +``` + +Notice how the setup code assigns a test hero \(`expectedHero`\) to the component's `hero` property, emulating the way the `Dashboard` would set it using the property binding in its repeater. The following test verifies that the hero name is propagated to the template using a binding. - +```ts +it('should display hero name in uppercase', () => { + const expectedPipedName = expectedHero.name.toUpperCase(); + expect(heroEl.textContent).toContain(expectedPipedName); +}); +``` Because the template passes the hero name through the Angular `UpperCasePipe`, the test must match the element value with the upper-cased name. ### Clicking -Clicking the hero should raise a `selected` event that the host component \(`DashboardComponent` presumably\) can hear: +Clicking the hero should raise a `selected` event that the host component \(`Dashboard` presumably\) can hear: - +```ts +it('should raise selected event when clicked (triggerEventHandler)', () => { + let selectedHero: Hero | undefined; + comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); + + heroDe.triggerEventHandler('click'); + expect(selectedHero).toBe(expectedHero); +}); +``` The component's `selected` property returns an `EventEmitter`, which looks like an RxJS synchronous `Observable` to consumers. The test subscribes to it _explicitly_ just as the host component does _implicitly_. @@ -469,14 +571,16 @@ The `heroDe` in the previous test is a `DebugElement` that represents the hero ` It has Angular properties and methods that abstract interaction with the native element. This test calls the `DebugElement.triggerEventHandler` with the "click" event name. -The "click" event binding responds by calling `DashboardHeroComponent.click()`. +The "click" event binding responds by calling `DashboardHero.click()`. The Angular `DebugElement.triggerEventHandler` can raise _any data-bound event_ by its _event name_. The second parameter is the event object passed to the handler. The test triggered a "click" event. - +```ts +heroDe.triggerEventHandler('click'); +``` In this case, the test correctly assumes that the runtime event handler, the component's `click()` method, doesn't care about the event object. @@ -488,7 +592,15 @@ The `RouterLink` directive throws an error if the event object is missing. The following test alternative calls the native element's own `click()` method, which is perfectly fine for _this component_. - +```ts +it('should raise selected event when clicked (element.click)', () => { + let selectedHero: Hero | undefined; + comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); + + heroEl.click(); + expect(selectedHero).toBe(expectedHero); +}); +``` ### `click()` helper @@ -496,7 +608,25 @@ Clicking a button, an anchor, or an arbitrary HTML element is a common test task Make that consistent and straightforward by encapsulating the _click-triggering_ process in a helper such as the following `click()` function: - +```ts +/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */ +export const ButtonClickEvents = { + left: {button: 0}, + right: {button: 2}, +}; + +/** Simulate element click. Defaults to mouse left-button click event. */ +export function click( + el: DebugElement | HTMLElement, + eventObj: any = ButtonClickEvents.left, +): void { + if (el instanceof HTMLElement) { + el.click(); + } else { + el.triggerEventHandler('click', eventObj); + } +} +``` The first parameter is the _element-to-click_. If you want, pass a custom event object as the second parameter. @@ -509,69 +639,140 @@ If you like it, add it to your own collection of helpers. Here's the previous test, rewritten using the click helper. - +```ts +it('should raise selected event when clicked (click helper with DebugElement)', () => { + let selectedHero: Hero | undefined; + comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); + + click(heroDe); // click helper with DebugElement + + expect(selectedHero).toBe(expectedHero); +}); +``` ## Component inside a test host -The previous tests played the role of the host `DashboardComponent` themselves. -But does the `DashboardHeroComponent` work correctly when properly data-bound to a host component? +The previous tests played the role of the host `Dashboard` component themselves. +But does the `DashboardHero` component work correctly when properly data-bound to a host component? - +```angular-ts +@Component({ + imports: [DashboardHero], + template: ` `, +}) +class TestHost { + hero: Hero = {id: 42, name: 'Test Name'}; + selectedHero: Hero | undefined; + + onSelected(hero: Hero) { + this.selectedHero = hero; + } +} +``` The test host sets the component's `hero` input property with its test hero. It binds the component's `selected` event with its `onSelected` handler, which records the emitted hero in its `selectedHero` property. -Later, the tests will be able to check `selectedHero` to verify that the `DashboardHeroComponent.selected` event emitted the expected hero. +Later, the tests will be able to check `selectedHero` to verify that the `DashboardHero.selected` event emitted the expected hero. The setup for the `test-host` tests is similar to the setup for the stand-alone tests: - +```ts +beforeEach(async () => { + // create TestHost instead of DashboardHero + fixture = TestBed.createComponent(TestHost); + testHost = fixture.componentInstance; + heroEl = fixture.nativeElement.querySelector('.hero'); + + await fixture.whenStable(); +}); +``` This testing module configuration shows two important differences: -- It _creates_ the `TestHostComponent` instead of the `DashboardHeroComponent` -- The `TestHostComponent` sets the `DashboardHeroComponent.hero` with a binding +- It _creates_ the `TestHost` component instead of the `DashboardHero` +- The `TestHost` component sets the `DashboardHero.hero` with a binding -The `createComponent` returns a `fixture` that holds an instance of `TestHostComponent` instead of an instance of `DashboardHeroComponent`. +The `createComponent` returns a `fixture` that holds an instance of `TestHost` instead of an instance of `DashboardHero`. -Creating the `TestHostComponent` has the side effect of creating a `DashboardHeroComponent` because the latter appears within the template of the former. +Creating the `TestHost` has the side effect of creating a `DashboardHero` because the latter appears within the template of the former. The query for the hero element \(`heroEl`\) still finds it in the test DOM, albeit at greater depth in the element tree than before. The tests themselves are almost identical to the stand-alone version: - +```ts +it('should display hero name', () => { + const expectedPipedName = testHost.hero.name.toUpperCase(); + expect(heroEl.textContent).toContain(expectedPipedName); +}); + +it('should raise selected event when clicked', () => { + click(heroEl); + // selected hero should be the same data bound hero + expect(testHost.selectedHero).toBe(testHost.hero); +}); +``` Only the selected event test differs. -It confirms that the selected `DashboardHeroComponent` hero really does find its way up through the event binding to the host component. +It confirms that the selected `DashboardHero` hero really does find its way up through the event binding to the host component. ## Routing component A _routing component_ is a component that tells the `Router` to navigate to another component. -The `DashboardComponent` is a _routing component_ because the user can navigate to the `HeroDetailComponent` by clicking on one of the _hero buttons_ on the dashboard. +The `Dashboard` component is a _routing component_ because the user can navigate to the `HeroDetail` component by clicking on one of the _hero buttons_ on the dashboard. Angular provides test helpers to reduce boilerplate and more effectively test code which depends on `HttpClient`. The `provideRouter` function can be used directly in the test module as well. - +```ts +beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([{path: '**', component: Dashboard}]), + provideHttpClientTesting(), + HeroService, + ], + }); + harness = await RouterTestingHarness.create(); + comp = await harness.navigateByUrl('/', Dashboard); + TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes()); +}); +``` The following test clicks the displayed hero and confirms that we navigate to the expected URL. - +```ts +it('should tell navigate when hero clicked', async () => { + // get first DebugElement + const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero')); + heroDe.triggerEventHandler('selected', comp.heroes[0]); + + // expecting to navigate to id of the component's first hero + const id = comp.heroes[0].id; + expect(TestBed.inject(Router).url, 'should nav to HeroDetail for first hero').toEqual( + `/heroes/${id}`, + ); +}); +``` ## Routed components A _routed component_ is the destination of a `Router` navigation. It can be trickier to test, especially when the route to the component _includes parameters_. -The `HeroDetailComponent` is a _routed component_ that is the destination of such a route. +The `HeroDetail` is a _routed component_ that is the destination of such a route. -When a user clicks a _Dashboard_ hero, the `DashboardComponent` tells the `Router` to navigate to `heroes/:id`. +When a user clicks a _Dashboard_ hero, the `Dashboard` tells the `Router` to navigate to `heroes/:id`. The `:id` is a route parameter whose value is the `id` of the hero to edit. -The `Router` matches that URL to a route to the `HeroDetailComponent`. -It creates an `ActivatedRoute` object with the routing information and injects it into a new instance of the `HeroDetailComponent`. +The `Router` matches that URL to a route to the `HeroDetail`. +It creates an `ActivatedRoute` object with the routing information and injects it into a new instance of the `HeroDetail`. -Here are the services injected into `HeroDetailComponent`: +Here are the services injected into `HeroDetail`: - +```ts +private heroDetailService = inject(HeroDetailService); +private route = inject(ActivatedRoute); +private router = inject(Router); +``` The `HeroDetail` component needs the `id` parameter so it can fetch the corresponding hero using the `HeroDetailService`. The component has to get the `id` from the `ActivatedRoute.paramMap` property which is an `Observable`. @@ -579,9 +780,16 @@ The component has to get the `id` from the `ActivatedRoute.paramMap` property wh It can't just reference the `id` property of the `ActivatedRoute.paramMap`. The component has to _subscribe_ to the `ActivatedRoute.paramMap` observable and be prepared for the `id` to change during its lifetime. - +```ts +constructor() { + // get hero when `id` param changes + this.route.paramMap + .pipe(takeUntilDestroyed()) + .subscribe((pmap) => this.getHero(pmap.get('id'))); +} +``` -Tests can explore how the `HeroDetailComponent` responds to different `id` parameter values by navigating to different routes. +Tests can explore how the `HeroDetail` responds to different `id` parameter values by navigating to different routes. ## Nested component tests @@ -589,17 +797,28 @@ Component templates often have nested components, whose templates might contain The component tree can be very deep and sometimes the nested components play no role in testing the component at the top of the tree. -The `AppComponent`, for example, displays a navigation bar with anchors and their `RouterLink` directives. +The `App` component, for example, displays a navigation bar with anchors and their `RouterLink` directives. - +```angular-html + + + + + + +``` To validate the links but not the navigation, you don't need the `Router` to navigate and you don't need the `` to mark where the `Router` inserts _routed components_. -The `BannerComponent` and `WelcomeComponent` \(indicated by `` and ``\) are also irrelevant. +The `Banner` and `Welcome` components \(indicated by `` and ``\) are also irrelevant. -Yet any test that creates the `AppComponent` in the DOM also creates instances of these three components and, if you let that happen, you'll have to configure the `TestBed` to create them. +Yet any test that creates the `App` component in the DOM also creates instances of these three components and, if you let that happen, you'll have to configure the `TestBed` to create them. -If you neglect to declare them, the Angular compiler won't recognize the ``, ``, and `` tags in the `AppComponent` template and will throw an error. +If you neglect to declare them, the Angular compiler won't recognize the ``, ``, and `` tags in the `App` template and will throw an error. If you declare the real components, you'll also have to declare _their_ nested components and provide for _all_ services injected in _any_ component in the tree. @@ -610,14 +829,39 @@ Use them, alone or in combination, to stay focused on testing the primary compon In the first technique, you create and declare stub versions of the components and directive that play little or no role in the tests. - +```ts +@Component({selector: 'app-banner', template: ''}) +class BannerStub {} + +@Component({selector: 'router-outlet', template: ''}) +class RouterOutletStub {} + +@Component({selector: 'app-welcome', template: ''}) +class WelcomeStub {} +``` The stub selectors match the selectors for the corresponding real components. But their templates and classes are empty. Then declare them by overriding the `imports` of your component using `TestBed.overrideComponent`. - +```ts +let comp: App; +let fixture: ComponentFixture; + +beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([]), UserAuthentication], + }).overrideComponent(App, { + set: { + imports: [RouterLink, BannerStub, RouterOutletStub, WelcomeStub], + }, + }); + + fixture = TestBed.createComponent(App); + comp = fixture.componentInstance; +}); +``` HELPFUL: The `set` key in this example replaces all the exisiting imports on your component, make sure to imports all dependencies, not only the stubs. Alternatively you can use the `remove`/`add` keys to selectively remove and add imports. @@ -625,11 +869,22 @@ HELPFUL: The `set` key in this example replaces all the exisiting imports on you In the second approach, add `NO_ERRORS_SCHEMA` to the metadata overrides of your component. - +```ts +beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([]), UserAuthentication], + }).overrideComponent(App, { + set: { + imports: [], // resets all imports + schemas: [NO_ERRORS_SCHEMA], + }, + }); +}); +``` The `NO_ERRORS_SCHEMA` tells the Angular compiler to ignore unrecognized elements and attributes. -The compiler recognizes the `` element and the `routerLink` attribute because you declared a corresponding `AppComponent` and `RouterLink` in the `TestBed` configuration. +The compiler recognizes the `` element and the `routerLink` attribute because you declared a corresponding `App` component and `RouterLink` in the `TestBed` configuration. But the compiler won't throw an error when it encounters ``, ``, or ``. It simply renders them as empty tags and the browser ignores them. @@ -650,15 +905,35 @@ While the stubs in _this_ example were empty, you could give them stripped-down In practice you will combine the two techniques in the same setup, as seen in this example. - +```ts +beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([]), UserAuthentication], + }).overrideComponent(App, { + remove: {imports: [RouterOutlet, Welcome]}, + set: {schemas: [NO_ERRORS_SCHEMA]}, + }); +}); +``` -The Angular compiler creates the `BannerStubComponent` for the `` element and applies the `RouterLink` to the anchors with the `routerLink` attribute, but it ignores the `` and `` tags. +The Angular compiler creates the `BannerStub` for the `` element and applies the `RouterLink` to the anchors with the `routerLink` attribute, but it ignores the `` and `` tags. ### `By.directive` and injected directives A little more setup triggers the initial data binding and gets references to the navigation links: - +```ts +beforeEach(async () => { + await fixture.whenStable(); + + // find DebugElements with an attached RouterLinkStubDirective + linkDes = fixture.debugElement.queryAll(By.directive(RouterLink)); + + // get attached link directive instances + // using each DebugElement's injector + routerLinks = linkDes.map((de) => de.injector.get(RouterLink)); +}); +``` Three points of special interest: @@ -666,22 +941,60 @@ Three points of special interest: - The query returns `DebugElement` wrappers around the matching elements - Each `DebugElement` exposes a dependency injector with the specific instance of the directive attached to that element -The `AppComponent` links to validate are as follows: +The `App` component links to validate are as follows: - +```angular-html + +``` Here are some tests that confirm those links are wired to the `routerLink` directives as expected: - +```ts +it('can get RouterLinks from template', () => { + expect(routerLinks.length, 'should have 3 routerLinks').toBe(3); + expect(routerLinks[0].href).toBe('/dashboard'); + expect(routerLinks[1].href).toBe('/heroes'); + expect(routerLinks[2].href).toBe('/about'); +}); + +it('can click Heroes link in template', async () => { + const heroesLinkDe = linkDes[1]; // heroes link DebugElement + + TestBed.inject(Router).resetConfig([{path: '**', children: []}]); + heroesLinkDe.triggerEventHandler('click', {button: 0}); + + await fixture.whenStable(); + + expect(TestBed.inject(Router).url).toBe('/heroes'); +}); +``` ## Use a `page` object -The `HeroDetailComponent` is a simple view with a title, two hero fields, and two buttons. +The `HeroDetail` component is a simple view with a title, two hero fields, and two buttons. But there's plenty of template complexity even in this simple form. - +```angular-html +@if (hero) { +
+

+ {{ hero.name | titlecase }} Details +

+
id: {{ hero.id }}
+
+ + +
+ + +
+} +``` Tests that exercise the component need … @@ -696,23 +1009,111 @@ Tame the complexity with a `Page` class that handles access to component propert Here is such a `Page` class for the `hero-detail.component.spec.ts` - +```ts +class Page { + // getter properties wait to query the DOM until called. + get buttons() { + return this.queryAll('button'); + } + get saveBtn() { + return this.buttons[0]; + } + get cancelBtn() { + return this.buttons[1]; + } + get nameDisplay() { + return this.query('span'); + } + get nameInput() { + return this.query('input'); + } + + //// query helpers //// + private query(selector: string): T { + return harness.routeNativeElement!.querySelector(selector)! as T; + } + + private queryAll(selector: string): T[] { + return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; + } +} +``` Now the important hooks for component manipulation and inspection are neatly organized and accessible from an instance of `Page`. A `createComponent` method creates a `page` object and fills in the blanks once the `hero` arrives. - +```ts +async function createComponent(id: number) { + harness = await RouterTestingHarness.create(); + component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetail); + page = new Page(); -Here are a few more `HeroDetailComponent` tests to reinforce the point. + const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); + const hero = getTestHeroes().find((h) => h.id === Number(id)); + request.flush(hero ? [hero] : []); + await harness.fixture.whenStable(); +} +``` - +Here are a few more `HeroDetail` component tests to reinforce the point. + +```ts +it("should display that hero's name", () => { + expect(page.nameDisplay.textContent).toBe(expectedHero.name); +}); + +it('should navigate when click cancel', () => { + click(page.cancelBtn); + expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`); +}); + +it('should save when click save but not navigate immediately', () => { + click(page.saveBtn); + expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'})); + expect(TestBed.inject(Router).url).toEqual('/heroes/41'); +}); + +it('should navigate when click save and save resolves', async () => { + click(page.saveBtn); + await harness.fixture.whenStable(); + expect(TestBed.inject(Router).url).toEqual('/heroes/41'); +}); + +it('should convert hero name to Title Case', async () => { + // get the name's input and display elements from the DOM + const hostElement: HTMLElement = harness.routeNativeElement!; + const nameInput: HTMLInputElement = hostElement.querySelector('input')!; + const nameDisplay: HTMLElement = hostElement.querySelector('span')!; + + // simulate user entering a new name into the input box + nameInput.value = 'quick BROWN fOx'; + + // Dispatch a DOM event so that Angular learns of input value change. + nameInput.dispatchEvent(new Event('input')); + + // Wait for Angular to update the display binding through the title pipe + await harness.fixture.whenStable(); + + expect(nameDisplay.textContent).toBe('Quick Brown Fox'); +}); +``` ## Override component providers -The `HeroDetailComponent` provides its own `HeroDetailService`. +The `HeroDetail` provides its own `HeroDetailService`. - +```ts +@Component({ + /* ... */ + providers: [HeroDetailService], +}) +export class HeroDetail { + private heroDetailService = inject(HeroDetailService); + private route = inject(ActivatedRoute); + private router = inject(Router); +} +``` It's not possible to stub the component's `HeroDetailService` in the `providers` of the `TestBed.configureTestingModule`. Those are providers for the _testing module_, not the component. @@ -731,7 +1132,12 @@ There might not be a remote server to call. Fortunately, the `HeroDetailService` delegates responsibility for remote data access to an injected `HeroService`. - +```ts +@Injectable({providedIn: 'root'}) +export class HeroDetailService { + private heroService = inject(HeroService); +} +``` The previous test configuration replaces the real `HeroService` with a `TestHeroService` that intercepts server requests and fakes their responses. @@ -741,7 +1147,22 @@ What if `HeroDetailService` makes its own server requests? The `TestBed.overrideComponent` method can replace the component's `providers` with easy-to-manage _test doubles_ as seen in the following setup variation: - +```ts +beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideRouter([ + {path: 'heroes', component: HeroList}, + {path: 'heroes/:id', component: HeroDetail}, + ]), + // HeroDetailService at this level is IRRELEVANT! + {provide: HeroDetailService, useValue: {}}, + ], + }).overrideComponent(HeroDetail, { + set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, + }); +}); +``` Notice that `TestBed.configureTestingModule` no longer provides a fake `HeroService` because it's [not needed](#provide-a-spy-stub-herodetailservicespy). @@ -749,35 +1170,35 @@ Notice that `TestBed.configureTestingModule` no longer provides a fake `HeroServ Focus on the `overrideComponent` method. - +```ts +.overrideComponent(HeroDetail, { + set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, +}); +``` -It takes two arguments: the component type to override \(`HeroDetailComponent`\) and an override metadata object. +It takes two arguments: the component type to override (`HeroDetail`) and an override metadata object. The [override metadata object](guide/testing/utility-apis#metadata-override-object) is a generic defined as follows: - - +```ts type MetadataOverride = { -add?: Partial; -remove?: Partial; -set?: Partial; + add?: Partial; + remove?: Partial; + set?: Partial; }; - - +``` A metadata override object can either add-and-remove elements in metadata properties or completely reset those properties. This example resets the component's `providers` metadata. The type parameter, `T`, is the kind of metadata you'd pass to the `@Component` decorator: - - +```ts selector?: string; template?: string; templateUrl?: string; providers?: any[]; … - - +``` ### Provide a _spy stub_ (`HeroDetailServiceSpy`) @@ -786,16 +1207,67 @@ This example completely replaces the component's `providers` array with a new ar The `HeroDetailServiceSpy` is a stubbed version of the real `HeroDetailService` that fakes all necessary features of that service. It neither injects nor delegates to the lower level `HeroService` so there's no need to provide a test double for that. -The related `HeroDetailComponent` tests will assert that methods of the `HeroDetailService` were called by spying on the service methods. +The related `HeroDetail` component tests will assert that methods of the `HeroDetailService` were called by spying on the service methods. Accordingly, the stub implements its methods as spies: - +```ts +import {vi} from 'vitest'; + +class HeroDetailServiceSpy { + testHero: Hero = {...testHero}; + + /* emit cloned test hero */ + getHero = vi.fn(() => asyncData({...this.testHero})); + + /* emit clone of test hero, with changes merged in */ + saveHero = vi.fn((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); +} +``` ### The override tests Now the tests can control the component's hero directly by manipulating the spy-stub's `testHero` and confirm that service methods were called. - +```ts +let hdsSpy: HeroDetailServiceSpy; + +beforeEach(async () => { + harness = await RouterTestingHarness.create(); + component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetail); + page = new Page(); + // get the component's injected HeroDetailServiceSpy + hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any; + + harness.detectChanges(); +}); + +it('should have called `getHero`', () => { + expect(hdsSpy.getHero, 'getHero called once').toHaveBeenCalledTimes(1); +}); + +it("should display stub hero's name", () => { + expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); +}); + +it('should save stub hero change', async () => { + const origName = hdsSpy.testHero.name; + const newName = 'New Name'; + + page.nameInput.value = newName; + + page.nameInput.dispatchEvent(new Event('input')); // tell Angular + + expect(component.hero.name, 'component hero has new name').toBe(newName); + expect(hdsSpy.testHero.name, 'service hero unchanged before save').toBe(origName); + + click(page.saveBtn); + expect(hdsSpy.saveHero, 'saveHero called once').toHaveBeenCalledTimes(1); + + await harness.fixture.whenStable(); + expect(hdsSpy.testHero.name, 'service hero has new name after save').toBe(newName); + expect(TestBed.inject(Router).url).toEqual('/heroes'); +}); +``` ### More overrides diff --git a/adev/src/content/guide/testing/creating-component-harnesses.md b/adev/src/content/guide/testing/creating-component-harnesses.md index bad01767ad9..76c6bc4b8de 100644 --- a/adev/src/content/guide/testing/creating-component-harnesses.md +++ b/adev/src/content/guide/testing/creating-component-harnesses.md @@ -124,9 +124,11 @@ class MyMenuItem {} @Component({ selector: 'my-menu', - template: ` - - `, + template: ` + + + + `, }) class MyMenu { triggerText = input(''); diff --git a/adev/src/content/guide/testing/debugging.md b/adev/src/content/guide/testing/debugging.md index a1de79fb7cd..49012648e75 100644 --- a/adev/src/content/guide/testing/debugging.md +++ b/adev/src/content/guide/testing/debugging.md @@ -15,16 +15,6 @@ Debugging in the default Node.js environment is often the quickest way to diagno ## Debugging in a browser -Debugging in a browser is recommended for tests that rely on the DOM or other browser-specific APIs. This approach allows you to use the browser's own developer tools. +Debugging with Vitest and [browser mode](/guide/testing/migrating-to-vitest#5-configure-browser-mode-optional) is not supported today. -1. Ensure you have a browser provider installed. See [Running tests in a browser](guide/testing/overview#running-tests-in-a-browser) for setup instructions. -2. Run the `ng test` command with both the `--browsers` and `--debug` flags: - ```shell - ng test --browsers=chromium --debug - ``` -3. This command runs the tests in a headed browser and keeps it open after the tests finish, allowing you to inspect the output. -4. Open the browser's **Developer Tools**. On Windows, press `Ctrl-Shift-I`. On macOS, press `Command-Option-I`. -5. Go to the **Sources** tab. -6. Use `Control/Command-P` to search for and open your test file. -7. Set a breakpoint in your test. -8. Reload the test runner UI in the browser. The execution will now stop at your breakpoint. +