Esta guía explora casos de uso comunes de prueba de componentes.
Binding de componentes
En la aplicación de ejemplo, el BannerComponent presenta texto de título estático en la plantilla HTML.
Después de algunos cambios, el BannerComponent presenta un título dinámico vinculándose a la propiedad title del componente como esto.
app/banner/banner.component.ts
import {Component, signal} from '@angular/core';@Component({ selector: 'app-banner', template: '<h1>{{title()}}</h1>', styles: ['h1 { color: green; font-size: 350%}'],})export class BannerComponent { title = signal('Test Tour of Heroes');}
Por mínimo que sea esto, decides agregar una prueba para confirmar que el componente realmente muestra el contenido correcto donde crees que debería.
Consultar por el <h1>
Escribirás una secuencia de pruebas que inspeccionan el valor del elemento <h1> que envuelve el binding de interpolación de la propiedad title.
Actualizas el beforeEach para encontrar ese elemento con un querySelector HTML estándar y asignarlo a la variable h1.
app/banner/banner.component.spec.ts (setup)
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; // BannerComponent test instance h1 = fixture.nativeElement.querySelector('h1'); }); it('no title in the DOM after createComponent()', () => { expect(h1.textContent).toEqual(''); }); it('should display original title', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display original title after detectChanges()', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });});
createComponent() no vincula datos
Para tu primera prueba te gustaría ver que la pantalla muestra el title predeterminado.
Tu instinto es escribir una prueba que inmediatamente inspecciona el <h1> así:
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; // BannerComponent test instance h1 = fixture.nativeElement.querySelector('h1'); }); it('no title in the DOM after createComponent()', () => { expect(h1.textContent).toEqual(''); }); it('should display original title', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display original title after detectChanges()', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });});
Esa prueba falla con el mensaje:
expected '' to contain 'Test Tour of Heroes'.
El binding ocurre cuando Angular realiza detección de cambios.
En producción, la detección de cambios se activa automáticamente cuando Angular crea un componente o el usuario ingresa una pulsación de tecla, por ejemplo.
El TestBed.createComponent no desencadena detección de cambios por defecto; un hecho confirmado en la prueba revisada:
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; // BannerComponent test instance h1 = fixture.nativeElement.querySelector('h1'); }); it('no title in the DOM after createComponent()', () => { expect(h1.textContent).toEqual(''); }); it('should display original title', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display original title after detectChanges()', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });});
detectChanges()
Puedes decirle al TestBed que realice binding de datos llamando a fixture.detectChanges().
Solo entonces el <h1> tiene el título esperado.
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; // BannerComponent test instance h1 = fixture.nativeElement.querySelector('h1'); }); it('no title in the DOM after createComponent()', () => { expect(h1.textContent).toEqual(''); }); it('should display original title', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display original title after detectChanges()', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });});
La detección de cambios retrasada es intencional y útil. Le da al probador una oportunidad de inspeccionar y cambiar el estado del componente antes de que Angular inicie el binding de datos y llame a hooks de ciclo de vida.
Aquí hay otra prueba que cambia la propiedad title del componente antes de llamar a fixture.detectChanges().
import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (inline template)', () => { let component: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BannerComponent); component = fixture.componentInstance; // BannerComponent test instance h1 = fixture.nativeElement.querySelector('h1'); }); it('no title in the DOM after createComponent()', () => { expect(h1.textContent).toEqual(''); }); it('should display original title', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display original title after detectChanges()', () => { fixture.detectChanges(); expect(h1.textContent).toContain(component.title); }); it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });});
Detección automática de cambios
Las pruebas de BannerComponent llaman frecuentemente a detectChanges.
Muchos probadores prefieren que el entorno de prueba de Angular ejecute la detección de cambios automáticamente como lo hace en producción.
Eso es posible configurando el TestBed con el provider ComponentFixtureAutoDetect.
Primero impórtalo de la librería utilitaria de testing:
app/banner/banner.component.detect-changes.spec.ts (import)
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => { let comp: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: [{provide: ComponentFixtureAutoDetect, useValue: true}], }); fixture = TestBed.createComponent(BannerComponent); comp = fixture.componentInstance; h1 = fixture.nativeElement.querySelector('h1'); }); it('should display original title', () => { // Hooray! No `fixture.detectChanges()` needed expect(h1.textContent).toContain(comp.title); }); it('should still see original title after comp.title change', async () => { const oldTitle = comp.title; const newTitle = 'Test Title'; comp.title.set(newTitle); // Displayed title is old because Angular didn't yet run change detection expect(h1.textContent).toContain(oldTitle); await fixture.whenStable(); expect(h1.textContent).toContain(newTitle); }); it('should display updated title after detectChanges', () => { comp.title.set('Test Title'); fixture.detectChanges(); // detect changes explicitly expect(h1.textContent).toContain(comp.title); });});
Luego agrégalo al array providers de la configuración del módulo de testing:
app/banner/banner.component.detect-changes.spec.ts (AutoDetect)
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => { let comp: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: [{provide: ComponentFixtureAutoDetect, useValue: true}], }); fixture = TestBed.createComponent(BannerComponent); comp = fixture.componentInstance; h1 = fixture.nativeElement.querySelector('h1'); }); it('should display original title', () => { // Hooray! No `fixture.detectChanges()` needed expect(h1.textContent).toContain(comp.title); }); it('should still see original title after comp.title change', async () => { const oldTitle = comp.title; const newTitle = 'Test Title'; comp.title.set(newTitle); // Displayed title is old because Angular didn't yet run change detection expect(h1.textContent).toContain(oldTitle); await fixture.whenStable(); expect(h1.textContent).toContain(newTitle); }); it('should display updated title after detectChanges', () => { comp.title.set('Test Title'); fixture.detectChanges(); // detect changes explicitly expect(h1.textContent).toContain(comp.title); });});
ÚTIL: También puedes usar la función fixture.autoDetectChanges() en su lugar si solo quieres habilitar la detección automática de cambios después de hacer actualizaciones al estado del componente del fixture. Además, la detección automática de cambios está activada por defecto cuando usas provideZonelessChangeDetection y desactivarla no es recomendado.
Aquí hay tres pruebas que ilustran cómo funciona la detección automática de cambios.
app/banner/banner.component.detect-changes.spec.ts (AutoDetect Tests)
import {ComponentFixtureAutoDetect} from '@angular/core/testing';import {ComponentFixture, TestBed} from '@angular/core/testing';import {BannerComponent} from './banner.component';describe('BannerComponent (AutoChangeDetect)', () => { let comp: BannerComponent; let fixture: ComponentFixture<BannerComponent>; let h1: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: [{provide: ComponentFixtureAutoDetect, useValue: true}], }); fixture = TestBed.createComponent(BannerComponent); comp = fixture.componentInstance; h1 = fixture.nativeElement.querySelector('h1'); }); it('should display original title', () => { // Hooray! No `fixture.detectChanges()` needed expect(h1.textContent).toContain(comp.title); }); it('should still see original title after comp.title change', async () => { const oldTitle = comp.title; const newTitle = 'Test Title'; comp.title.set(newTitle); // Displayed title is old because Angular didn't yet run change detection expect(h1.textContent).toContain(oldTitle); await fixture.whenStable(); expect(h1.textContent).toContain(newTitle); }); it('should display updated title after detectChanges', () => { comp.title.set('Test Title'); fixture.detectChanges(); // detect changes explicitly expect(h1.textContent).toContain(comp.title); });});
La primera prueba muestra el beneficio de la detección automática de cambios.
La segunda y tercera prueba revelan una limitación importante.
El entorno de testing de Angular no ejecuta detección de cambios síncronamente cuando las actualizaciones ocurren dentro del caso de prueba que cambió el title del componente.
La prueba debe llamar a await fixture.whenStable para esperar otra ronda de detección de cambios.
ÚTIL: Angular no sabe sobre actualizaciones directas a valores que no son signals. La forma más fácil de asegurar que la detección de cambios se programe es usar signals para valores leídos en la plantilla.
Cambiar un valor de input con dispatchEvent()
Para simular la entrada del usuario, encuentra el elemento input y establece su propiedad value.
Pero hay un paso esencial, intermedio.
Angular no sabe que estableciste la propiedad value del elemento input.
No leerá esa propiedad hasta que generes el evento input del elemento llamando a dispatchEvent().
El siguiente ejemplo demuestra la secuencia adecuada.
app/hero/hero-detail.component.spec.ts (pipe test)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Componente con archivos externos
El BannerComponent precedente está definido con una plantilla inline y css inline, especificados en las propiedades @Component.template y @Component.styles respectivamente.
Muchos componentes especifican plantillas externas y css externo con las propiedades @Component.templateUrl y @Component.styleUrls respectivamente, como lo hace la siguiente variante de BannerComponent.
app/banner/banner-external.component.ts (metadata)
import {Component} from '@angular/core';@Component({ selector: 'app-banner', templateUrl: './banner-external.component.html', styleUrls: ['./banner-external.component.css'],})export class BannerComponent { title = 'Test Tour of Heroes';}
Esta sintaxis le dice al compilador de Angular que lea los archivos externos durante la compilación del componente.
Eso no es un problema cuando ejecutas el comando CLI ng test porque compila la aplicación antes de ejecutar las pruebas.
Sin embargo, si ejecutas las pruebas en un entorno no-CLI, las pruebas de este componente podrían fallar.
Por ejemplo, si ejecutas las pruebas de BannerComponent en un entorno de codificación web como plunker, verás un mensaje como este:
Error: This test module uses the component BannerComponentwhich is using a "templateUrl" or "styleUrls", but they were never compiled.Please call "TestBed.compileComponents" before your test.
Obtienes este mensaje de fallo de prueba cuando el entorno de runtime compila el código fuente durante las pruebas mismas.
Para corregir el problema, llama a compileComponents() como se explica en la siguiente sección Llamando compileComponents.
Componente con una dependencia
Los componentes a menudo tienen dependencias de servicio.
El WelcomeComponent muestra un mensaje de bienvenida al usuario autenticado.
Sabe quién es el usuario basado en una propiedad del UserService inyectado:
app/welcome/welcome.component.ts
import {Component, inject, OnInit, signal} from '@angular/core';import {UserService} from '../model/user.service';@Component({ selector: 'app-welcome', template: '<h3 class="welcome"><i>{{welcome()}}</i></h3>',})export class WelcomeComponent { welcome = signal(''); private userService = inject(UserService); constructor() { this.welcome.set( this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.user().name : 'Please log in.', ); }}
El WelcomeComponent tiene lógica de decisión que interactúa con el servicio, lógica que hace que este componente valga la pena probar.
Proporcionar dobles de prueba de servicio
Un componente-bajo-prueba no tiene que ser proporcionado con servicios reales.
Inyectar el UserService real podría ser difícil.
El servicio real podría pedirle al usuario credenciales de inicio de sesión e intentar llegar a un servidor de autenticación.
Estos comportamientos pueden ser difíciles de interceptar. Ten en cuenta que usar dobles de prueba hace que la prueba se comporte diferente de la producción, así que úsalos con moderación.
Obtener servicios inyectados
Las pruebas necesitan acceso al UserService inyectado en el WelcomeComponent.
Angular tiene un sistema de inyección jerárquico.
Puede haber inyectores en múltiples niveles, desde el inyector raíz creado por el TestBed hacia abajo a través del árbol de componentes.
La forma más segura de obtener el servicio inyectado, la forma que siempre funciona,
es obtenerlo del inyector del componente-bajo-prueba.
El inyector del componente es una propiedad del DebugElement del fixture.
WelcomeComponent's injector
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService { isLoggedIn = true; user = {name: 'Test User'};}describe('WelcomeComponent', () => { let comp: WelcomeComponent; let fixture: ComponentFixture<WelcomeComponent>; let componentUserService: UserService; // the actually injected service let userService: UserService; // the TestBed injected service let el: HTMLElement; // the DOM element with the welcome message beforeEach(() => { fixture = TestBed.createComponent(WelcomeComponent); fixture.autoDetectChanges(); comp = fixture.componentInstance; // UserService actually injected into the component userService = fixture.debugElement.injector.get(UserService); componentUserService = userService; // UserService from the root injector userService = TestBed.inject(UserService); // get the "welcome" element by CSS selector (e.g., by class name) el = fixture.nativeElement.querySelector('.welcome'); }); it('should welcome the user', async () => { await fixture.whenStable(); const content = el.textContent; expect(content).withContext('"Welcome ..."').toContain('Welcome'); expect(content).withContext('expected name').toContain('Test User'); }); it('should welcome "Bubba"', async () => { userService.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 () => { userService.isLoggedIn.set(false); // welcome message hasn't been shown yet await fixture.whenStable(); const content = el.textContent; expect(content).withContext('not welcomed').not.toContain('Welcome'); expect(content) .withContext('"log in"') .toMatch(/log in/i); }); it("should inject the component's UserService instance", inject( [UserService], (service: UserService) => { expect(service).toBe(componentUserService); }, )); it('TestBed and Component UserService should be the same', () => { expect(userService).toBe(componentUserService); });});
ÚTIL: Esto usualmente no es necesario. Los servicios a menudo se proporcionan en la raíz o los overrides del TestBed y pueden recuperarse más fácilmente con TestBed.inject() (ver abajo).
TestBed.inject()
Esto es más fácil de recordar y menos verboso que recuperar un servicio usando el DebugElement del fixture.
En esta suite de pruebas, el único provider de UserService es el módulo de testing raíz, por lo que es seguro llamar a TestBed.inject() de la siguiente manera:
TestBed injector
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService { isLoggedIn = true; user = {name: 'Test User'};}describe('WelcomeComponent', () => { let comp: WelcomeComponent; let fixture: ComponentFixture<WelcomeComponent>; let componentUserService: UserService; // the actually injected service let userService: UserService; // the TestBed injected service let el: HTMLElement; // the DOM element with the welcome message beforeEach(() => { fixture = TestBed.createComponent(WelcomeComponent); fixture.autoDetectChanges(); comp = fixture.componentInstance; // UserService actually injected into the component userService = fixture.debugElement.injector.get(UserService); componentUserService = userService; // UserService from the root injector userService = TestBed.inject(UserService); // get the "welcome" element by CSS selector (e.g., by class name) el = fixture.nativeElement.querySelector('.welcome'); }); it('should welcome the user', async () => { await fixture.whenStable(); const content = el.textContent; expect(content).withContext('"Welcome ..."').toContain('Welcome'); expect(content).withContext('expected name').toContain('Test User'); }); it('should welcome "Bubba"', async () => { userService.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 () => { userService.isLoggedIn.set(false); // welcome message hasn't been shown yet await fixture.whenStable(); const content = el.textContent; expect(content).withContext('not welcomed').not.toContain('Welcome'); expect(content) .withContext('"log in"') .toMatch(/log in/i); }); it("should inject the component's UserService instance", inject( [UserService], (service: UserService) => { expect(service).toBe(componentUserService); }, )); it('TestBed and Component UserService should be the same', () => { expect(userService).toBe(componentUserService); });});
ÚTIL: Para un caso de uso en el que TestBed.inject() no funciona, consulta la sección Override component providers que explica cuándo y por qué debes obtener el servicio del inyector del componente en su lugar.
Configuración final y pruebas
Aquí está el beforeEach() completo, usando TestBed.inject():
app/welcome/welcome.component.spec.ts
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService { isLoggedIn = true; user = {name: 'Test User'};}describe('WelcomeComponent', () => { let comp: WelcomeComponent; let fixture: ComponentFixture<WelcomeComponent>; let componentUserService: UserService; // the actually injected service let userService: UserService; // the TestBed injected service let el: HTMLElement; // the DOM element with the welcome message beforeEach(() => { fixture = TestBed.createComponent(WelcomeComponent); fixture.autoDetectChanges(); comp = fixture.componentInstance; // UserService actually injected into the component userService = fixture.debugElement.injector.get(UserService); componentUserService = userService; // UserService from the root injector userService = TestBed.inject(UserService); // get the "welcome" element by CSS selector (e.g., by class name) el = fixture.nativeElement.querySelector('.welcome'); }); it('should welcome the user', async () => { await fixture.whenStable(); const content = el.textContent; expect(content).withContext('"Welcome ..."').toContain('Welcome'); expect(content).withContext('expected name').toContain('Test User'); }); it('should welcome "Bubba"', async () => { userService.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 () => { userService.isLoggedIn.set(false); // welcome message hasn't been shown yet await fixture.whenStable(); const content = el.textContent; expect(content).withContext('not welcomed').not.toContain('Welcome'); expect(content) .withContext('"log in"') .toMatch(/log in/i); }); it("should inject the component's UserService instance", inject( [UserService], (service: UserService) => { expect(service).toBe(componentUserService); }, )); it('TestBed and Component UserService should be the same', () => { expect(userService).toBe(componentUserService); });});
Y aquí hay algunas pruebas:
app/welcome/welcome.component.spec.ts
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';import {UserService} from '../model/user.service';import {WelcomeComponent} from './welcome.component';class MockUserService { isLoggedIn = true; user = {name: 'Test User'};}describe('WelcomeComponent', () => { let comp: WelcomeComponent; let fixture: ComponentFixture<WelcomeComponent>; let componentUserService: UserService; // the actually injected service let userService: UserService; // the TestBed injected service let el: HTMLElement; // the DOM element with the welcome message beforeEach(() => { fixture = TestBed.createComponent(WelcomeComponent); fixture.autoDetectChanges(); comp = fixture.componentInstance; // UserService actually injected into the component userService = fixture.debugElement.injector.get(UserService); componentUserService = userService; // UserService from the root injector userService = TestBed.inject(UserService); // get the "welcome" element by CSS selector (e.g., by class name) el = fixture.nativeElement.querySelector('.welcome'); }); it('should welcome the user', async () => { await fixture.whenStable(); const content = el.textContent; expect(content).withContext('"Welcome ..."').toContain('Welcome'); expect(content).withContext('expected name').toContain('Test User'); }); it('should welcome "Bubba"', async () => { userService.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 () => { userService.isLoggedIn.set(false); // welcome message hasn't been shown yet await fixture.whenStable(); const content = el.textContent; expect(content).withContext('not welcomed').not.toContain('Welcome'); expect(content) .withContext('"log in"') .toMatch(/log in/i); }); it("should inject the component's UserService instance", inject( [UserService], (service: UserService) => { expect(service).toBe(componentUserService); }, )); it('TestBed and Component UserService should be the same', () => { expect(userService).toBe(componentUserService); });});
La primera es una prueba de cordura; confirma que el UserService es llamado y funciona.
ÚTIL: La función withContext (por ejemplo, 'expected name') es una etiqueta de fallo opcional.
Si la expectativa falla, Jasmine agrega esta etiqueta al mensaje de fallo de expectativa.
En una spec con múltiples expectativas, puede ayudar a aclarar qué salió mal y qué expectativa falló.
Las pruebas restantes confirman la lógica del componente cuando el servicio retorna diferentes valores. La segunda prueba valida el efecto de cambiar el nombre del usuario. La tercera prueba verifica que el componente muestra el mensaje apropiado cuando no hay usuario autenticado.
Componente con servicio async
En esta muestra, la plantilla AboutComponent aloja un TwainComponent.
El TwainComponent muestra citas de Mark Twain.
app/twain/twain.component.ts (template)
import {Component, inject, OnInit, signal} from '@angular/core';import {AsyncPipe} from '@angular/common';import {sharedImports} from '../shared/shared';import {Observable, of} from 'rxjs';import {catchError, startWith} from 'rxjs/operators';import {TwainService} from './twain.service';@Component({ selector: 'twain-quote', template: ` <p class="twain"> <i>{{ quote | async }}</i> </p> <button type="button" (click)="getQuote()">Next quote</button> @if (errorMessage()) { <p class="error">{{ errorMessage() }}</p> }`, styles: ['.twain { font-style: italic; } .error { color: red; }'], imports: [AsyncPipe, sharedImports],})export class TwainComponent { errorMessage = signal(''); quote?: Observable<string>; private twainService = inject(TwainService); constructor() { this.getQuote(); } getQuote() { this.errorMessage.set(''); this.quote = this.twainService.getQuote().pipe( startWith('...'), catchError((err: any) => { this.errorMessage.set(err.message || err.toString()); return of('...'); // reset message to placeholder }), ); }}
ÚTIL: El valor de la propiedad quote del componente pasa a través de un AsyncPipe.
Eso significa que la propiedad retorna ya sea una Promise o un Observable.
En este ejemplo, el método TwainComponent.getQuote() te dice que la propiedad quote retorna un Observable.
app/twain/twain.component.ts (getQuote)
import {Component, inject, OnInit, signal} from '@angular/core';import {AsyncPipe} from '@angular/common';import {sharedImports} from '../shared/shared';import {Observable, of} from 'rxjs';import {catchError, startWith} from 'rxjs/operators';import {TwainService} from './twain.service';@Component({ selector: 'twain-quote', template: ` <p class="twain"> <i>{{ quote | async }}</i> </p> <button type="button" (click)="getQuote()">Next quote</button> @if (errorMessage()) { <p class="error">{{ errorMessage() }}</p> }`, styles: ['.twain { font-style: italic; } .error { color: red; }'], imports: [AsyncPipe, sharedImports],})export class TwainComponent { errorMessage = signal(''); quote?: Observable<string>; private twainService = inject(TwainService); constructor() { this.getQuote(); } getQuote() { this.errorMessage.set(''); this.quote = this.twainService.getQuote().pipe( startWith('...'), catchError((err: any) => { this.errorMessage.set(err.message || err.toString()); return of('...'); // reset message to placeholder }), ); }}
El TwainComponent obtiene citas de un TwainService inyectado.
El componente inicia el Observable retornado con un valor placeholder ('...'), antes de que el servicio pueda retornar su primera cita.
El catchError intercepta errores de servicio, prepara un mensaje de error, y retorna el valor placeholder en el canal de éxito.
Estas son todas características que querrás probar.
Probar con un spy
Cuando se prueba un componente, solo la API pública del servicio debería importar.
En general, las pruebas mismas no deberían hacer llamadas a servidores remotos.
Deberían emular tales llamadas.
La configuración en este app/twain/twain.component.spec.ts muestra una forma de hacerlo:
app/twain/twain.component.spec.ts (setup)
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
Enfócate en el spy.
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
El spy está diseñado de tal manera que cualquier llamada a getQuote recibe un observable con una cita de prueba.
A diferencia del método getQuote() real, este spy evita el servidor y retorna un observable síncrono cuyo valor está disponible inmediatamente.
Puedes escribir muchas pruebas útiles con este spy, aunque su Observable sea síncrono.
ÚTIL: Es mejor limitar el uso de spies solo a lo que es necesario para la prueba. Crear mocks o spies para más de lo necesario puede ser frágil. A medida que el componente e injectable evolucionan, las pruebas no relacionadas pueden fallar porque ya no simulan suficientes comportamientos que de otro modo no afectarían la prueba.
Prueba async con fakeAsync()
Para usar la funcionalidad fakeAsync(), debes importar zone.js/testing en tu archivo de configuración de pruebas.
Si creaste tu proyecto con Angular CLI, zone-testing ya está importado en src/test.ts.
La siguiente prueba confirma el comportamiento esperado cuando el servicio retorna un ErrorObservable.
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
ÚTIL: La función it() recibe un argumento de la siguiente forma.
fakeAsync(() => { /*test body*/ })
La función fakeAsync() habilita un estilo de codificación lineal ejecutando el cuerpo de la prueba en una fakeAsync test zone especial.
El cuerpo de la prueba aparece ser síncrono.
No hay sintaxis anidada (como un Promise.then()) para interrumpir el flujo de control.
ÚTIL: Limitación: La función fakeAsync() no funcionará si el cuerpo de la prueba hace una llamada XMLHttpRequest (XHR).
Las llamadas XHR dentro de una prueba son raras, pero si necesitas llamar XHR, consulta la sección waitForAsync().
IMPORTANTE: Ten en cuenta que las tareas asíncronas que ocurren dentro de la zona fakeAsync necesitan ejecutarse manualmente con flush o tick. Si intentas esperar a que se completen (es decir, usando fixture.whenStable) sin usar los helpers de prueba fakeAsync para avanzar el tiempo, tu prueba probablemente fallará. Ver abajo para más información.
La función tick()
Tienes que llamar a tick() para avanzar el reloj virtual.
Llamar a tick() simula el paso del tiempo hasta que todas las actividades asíncronas pendientes terminen.
En este caso, espera al setTimeout() del observable.
La función tick() acepta millis y tickOptions como parámetros. El parámetro millis especifica cuánto avanza el reloj virtual y por defecto es 0 si no se proporciona.
Por ejemplo, si tienes un setTimeout(fn, 100) en una prueba fakeAsync(), necesitas usar tick(100) para desencadenar el callback fn.
El parámetro opcional tickOptions tiene una propiedad llamada processNewMacroTasksSynchronously. La propiedad processNewMacroTasksSynchronously representa si invocar nuevas macro tasks generadas al hacer tick y por defecto es true.
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
La función tick() es una de las utilidades de testing de Angular que importas con TestBed.
Es una compañera de fakeAsync() y solo puedes llamarla dentro de un cuerpo fakeAsync().
tickOptions
En este ejemplo, tienes una nueva macro task, la función setTimeout anidada. Por defecto, cuando el tick es setTimeout, tanto outside como nested se desencadenarán.
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
En algunos casos, no quieres desencadenar la nueva macro task al hacer tick. Puedes usar tick(millis, {processNewMacroTasksSynchronously: false}) para no invocar una nueva macro task.
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
Comparar fechas dentro de fakeAsync()
fakeAsync() simula el paso del tiempo, lo que te permite calcular la diferencia entre fechas dentro de fakeAsync().
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
jasmine.clock con fakeAsync()
Jasmine también proporciona una característica clock para simular fechas.
Angular ejecuta automáticamente pruebas que se ejecutan después de que se llame jasmine.clock().install() dentro de un método fakeAsync() hasta que se llame jasmine.clock().uninstall().
fakeAsync() no es necesario y lanza un error si se anida.
Por defecto, esta característica está deshabilitada.
Para habilitarla, establece una bandera global antes de importar zone-testing.
Si usas Angular CLI, configura esta bandera en src/test.ts.
[window as any]('__zone_symbol__fakeAsyncPatchLock') = true;import 'zone.js/testing';
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
Usar el scheduler de RxJS dentro de fakeAsync()
También puedes usar el scheduler de RxJS en fakeAsync() igual que usando setTimeout() o setInterval(), pero necesitas importar zone.js/plugins/zone-patch-rxjs-fake-async para parchear el scheduler de RxJS.
import {fakeAsync, tick, waitForAsync} from '@angular/core/testing';import {interval, of} from 'rxjs';import {delay, take} from 'rxjs/operators';describe('Angular async helper', () => { describe('async', () => { let actuallyDone = false; beforeEach(() => { actuallyDone = false; }); afterEach(() => { expect(actuallyDone).withContext('actuallyDone should be true').toBe(true); }); it('should run normal test', () => { actuallyDone = true; }); it('should run normal async test', (done: DoneFn) => { setTimeout(() => { actuallyDone = true; done(); }, 0); }); it('should run async test with task', waitForAsync(() => { setTimeout(() => { actuallyDone = true; }, 0); })); it('should run async test with task', waitForAsync(() => { const id = setInterval(() => { actuallyDone = true; clearInterval(id); }, 100); })); it('should run async test with successful promise', waitForAsync(() => { const p = new Promise((resolve) => { setTimeout(resolve, 10); }); p.then(() => { actuallyDone = true; }); })); it('should run async test with failed promise', waitForAsync(() => { const p = new Promise((resolve, reject) => { setTimeout(reject, 10); }); p.catch(() => { actuallyDone = true; }); })); // Use done. Can also use async or fakeAsync. it('should run async test with successful delayed Observable', (done: DoneFn) => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), complete: done, }); }); it('should run async test with successful delayed Observable', waitForAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); })); it('should run async test with successful delayed Observable', fakeAsync(() => { const source = of(true).pipe(delay(10)); source.subscribe({ next: (val) => (actuallyDone = true), error: (err) => fail(err), }); tick(10); })); }); describe('fakeAsync', () => { it('should run timeout callback with delay after call tick with millis', fakeAsync(() => { let called = false; setTimeout(() => { called = true; }, 100); tick(100); expect(called).toBe(true); })); it('should run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0); // the nested timeout will also be triggered expect(callback).toHaveBeenCalled(); })); it('should not run new macro task callback with delay after call tick with millis', fakeAsync(() => { function nestedTimer(cb: () => any): void { setTimeout(() => setTimeout(() => cb())); } const callback = jasmine.createSpy('callback'); nestedTimer(callback); expect(callback).not.toHaveBeenCalled(); tick(0, {processNewMacroTasksSynchronously: false}); // the nested timeout will not be triggered expect(callback).not.toHaveBeenCalled(); tick(0); expect(callback).toHaveBeenCalled(); })); it('should get Date diff correctly in fakeAsync', fakeAsync(() => { const start = Date.now(); tick(100); const end = Date.now(); expect(end - start).toBe(100); })); it('should get Date diff correctly in fakeAsync with rxjs scheduler', fakeAsync(() => { // need to add `import 'zone.js/plugins/zone-patch-rxjs-fake-async' // to patch rxjs scheduler let result = ''; of('hello') .pipe(delay(1000)) .subscribe((v) => { result = v; }); expect(result).toBe(''); tick(1000); expect(result).toBe('hello'); const start = new Date().getTime(); let dateDiff = 0; interval(1000) .pipe(take(2)) .subscribe(() => (dateDiff = new Date().getTime() - start)); tick(1000); expect(dateDiff).toBe(1000); tick(1000); expect(dateDiff).toBe(2000); })); }); describe('use jasmine.clock()', () => { // need to config __zone_symbol__fakeAsyncPatchLock flag // before loading zone.js/testing beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it('should auto enter fakeAsync', () => { // is in fakeAsync now, don't need to call fakeAsync(testFn) let called = false; setTimeout(() => { called = true; }, 100); jasmine.clock().tick(100); expect(called).toBe(true); }); }); describe('test jsonp', () => { function jsonp(url: string, callback: () => void) { // do a jsonp call which is not zone aware } // need to config __zone_symbol__supportWaitUnResolvedChainedPromise flag // before loading zone.js/testing it('should wait until promise.then is called', waitForAsync(() => { let finished = false; new Promise<void>((res) => { jsonp('localhost:8080/jsonp', () => { // success callback and resolve the promise finished = true; res(); }); }).then(() => { // async will wait until promise.then is called // if __zone_symbol__supportWaitUnResolvedChainedPromise is set expect(finished).toBe(true); }); })); });});
Soportar más macroTasks
Por defecto, fakeAsync() soporta las siguientes macro tasks.
setTimeoutsetIntervalrequestAnimationFramewebkitRequestAnimationFramemozRequestAnimationFrame
Si ejecutas otras macro tasks como HTMLCanvasElement.toBlob(), se lanza un error "Unknown macroTask scheduled in fake async test".
src/app/shared/canvas.component.spec.ts (failing)
import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {CanvasComponent} from './canvas.component';describe('CanvasComponent', () => { beforeEach(() => { (window as any).__zone_symbol__FakeAsyncTestMacroTask = [ { source: 'HTMLCanvasElement.toBlob', callbackArgs: [{size: 200}], }, ]; }); it('should be able to generate blob data from canvas', fakeAsync(() => { const fixture = TestBed.createComponent(CanvasComponent); const canvasComp = fixture.componentInstance; fixture.detectChanges(); expect(canvasComp.blobSize).toBe(0); tick(); expect(canvasComp.blobSize).toBeGreaterThan(0); }));});
src/app/shared/canvas.component.ts
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component// file using `HTMLCanvasElement` (if it is only used in a single file).import 'zone.js/plugins/zone-patch-canvas';import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';@Component({ selector: 'sample-canvas', template: '<canvas #sampleCanvas width="200" height="200"></canvas>',})export class CanvasComponent implements AfterViewInit { blobSize = 0; @ViewChild('sampleCanvas') sampleCanvas!: ElementRef; ngAfterViewInit() { const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement; const context = canvas.getContext('2d')!; context.clearRect(0, 0, 200, 200); context.fillStyle = '#FF1122'; context.fillRect(0, 0, 200, 200); canvas.toBlob((blob) => { this.blobSize = blob?.size ?? 0; }); }}
Si quieres soportar tal caso, necesitas definir la macro task que quieres soportar en beforeEach().
Por ejemplo:
src/app/shared/canvas.component.spec.ts (excerpt)
import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {CanvasComponent} from './canvas.component';describe('CanvasComponent', () => { beforeEach(() => { (window as any).__zone_symbol__FakeAsyncTestMacroTask = [ { source: 'HTMLCanvasElement.toBlob', callbackArgs: [{size: 200}], }, ]; }); it('should be able to generate blob data from canvas', fakeAsync(() => { const fixture = TestBed.createComponent(CanvasComponent); const canvasComp = fixture.componentInstance; fixture.detectChanges(); expect(canvasComp.blobSize).toBe(0); tick(); expect(canvasComp.blobSize).toBeGreaterThan(0); }));});
ÚTIL: Para hacer que el elemento <canvas> sea Zone.js-aware en tu aplicación, necesitas importar el parche zone-patch-canvas (ya sea en polyfills.ts o en el archivo específico que usa <canvas>):
src/polyfills.ts or src/app/shared/canvas.component.ts
// Import patch to make async `HTMLCanvasElement` methods (such as `.toBlob()`) Zone.js-aware.// Either import in `polyfills.ts` (if used in more than one places in the app) or in the component// file using `HTMLCanvasElement` (if it is only used in a single file).import 'zone.js/plugins/zone-patch-canvas';import {Component, AfterViewInit, ViewChild, ElementRef} from '@angular/core';@Component({ selector: 'sample-canvas', template: '<canvas #sampleCanvas width="200" height="200"></canvas>',})export class CanvasComponent implements AfterViewInit { blobSize = 0; @ViewChild('sampleCanvas') sampleCanvas!: ElementRef; ngAfterViewInit() { const canvas: HTMLCanvasElement = this.sampleCanvas.nativeElement; const context = canvas.getContext('2d')!; context.clearRect(0, 0, 200, 200); context.fillStyle = '#FF1122'; context.fillRect(0, 0, 200, 200); canvas.toBlob((blob) => { this.blobSize = blob?.size ?? 0; }); }}
Observables async
Podrías estar satisfecho con la cobertura de prueba de estas pruebas.
Sin embargo, podrías estar preocupado por el hecho de que el servicio real no se comporta exactamente de esta manera. El servicio real envía solicitudes a un servidor remoto. Un servidor toma tiempo en responder y la respuesta ciertamente no estará disponible inmediatamente como en las dos pruebas anteriores.
Tus pruebas reflejarán el mundo real más fielmente si retornas un observable asíncrono del spy getQuote() así.
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
Helpers de observable async
El observable async fue producido por un helper asyncData.
El helper asyncData es una función utilitaria que tendrás que escribir tú mismo, o copiar esta de el código de muestra.
testing/async-observable-helpers.ts
/* * Mock async observables that return asynchronously. * The observable either emits once and completes or errors. * * Must call `tick()` when test with `fakeAsync()`. * * THE FOLLOWING DON'T WORK * Using `of().delay()` triggers TestBed errors; * see https://github.com/angular/angular/issues/10127 . * * Using `asap` scheduler - as in `of(value, asap)` - doesn't work either. */import {defer} from 'rxjs';/** * Create async observable that emits-once and completes * after a JS engine turn */export function asyncData<T>(data: T) { return defer(() => Promise.resolve(data));}/** * Create async observable error that errors * after a JS engine turn */export function asyncError<T>(errorObject: any) { return defer(() => Promise.reject(errorObject));}
El observable de este helper emite el valor data en el siguiente turno del motor de JavaScript.
El operador defer() de RxJS retorna un observable.
Toma una función factory que retorna ya sea una promesa o un observable.
Cuando algo se suscribe al observable de defer, agrega el suscriptor a un nuevo observable creado con esa factory.
El operador defer() transforma el Promise.resolve() en un nuevo observable que, como HttpClient, emite una vez y se completa.
Los suscriptores se desuscriben después de recibir el valor de datos.
Hay un helper similar para producir un error async.
/* * Mock async observables that return asynchronously. * The observable either emits once and completes or errors. * * Must call `tick()` when test with `fakeAsync()`. * * THE FOLLOWING DON'T WORK * Using `of().delay()` triggers TestBed errors; * see https://github.com/angular/angular/issues/10127 . * * Using `asap` scheduler - as in `of(value, asap)` - doesn't work either. */import {defer} from 'rxjs';/** * Create async observable that emits-once and completes * after a JS engine turn */export function asyncData<T>(data: T) { return defer(() => Promise.resolve(data));}/** * Create async observable error that errors * after a JS engine turn */export function asyncError<T>(errorObject: any) { return defer(() => Promise.reject(errorObject));}
Más pruebas async
Ahora que el spy getQuote() está retornando observables async, la mayoría de tus pruebas tendrán que ser async también.
Aquí hay una prueba fakeAsync() que demuestra el flujo de datos que esperarías en el mundo real.
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
Nota que el elemento quote muestra el valor placeholder ('...') después de ngOnInit().
La primera cita aún no ha llegado.
Para vaciar la primera cita del observable, llamas a tick().
Luego llamas a detectChanges() para decirle a Angular que actualice la pantalla.
Entonces puedes afirmar que el elemento quote muestra el texto esperado.
Prueba async sin fakeAsync()
Aquí está la prueba fakeAsync() anterior, reescrita con el async.
import {fakeAsync, ComponentFixture, TestBed, tick, waitForAsync} from '@angular/core/testing';import {asyncData, asyncError} from '../../testing';import {Subject, defer, of, throwError} from 'rxjs';import {last} from 'rxjs/operators';import {TwainComponent} from './twain.component';import {TwainService} from './twain.service';describe('TwainComponent', () => { let component: TwainComponent; let fixture: ComponentFixture<TwainComponent>; let getQuoteSpy: jasmine.Spy; let quoteEl: HTMLElement; let testQuote: string; // Helper function to get the error message element value // An *ngIf keeps it out of the DOM until there is an error const errorMessage = () => { const el = fixture.nativeElement.querySelector('.error'); return el ? el.textContent : null; }; beforeEach(() => { TestBed.configureTestingModule({ providers: [TwainService], }); testQuote = 'Test Quote'; // Create a fake TwainService object with a `getQuote()` spy const twainService = TestBed.inject(TwainService); // Make the spy return a synchronous Observable with the test data getQuoteSpy = spyOn(twainService, 'getQuote').and.returnValue(of(testQuote)); fixture = TestBed.createComponent(TwainComponent); fixture.autoDetectChanges(); component = fixture.componentInstance; quoteEl = fixture.nativeElement.querySelector('.twain'); }); describe('when test with synchronous observable', () => { it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); // The quote would not be immediately available if the service were truly async. it('should show quote after component initialized', async () => { await fixture.whenStable(); // onInit() // sync spy result shows testQuote immediately after init expect(quoteEl.textContent).toBe(testQuote); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); // The error would not be immediately available if the service were truly async. // Use `fakeAsync` because the component error calls `setTimeout` it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an error observable after a timeout getQuoteSpy.and.returnValue( defer(() => { return new Promise((resolve, reject) => { setTimeout(() => { reject('TwainService test failure'); }); }); }), ); fixture.detectChanges(); // onInit() // sync spy errors immediately after init tick(); // flush the setTimeout() fixture.detectChanges(); // update errorMessage within setTimeout() expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); }); describe('when test with asynchronous observable', () => { beforeEach(() => { // Simulate delayed observable values with the `asyncData()` helper getQuoteSpy.and.returnValue(asyncData(testQuote)); }); it('should not show quote before OnInit', () => { expect(quoteEl.textContent).withContext('nothing displayed').toBe(''); expect(errorMessage()).withContext('should not show error element').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote not yet called').toBe(false); }); it('should still not show quote after component initialized', () => { fixture.detectChanges(); // getQuote service is async => still has not returned with quote // so should show the start value, '...' expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); expect(errorMessage()).withContext('should not show error').toBeNull(); expect(getQuoteSpy.calls.any()).withContext('getQuote called').toBe(true); }); it('should show quote after getQuote (fakeAsync)', fakeAsync(() => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); tick(); // flush the observable to get the quote fixture.detectChanges(); // update view expect(quoteEl.textContent).withContext('should show quote').toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); })); it('should show quote after getQuote (async)', async () => { fixture.detectChanges(); // ngOnInit() expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); await fixture.whenStable(); // wait for async getQuote fixture.detectChanges(); // update view with quote expect(quoteEl.textContent).toBe(testQuote); expect(errorMessage()).withContext('should not show error').toBeNull(); }); it('should display error when TwainService fails', fakeAsync(() => { // tell spy to return an async error observable getQuoteSpy.and.returnValue(asyncError<string>('TwainService test failure')); fixture.detectChanges(); tick(); // component shows error after a setTimeout() fixture.detectChanges(); // update error message expect(errorMessage()) .withContext('should display error') .toMatch(/test failure/); expect(quoteEl.textContent).withContext('should show placeholder').toBe('...'); })); });});
whenStable
La prueba debe esperar a que el observable getQuote() emita la siguiente cita.
En lugar de llamar a tick(), llama a fixture.whenStable().
El fixture.whenStable() retorna una promesa que se resuelve cuando la cola de tareas del motor de JavaScript se vuelve vacía.
En este ejemplo, la cola de tareas se vuelve vacía cuando el observable emite la primera cita.
Componente con inputs y outputs
Un componente con inputs y outputs típicamente aparece dentro de la plantilla de vista de un componente host. El host usa un property binding para establecer la propiedad input y un event binding para escuchar eventos generados por la propiedad output.
El objetivo de testing es verificar que tales bindings funcionen como se espera. Las pruebas deberían establecer valores de input y escuchar eventos de output.
El DashboardHeroComponent es un pequeño ejemplo de un componente en este rol.
Muestra un héroe individual proporcionado por el DashboardComponent.
Hacer clic en ese héroe le dice al DashboardComponent que el usuario ha seleccionado el héroe.
El DashboardHeroComponent está incrustado en la plantilla DashboardComponent así:
app/dashboard/dashboard.component.html (excerpt)
<h2 highlight>{{ title }}</h2><div class="grid grid-pad"> @for (hero of heroes; track hero) { <dashboard-hero class="col-1-4" [hero]="hero" (selected)="gotoDetail($event)" > </dashboard-hero> }</div>
El DashboardHeroComponent aparece en un bloque @for, que establece la propiedad input hero de cada componente al valor de bucle y escucha el evento selected del componente.
Aquí está la definición completa del componente:
app/dashboard/dashboard-hero.component.ts (component)
import {Component, input, output} from '@angular/core';import {UpperCasePipe} from '@angular/common';import {Hero} from '../model/hero';@Component({ selector: 'dashboard-hero', template: ` <button type="button" (click)="click()" class="hero"> {{ hero().name | uppercase }} </button> `, styleUrls: ['./dashboard-hero.component.css'], imports: [UpperCasePipe],})export class DashboardHeroComponent { readonly hero = input.required<Hero>(); readonly selected = output<Hero>(); click() { this.selected.emit(this.hero()); }}
Aunque probar un componente tan simple tiene poco valor intrínseco, vale la pena saber cómo. Usa uno de estos enfoques:
- Probarlo como se usa por
DashboardComponent - Probarlo como un componente standalone
- Probarlo como se usa por un sustituto para
DashboardComponent
El objetivo inmediato es probar el DashboardHeroComponent, no el DashboardComponent, así que, prueba la segunda y tercera opciones.
Probar DashboardHeroComponent standalone
Aquí está la parte principal de la configuración del archivo spec.
app/dashboard/dashboard-hero.component.spec.ts (setup)
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Nota cómo el código de configuración asigna un héroe de prueba (expectedHero) a la propiedad hero del componente, emulando la forma en que el DashboardComponent lo establecería usando el property binding en su repeater.
La siguiente prueba verifica que el nombre del héroe se propaga a la plantilla usando un binding.
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Porque la plantilla pasa el nombre del héroe a través del UpperCasePipe de Angular, la prueba debe coincidir el valor del elemento con el nombre en mayúsculas.
Hacer clic
Hacer clic en el héroe debería generar un evento selected que el componente host (DashboardComponent presumiblemente) puede escuchar:
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
La propiedad selected del componente retorna un EventEmitter, que se ve como un Observable síncrono de RxJS para los consumidores.
La prueba se suscribe a él explícitamente igual que el componente host lo hace implícitamente.
Si el componente se comporta como se espera, hacer clic en el elemento del héroe debería decirle a la propiedad selected del componente que emita el objeto hero.
La prueba detecta ese evento a través de su suscripción a selected.
triggerEventHandler
El heroDe en la prueba anterior es un DebugElement que representa el <div> del héroe.
Tiene propiedades y métodos de Angular que abstraen la interacción con el elemento nativo.
Esta prueba llama al DebugElement.triggerEventHandler con el nombre de evento "click".
El binding de evento "click" responde llamando a DashboardHeroComponent.click().
El DebugElement.triggerEventHandler de Angular puede generar cualquier evento vinculado a datos por su nombre de evento.
El segundo parámetro es el objeto de evento pasado al handler.
La prueba desencadenó un evento "click".
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
En este caso, la prueba asume correctamente que el handler de evento de runtime, el método click() del componente, no se preocupa por el objeto de evento.
ÚTIL: Otros handlers son menos indulgentes.
Por ejemplo, la directiva RouterLink espera un objeto con una propiedad button que identifica qué botón del mouse, si alguno, fue presionado durante el clic.
La directiva RouterLink lanza un error si el objeto de evento falta.
Hacer clic en el elemento
La siguiente alternativa de prueba llama al método click() propio del elemento nativo, que es perfectamente fino para este componente.
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Helper click()
Hacer clic en un botón, un anchor, o un elemento HTML arbitrario es una tarea de prueba común.
Haz eso consistente y directo encapsulando el proceso click-triggering en un helper como la siguiente función click():
testing/index.ts (click helper)
import {DebugElement} from '@angular/core';import {ComponentFixture, tick} from '@angular/core/testing';export * from './async-observable-helpers';export * from './jasmine-matchers';///// Short utilities //////** Wait a tick, then detect changes */export function advance(f: ComponentFixture<any>): void { tick(); f.detectChanges();}// See https://developer.mozilla.org/docs/Web/API/MouseEvent/button/** 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); }}
El primer parámetro es el elemento-a-hacer-clic.
Si quieres, pasa un objeto de evento personalizado como segundo parámetro.
El predeterminado es un objeto de evento de mouse de botón izquierdo parcial aceptado por muchos handlers incluyendo la directiva RouterLink.
IMPORTANTE: La función helper click() no es una de las utilidades de testing de Angular.
Es una función definida en el código de muestra de esta guía.
Todas las pruebas de muestra la usan.
Si te gusta, agrégala a tu propia colección de helpers.
Aquí está la prueba anterior, reescrita usando el helper click.
app/dashboard/dashboard-hero.component.spec.ts (test with click helper)
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Componente dentro de un test host
Las pruebas anteriores jugaron el rol del DashboardComponent host ellas mismas.
¿Pero funciona correctamente el DashboardHeroComponent cuando está correctamente vinculado a datos a un componente host?
app/dashboard/dashboard-hero.component.spec.ts (test host)
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
El test host establece la propiedad input hero del componente con su héroe de prueba.
Vincula el evento selected del componente con su handler onSelected, que registra el héroe emitido en su propiedad selectedHero.
Más tarde, las pruebas podrán verificar selectedHero para verificar que el evento DashboardHeroComponent.selected emitió el héroe esperado.
La configuración para las pruebas test-host es similar a la configuración para las pruebas standalone:
app/dashboard/dashboard-hero.component.spec.ts (test host setup)
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Esta configuración de módulo de testing muestra dos diferencias importantes:
- Crea el
TestHostComponenten lugar delDashboardHeroComponent - El
TestHostComponentestablece elDashboardHeroComponent.herocon un binding
El createComponent retorna un fixture que contiene una instancia de TestHostComponent en lugar de una instancia de DashboardHeroComponent.
Crear el TestHostComponent tiene el efecto secundario de crear un DashboardHeroComponent porque este último aparece dentro de la plantilla del primero.
La consulta para el elemento héroe (heroEl) aún lo encuentra en el DOM de prueba, aunque a mayor profundidad en el árbol de elementos que antes.
Las pruebas mismas son casi idénticas a la versión standalone:
app/dashboard/dashboard-hero.component.spec.ts (test-host)
import {DebugElement} from '@angular/core';import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {first} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {appProviders} from '../app.config';import {Hero} from '../model/hero';import {DashboardHeroComponent} from './dashboard-hero.component';beforeEach(addMatchers);describe('DashboardHeroComponent when tested directly', () => { let comp: DashboardHeroComponent; let expectedHero: Hero; let fixture: ComponentFixture<DashboardHeroComponent>; let heroDe: DebugElement; let heroEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ providers: appProviders, }); }); beforeEach(async () => { fixture = TestBed.createComponent(DashboardHeroComponent); fixture.autoDetectChanges(); 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(); }); it('should display hero name in uppercase', () => { const expectedPipedName = expectedHero.name.toUpperCase(); expect(heroEl.textContent).toContain(expectedPipedName); }); 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); }); 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); }); 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); }); it('should raise selected event when clicked (click helper with native element)', () => { let selectedHero: Hero | undefined; comp.selected.subscribe((hero: Hero) => (selectedHero = hero)); click(heroEl); // click helper with native element expect(selectedHero).toBe(expectedHero); });});//////////////////describe('DashboardHeroComponent when inside a test host', () => { let testHost: TestHostComponent; let fixture: ComponentFixture<TestHostComponent>; let heroEl: HTMLElement; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ providers: appProviders, }); })); beforeEach(() => { // create TestHostComponent instead of DashboardHeroComponent fixture = TestBed.createComponent(TestHostComponent); testHost = fixture.componentInstance; heroEl = fixture.nativeElement.querySelector('.hero'); fixture.detectChanges(); // trigger initial data binding }); 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); });});////// Test Host Component //////import {Component} from '@angular/core';@Component({ imports: [DashboardHeroComponent], template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)"> </dashboard-hero>`,})class TestHostComponent { hero: Hero = {id: 42, name: 'Test Name'}; selectedHero: Hero | undefined; onSelected(hero: Hero) { this.selectedHero = hero; }}
Solo la prueba de evento selected difiere.
Confirma que el héroe DashboardHeroComponent seleccionado realmente encuentra su camino hacia arriba a través del event binding al componente host.
Componente de enrutamiento
Un componente de enrutamiento es un componente que le dice al Router que navegue a otro componente.
El DashboardComponent es un componente de enrutamiento porque el usuario puede navegar al HeroDetailComponent haciendo clic en uno de los botones de héroe en el dashboard.
Angular proporciona helpers de prueba para reducir boilerplate y probar más efectivamente código que depende de HttpClient. La función provideRouter puede usarse directamente en el módulo de prueba también.
app/dashboard/dashboard.component.spec.ts
import {provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {NO_ERRORS_SCHEMA} from '@angular/core';import {TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {NavigationEnd, provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {firstValueFrom} from 'rxjs';import {filter} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {HeroService} from '../model/hero.service';import {getTestHeroes} from '../model/testing/test-heroes';import {DashboardComponent} from './dashboard.component';import {appConfig} from '../app.config';import {HeroDetailComponent} from '../hero/hero-detail.component';beforeEach(addMatchers);let comp: DashboardComponent;let harness: RouterTestingHarness;//////// Deep ////////////////describe('DashboardComponent (deep)', () => { compileAndCreate(); tests(clickForDeep); function clickForDeep() { // get first <div class="hero"> const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!; click(heroEl); return firstValueFrom( TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)), ); }});//////// Shallow ////////////////describe('DashboardComponent (shallow)', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [DashboardComponent, HeroDetailComponent], providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])], schemas: [NO_ERRORS_SCHEMA], }), ); }); compileAndCreate(); tests(clickForShallow); function clickForShallow() { // get first <dashboard-hero> DebugElement const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero')); heroDe.triggerEventHandler('selected', comp.heroes[0]); return Promise.resolve(); }});/** Add TestBed providers, compile, and create DashboardComponent */function compileAndCreate() { beforeEach(async () => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [ provideRouter([{path: '**', component: DashboardComponent}]), provideHttpClient(), provideHttpClientTesting(), HeroService, ], }), ); harness = await RouterTestingHarness.create(); comp = await harness.navigateByUrl('/', DashboardComponent); TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes()); });}/** * The (almost) same tests for both. * Only change: the way that the first hero is clicked */function tests(heroClick: () => Promise<unknown>) { describe('after get dashboard heroes', () => { let router: Router; // Trigger component so it gets heroes and binds to them beforeEach(waitForAsync(() => { router = TestBed.inject(Router); harness.detectChanges(); // runs ngOnInit -> getHeroes })); it('should HAVE heroes', () => { expect(comp.heroes.length) .withContext('should have heroes after service promise resolves') .toBeGreaterThan(0); }); it('should DISPLAY heroes', () => { // Find and examine the displayed heroes // Look for them in the DOM by css class const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero'); expect(heroes.length).withContext('should display 4 heroes').toBe(4); }); it('should tell navigate when hero clicked', async () => { await heroClick(); // trigger click on first inner <div class="hero"> // expecting to navigate to id of the component's first hero const id = comp.heroes[0].id; expect(TestBed.inject(Router).url) .withContext('should nav to HeroDetail for first hero') .toEqual(`/heroes/${id}`); }); });}
La siguiente prueba hace clic en el héroe mostrado y confirma que navegamos a la URL esperada.
app/dashboard/dashboard.component.spec.ts (navigate test)
import {provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {NO_ERRORS_SCHEMA} from '@angular/core';import {TestBed, waitForAsync} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {NavigationEnd, provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {firstValueFrom} from 'rxjs';import {filter} from 'rxjs/operators';import {addMatchers, click} from '../../testing';import {HeroService} from '../model/hero.service';import {getTestHeroes} from '../model/testing/test-heroes';import {DashboardComponent} from './dashboard.component';import {appConfig} from '../app.config';import {HeroDetailComponent} from '../hero/hero-detail.component';beforeEach(addMatchers);let comp: DashboardComponent;let harness: RouterTestingHarness;//////// Deep ////////////////describe('DashboardComponent (deep)', () => { compileAndCreate(); tests(clickForDeep); function clickForDeep() { // get first <div class="hero"> const heroEl: HTMLElement = harness.routeNativeElement!.querySelector('.hero')!; click(heroEl); return firstValueFrom( TestBed.inject(Router).events.pipe(filter((e) => e instanceof NavigationEnd)), ); }});//////// Shallow ////////////////describe('DashboardComponent (shallow)', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [DashboardComponent, HeroDetailComponent], providers: [provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}])], schemas: [NO_ERRORS_SCHEMA], }), ); }); compileAndCreate(); tests(clickForShallow); function clickForShallow() { // get first <dashboard-hero> DebugElement const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero')); heroDe.triggerEventHandler('selected', comp.heroes[0]); return Promise.resolve(); }});/** Add TestBed providers, compile, and create DashboardComponent */function compileAndCreate() { beforeEach(async () => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [ provideRouter([{path: '**', component: DashboardComponent}]), provideHttpClient(), provideHttpClientTesting(), HeroService, ], }), ); harness = await RouterTestingHarness.create(); comp = await harness.navigateByUrl('/', DashboardComponent); TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes()); });}/** * The (almost) same tests for both. * Only change: the way that the first hero is clicked */function tests(heroClick: () => Promise<unknown>) { describe('after get dashboard heroes', () => { let router: Router; // Trigger component so it gets heroes and binds to them beforeEach(waitForAsync(() => { router = TestBed.inject(Router); harness.detectChanges(); // runs ngOnInit -> getHeroes })); it('should HAVE heroes', () => { expect(comp.heroes.length) .withContext('should have heroes after service promise resolves') .toBeGreaterThan(0); }); it('should DISPLAY heroes', () => { // Find and examine the displayed heroes // Look for them in the DOM by css class const heroes = harness.routeNativeElement!.querySelectorAll('dashboard-hero'); expect(heroes.length).withContext('should display 4 heroes').toBe(4); }); it('should tell navigate when hero clicked', async () => { await heroClick(); // trigger click on first inner <div class="hero"> // expecting to navigate to id of the component's first hero const id = comp.heroes[0].id; expect(TestBed.inject(Router).url) .withContext('should nav to HeroDetail for first hero') .toEqual(`/heroes/${id}`); }); });}
Componentes enrutados
Un componente enrutado es el destino de una navegación Router.
Puede ser más difícil de probar, especialmente cuando la ruta al componente incluye parámetros.
El HeroDetailComponent es un componente enrutado que es el destino de tal ruta.
Cuando un usuario hace clic en un héroe de Dashboard, el DashboardComponent le dice al Router que navegue a heroes/:id.
El :id es un parámetro de ruta cuyo valor es el id del héroe a editar.
El Router coincide esa URL con una ruta al HeroDetailComponent.
Crea un objeto ActivatedRoute con la información de enrutamiento y lo inyecta en una nueva instancia del HeroDetailComponent.
Aquí están los servicios inyectados en HeroDetailComponent:
app/hero/hero-detail.component.ts (inject)
import {Component, inject} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({ selector: 'app-hero-detail', templateUrl: './hero-detail.component.html', styleUrls: ['./hero-detail.component.css'], providers: [HeroDetailService], imports: [...sharedImports],})export class HeroDetailComponent { private heroDetailService = inject(HeroDetailService); private route = inject(ActivatedRoute); private router = inject(Router); hero!: Hero; constructor() { // get hero when `id` param changes this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id'))); } private getHero(id: string | null): void { // when no id or id===0, create new blank hero if (!id) { this.hero = {id: 0, name: ''} as Hero; return; } this.heroDetailService.getHero(id).subscribe((hero) => { if (hero) { this.hero = hero; } else { this.gotoList(); // id not found; navigate to list } }); } save(): void { this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList()); } cancel() { this.gotoList(); } gotoList() { this.router.navigate(['../'], {relativeTo: this.route}); }}
El componente HeroDetail necesita el parámetro id para poder obtener el héroe correspondiente usando el HeroDetailService.
El componente tiene que obtener el id de la propiedad ActivatedRoute.paramMap que es un Observable.
No puede simplemente referenciar la propiedad id del ActivatedRoute.paramMap.
El componente tiene que suscribirse al observable ActivatedRoute.paramMap y estar preparado para que el id cambie durante su vida útil.
app/hero/hero-detail.component.ts (constructor)
import {Component, inject} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({ selector: 'app-hero-detail', templateUrl: './hero-detail.component.html', styleUrls: ['./hero-detail.component.css'], providers: [HeroDetailService], imports: [...sharedImports],})export class HeroDetailComponent { private heroDetailService = inject(HeroDetailService); private route = inject(ActivatedRoute); private router = inject(Router); hero!: Hero; constructor() { // get hero when `id` param changes this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id'))); } private getHero(id: string | null): void { // when no id or id===0, create new blank hero if (!id) { this.hero = {id: 0, name: ''} as Hero; return; } this.heroDetailService.getHero(id).subscribe((hero) => { if (hero) { this.hero = hero; } else { this.gotoList(); // id not found; navigate to list } }); } save(): void { this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList()); } cancel() { this.gotoList(); } gotoList() { this.router.navigate(['../'], {relativeTo: this.route}); }}
Las pruebas pueden explorar cómo el HeroDetailComponent responde a diferentes valores de parámetro id navegando a diferentes rutas.
Pruebas de componentes anidados
Las plantillas de componentes a menudo tienen componentes anidados, cuyas plantillas podrían contener más componentes.
El árbol de componentes puede ser muy profundo y a veces los componentes anidados no juegan ningún rol en probar el componente en la parte superior del árbol.
El AppComponent, por ejemplo, muestra una barra de navegación con anchors y sus directivas RouterLink.
app/app.component.html
<app-banner></app-banner><app-welcome></app-welcome><nav> <a routerLink="/dashboard">Dashboard</a> <a routerLink="/heroes">Heroes</a> <a routerLink="/about">About</a></nav><router-outlet></router-outlet>
Para validar los enlaces pero no la navegación, no necesitas el Router para navegar y no necesitas el <router-outlet> para marcar dónde el Router inserta componentes enrutados.
El BannerComponent y WelcomeComponent (indicados por <app-banner> y <app-welcome>) también son irrelevantes.
Sin embargo, cualquier prueba que cree el AppComponent en el DOM también crea instancias de estos tres componentes y, si dejas que eso suceda, tendrás que configurar el TestBed para crearlos.
Si descuidas declararlos, el compilador de Angular no reconocerá las etiquetas <app-banner>, <app-welcome> y <router-outlet> en la plantilla AppComponent y lanzará un error.
Si declaras los componentes reales, también tendrás que declarar sus componentes anidados y proporcionar todos los servicios inyectados en cualquier componente en el árbol.
Esta sección describe dos técnicas para minimizar la configuración. Úsalas, solas o en combinación, para mantener el foco en probar el componente primario.
Hacer stub de componentes innecesarios
En la primera técnica, creas y declaras versiones stub de los componentes y directiva que juegan poco o ningún rol en las pruebas.
app/app.component.spec.ts (stub declaration)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
Los selectores stub coinciden con los selectores para los componentes reales correspondientes. Pero sus plantillas y clases están vacías.
Luego declár alos sobrescribiendo los imports de tu componente usando TestBed.overrideComponent.
app/app.component.spec.ts (TestBed stubs)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
ÚTIL: La clave set en este ejemplo reemplaza todos los imports existentes en tu componente, asegúrate de importar todas las dependencias, no solo los stubs. Alternativamente puedes usar las claves remove/add para remover y agregar imports selectivamente.
NO_ERRORS_SCHEMA
En el segundo enfoque, agrega NO_ERRORS_SCHEMA a los overrides de metadata de tu componente.
app/app.component.spec.ts (NO_ERRORS_SCHEMA)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
El NO_ERRORS_SCHEMA le dice al compilador de Angular que ignore elementos y atributos no reconocidos.
El compilador reconoce el elemento <app-root> y el atributo routerLink porque declaraste un AppComponent y RouterLink correspondientes en la configuración del TestBed.
Pero el compilador no lanzará un error cuando encuentre <app-banner>, <app-welcome> o <router-outlet>.
Simplemente los renderiza como etiquetas vacías y el navegador los ignora.
Ya no necesitas los componentes stub.
Usar ambas técnicas juntas
Estas son técnicas para Shallow Component Testing, llamadas así porque reducen la superficie visual del componente solo a aquellos elementos en la plantilla del componente que importan para las pruebas.
El enfoque NO_ERRORS_SCHEMA es el más fácil de los dos pero no lo uses en exceso.
El NO_ERRORS_SCHEMA también evita que el compilador te diga sobre los componentes y atributos faltantes que omitiste inadvertidamente o escribiste mal.
Podrías desperdiciar horas persiguiendo bugs fantasma que el compilador habría capturado en un instante.
El enfoque de componente stub tiene otra ventaja. Aunque los stubs en este ejemplo están vacíos, podrías darles plantillas y clases reducidas si tus pruebas necesitan interactuar con ellos de alguna manera.
En la práctica combinarás las dos técnicas en la misma configuración, como se ve en este ejemplo.
app/app.component.spec.ts (mixed setup)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
El compilador de Angular crea el BannerStubComponent para el elemento <app-banner> y aplica el RouterLink a los anchors con el atributo routerLink, pero ignora las etiquetas <app-welcome> y <router-outlet>.
By.directive y directivas inyectadas
Un poco más de configuración desencadena el binding de datos inicial y obtiene referencias a los enlaces de navegación:
app/app.component.spec.ts (test setup)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
Tres puntos de interés especial:
- Localizar los elementos anchor con una directiva adjunta usando
By.directive - La consulta retorna wrappers
DebugElementalrededor de los elementos coincidentes - Cada
DebugElementexpone un inyector de dependencias con la instancia específica de la directiva adjunta a ese elemento
Los enlaces del AppComponent a validar son los siguientes:
app/app.component.html (navigation links)
<app-banner></app-banner><app-welcome></app-welcome><nav> <a routerLink="/dashboard">Dashboard</a> <a routerLink="/heroes">Heroes</a> <a routerLink="/about">About</a></nav><router-outlet></router-outlet>
Aquí hay algunas pruebas que confirman que esos enlaces están conectados a las directivas routerLink como se espera:
app/app.component.spec.ts (selected tests)
import {Component, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core';import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';import {By} from '@angular/platform-browser';import {provideRouter, Router, RouterLink, RouterOutlet} from '@angular/router';import {AppComponent} from './app.component';import {appConfig} from './app.config';import {UserService} from './model';import {WelcomeComponent} from './welcome/welcome.component';@Component({selector: 'app-banner', template: ''})class BannerStubComponent {}@Component({selector: 'router-outlet', template: ''})class RouterOutletStubComponent {}@Component({selector: 'app-welcome', template: ''})class WelcomeStubComponent {}let comp: AppComponent;let fixture: ComponentFixture<AppComponent>;describe('AppComponent & TestModule', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [BannerStubComponent, RouterLink, RouterOutletStubComponent, WelcomeStubComponent], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});//////// Testing w/ NO_ERRORS_SCHEMA //////describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { set: { imports: [], // resets all imports schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});describe('AppComponent & NO_ERRORS_SCHEMA', () => { beforeEach(() => { TestBed.configureTestingModule( Object.assign({}, appConfig, { providers: [provideRouter([]), UserService], }), ).overrideComponent(AppComponent, { remove: { imports: [RouterOutlet, WelcomeComponent], }, set: { schemas: [NO_ERRORS_SCHEMA], }, }); fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); tests();});function tests() { let routerLinks: RouterLink[]; let linkDes: DebugElement[]; beforeEach(() => { fixture.detectChanges(); // trigger initial data binding // 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)); }); it('can instantiate the component', () => { expect(comp).not.toBeNull(); }); it('can get RouterLinks from template', () => { expect(routerLinks.length).withContext('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', fakeAsync(() => { const heroesLinkDe = linkDes[1]; // heroes link DebugElement TestBed.inject(Router).resetConfig([{path: '**', children: []}]); heroesLinkDe.triggerEventHandler('click', {button: 0}); tick(); fixture.detectChanges(); expect(TestBed.inject(Router).url).toBe('/heroes'); }));}
Usar un objeto page
El HeroDetailComponent es una vista simple con un título, dos campos de héroe y dos botones.
Pero hay bastante complejidad de plantilla incluso en este formulario simple.
app/hero/hero-detail.component.html
@if (hero) { <div> <h2> <span>{{ hero.name | titlecase }}</span> Details </h2> <div><span>id: </span>{{ hero.id }}</div> <div> <label for="name">name: </label> <input id="name" [(ngModel)]="hero.name" placeholder="name" /> </div> <button type="button" (click)="save()">Save</button> <button type="button" (click)="cancel()">Cancel</button> </div>}
Las pruebas que ejercitan el componente necesitan…
- Esperar hasta que un héroe llegue antes de que los elementos aparezcan en el DOM
- Una referencia al texto del título
- Una referencia al cuadro de entrada de nombre para inspeccionarlo y establecerlo
- Referencias a los dos botones para poder hacer clic en ellos
Incluso un formulario pequeño como este puede producir un lío de configuración condicional tortuosa y selección de elementos CSS.
Domestica la complejidad con una clase Page que maneja el acceso a las propiedades del componente y encapsula la lógica que las establece.
Aquí hay tal clase Page para el hero-detail.component.spec.ts
app/hero/hero-detail.component.spec.ts (Page)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Ahora los hooks importantes para manipulación e inspección de componentes están organizados ordenadamente y son accesibles desde una instancia de Page.
Un método createComponent crea un objeto page y llena los espacios en blanco una vez que el hero llega.
app/hero/hero-detail.component.spec.ts (createComponent)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Aquí hay algunas pruebas más de HeroDetailComponent para reforzar el punto.
app/hero/hero-detail.component.spec.ts (selected tests)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Override component providers
El HeroDetailComponent proporciona su propio HeroDetailService.
app/hero/hero-detail.component.ts (prototype)
import {Component, inject} from '@angular/core';import {ActivatedRoute, Router, RouterLink} from '@angular/router';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailService} from './hero-detail.service';@Component({ selector: 'app-hero-detail', templateUrl: './hero-detail.component.html', styleUrls: ['./hero-detail.component.css'], providers: [HeroDetailService], imports: [...sharedImports],})export class HeroDetailComponent { private heroDetailService = inject(HeroDetailService); private route = inject(ActivatedRoute); private router = inject(Router); hero!: Hero; constructor() { // get hero when `id` param changes this.route.paramMap.subscribe((pmap) => this.getHero(pmap.get('id'))); } private getHero(id: string | null): void { // when no id or id===0, create new blank hero if (!id) { this.hero = {id: 0, name: ''} as Hero; return; } this.heroDetailService.getHero(id).subscribe((hero) => { if (hero) { this.hero = hero; } else { this.gotoList(); // id not found; navigate to list } }); } save(): void { this.heroDetailService.saveHero(this.hero).subscribe(() => this.gotoList()); } cancel() { this.gotoList(); } gotoList() { this.router.navigate(['../'], {relativeTo: this.route}); }}
No es posible hacer stub del HeroDetailService del componente en los providers del TestBed.configureTestingModule.
Esos son providers para el módulo de testing, no el componente.
Preparan el inyector de dependencias en el nivel del fixture.
Angular crea el componente con su propio inyector, que es un hijo del inyector del fixture.
Registra los providers del componente (el HeroDetailService en este caso) con el inyector hijo.
Una prueba no puede llegar a servicios del inyector hijo desde el inyector del fixture.
Y TestBed.configureTestingModule tampoco puede configurarlos.
¡Angular ha creado nuevas instancias del HeroDetailService real todo el tiempo!
ÚTIL: Estas pruebas podrían fallar o agotar el tiempo de espera si el HeroDetailService hiciera sus propias llamadas XHR a un servidor remoto.
Podría no haber un servidor remoto al cual llamar.
Afortunadamente, el HeroDetailService delega responsabilidad para acceso a datos remotos a un HeroService inyectado.
app/hero/hero-detail.service.ts (prototype)
import {inject, Injectable} from '@angular/core';import {Observable} from 'rxjs';import {map} from 'rxjs/operators';import {Hero} from '../model/hero';import {HeroService} from '../model/hero.service';@Injectable({providedIn: 'root'})export class HeroDetailService { private heroService = inject(HeroService); // Returns a clone which caller may modify safely getHero(id: number | string): Observable<Hero | null> { if (typeof id === 'string') { id = parseInt(id, 10); } return this.heroService.getHero(id).pipe( map((hero) => (hero ? Object.assign({}, hero) : null)), // clone or null ); } saveHero(hero: Hero) { return this.heroService.updateHero(hero); }}
La configuración de prueba anterior reemplaza el HeroService real con un TestHeroService que intercepta solicitudes de servidor y falsifica sus respuestas.
¿Qué pasa si no tienes tanta suerte?
¿Qué pasa si falsificar el HeroService es difícil?
¿Qué pasa si HeroDetailService hace sus propias solicitudes de servidor?
El método TestBed.overrideComponent puede reemplazar los providers del componente con test doubles fáciles de manejar como se ve en la siguiente variación de configuración:
app/hero/hero-detail.component.spec.ts (Override setup)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Nota que TestBed.configureTestingModule ya no proporciona un HeroService falso porque no es necesario.
El método overrideComponent
Enfócate en el método overrideComponent.
app/hero/hero-detail.component.spec.ts (overrideComponent)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Toma dos argumentos: el tipo de componente a sobrescribir (HeroDetailComponent) y un objeto de metadata de override.
El objeto de metadata de override es un genérico definido como sigue:
type MetadataOverride<T> = { add?: Partial<T>; remove?: Partial<T>; set?: Partial<T>;};
Un objeto de metadata override puede agregar-y-remover elementos en propiedades de metadata o restablecer completamente esas propiedades.
Este ejemplo restablece los metadata providers del componente.
El parámetro de tipo, T, es el tipo de metadata que pasarías al decorador @Component:
selector?: string;template?: string;templateUrl?: string;providers?: any[];…
Proporcionar un spy stub (HeroDetailServiceSpy)
Este ejemplo reemplaza completamente el array providers del componente con un nuevo array que contiene un HeroDetailServiceSpy.
El HeroDetailServiceSpy es una versión stubbed del HeroDetailService real que falsifica todas las características necesarias de ese servicio.
No inyecta ni delega al HeroService de nivel inferior, por lo que no hay necesidad de proporcionar un test double para eso.
Las pruebas relacionadas con HeroDetailComponent afirmarán que los métodos del HeroDetailService fueron llamados espiando en los métodos del servicio.
En consecuencia, el stub implementa sus métodos como spies:
app/hero/hero-detail.component.spec.ts (HeroDetailServiceSpy)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Las pruebas override
Ahora las pruebas pueden controlar el héroe del componente directamente manipulando el testHero del spy-stub y confirmar que los métodos del servicio fueron llamados.
app/hero/hero-detail.component.spec.ts (override tests)
import {HttpClient, HttpHandler, provideHttpClient} from '@angular/common/http';import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';import {fakeAsync, TestBed, tick} from '@angular/core/testing';import {provideRouter, Router} from '@angular/router';import {RouterTestingHarness} from '@angular/router/testing';import {asyncData, click} from '../../testing';import {Hero} from '../model/hero';import {sharedImports} from '../shared/shared';import {HeroDetailComponent} from './hero-detail.component';import {HeroDetailService} from './hero-detail.service';import {HeroListComponent} from './hero-list.component';////// Testing Vars //////let component: HeroDetailComponent;let harness: RouterTestingHarness;let page: Page;////// Tests //////describe('HeroDetailComponent', () => { describe('with HeroModule setup', heroModuleSetup); describe('when override its provided HeroDetailService', overrideSetup); describe('with FormsModule setup', formsModuleSetup); describe('with SharedModule setup', sharedModuleSetup);});///////////////////const testHero = getTestHeroes()[0];function overrideSetup() { class HeroDetailServiceSpy { testHero: Hero = {...testHero}; /* emit cloned test hero */ getHero = jasmine .createSpy('getHero') .and.callFake(() => asyncData(Object.assign({}, this.testHero))); /* emit clone of test hero, with changes merged in */ saveHero = jasmine .createSpy('saveHero') .and.callFake((hero: Hero) => asyncData(Object.assign(this.testHero, hero))); } beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes', component: HeroListComponent}, {path: 'heroes/:id', component: HeroDetailComponent}, ]), HttpClient, HttpHandler, // HeroDetailService at this level is IRRELEVANT! {provide: HeroDetailService, useValue: {}}, ], }), ) .overrideComponent(HeroDetailComponent, { set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]}, }); }); let hdsSpy: HeroDetailServiceSpy; beforeEach(async () => { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetailComponent); 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.calls.count()) .withContext('getHero called once') .toBe(1, 'getHero called once'); }); it("should display stub hero's name", () => { expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name); }); it('should save stub hero change', fakeAsync(() => { 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).withContext('component hero has new name').toBe(newName); expect(hdsSpy.testHero.name).withContext('service hero unchanged before save').toBe(origName); click(page.saveBtn); expect(hdsSpy.saveHero.calls.count()).withContext('saveHero called once').toBe(1); tick(); // wait for async save to complete expect(hdsSpy.testHero.name).withContext('service hero has new name after save').toBe(newName); expect(TestBed.inject(Router).url).toEqual('/heroes'); }));}////////////////////import {getTestHeroes} from '../model/testing/test-hero.service';const firstHero = getTestHeroes()[0];function heroModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, HeroListComponent], providers: [ provideRouter([ {path: 'heroes/:id', component: HeroDetailComponent}, {path: 'heroes', component: HeroListComponent}, ]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); describe('when navigate to existing hero', () => { let expectedHero: Hero; beforeEach(async () => { expectedHero = firstHero; await createComponent(expectedHero.id); }); 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', fakeAsync(() => { click(page.saveBtn); tick(); // wait for async save to complete expect(TestBed.inject(Router).url).toEqual('/heroes/41'); })); it('should convert hero name to Title Case', async () => { harness.fixture.autoDetectChanges(); // 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'); }); }); describe('when navigate to non-existent hero id', () => { beforeEach(async () => { await createComponent(999); }); it('should try to navigate back to hero list', () => { expect(TestBed.inject(Router).url).toEqual('/heroes'); }); });}/////////////////////import {FormsModule} from '@angular/forms';import {TitleCasePipe} from '../shared/title-case.pipe';import {appConfig} from '../app.config';function formsModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [FormsModule, HeroDetailComponent, TitleCasePipe], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}///////////////////////function sharedModuleSetup() { beforeEach(async () => { await TestBed.configureTestingModule( Object.assign({}, appConfig, { imports: [HeroDetailComponent, sharedImports], providers: [ provideRouter([{path: 'heroes/:id', component: HeroDetailComponent}]), provideHttpClient(), provideHttpClientTesting(), ], }), ); }); it("should display 1st hero's name", async () => { const expectedHero = firstHero; await createComponent(expectedHero.id).then(() => { expect(page.nameDisplay.textContent).toBe(expectedHero.name); }); });}/////////// Helpers //////** Create the HeroDetailComponent, initialize it, set test variables */async function createComponent(id: number) { harness = await RouterTestingHarness.create(); component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetailComponent); page = new Page(); const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`); const hero = getTestHeroes().find((h) => h.id === Number(id)); request.flush(hero ? [hero] : []); harness.detectChanges();}class Page { // getter properties wait to query the DOM until called. get buttons() { return this.queryAll<HTMLButtonElement>('button'); } get saveBtn() { return this.buttons[0]; } get cancelBtn() { return this.buttons[1]; } get nameDisplay() { return this.query<HTMLElement>('span'); } get nameInput() { return this.query<HTMLInputElement>('input'); } //// query helpers //// private query<T>(selector: string): T { return harness.routeNativeElement!.querySelector(selector)! as T; } private queryAll<T>(selector: string): T[] { return harness.routeNativeElement!.querySelectorAll(selector) as any as T[]; }}
Más overrides
El método TestBed.overrideComponent puede llamarse múltiples veces para los mismos o diferentes componentes.
El TestBed ofrece métodos similares overrideDirective, overrideModule y overridePipe para profundizar y reemplazar partes de estas otras clases.
Explora las opciones y combinaciones por tu cuenta.