Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Tech Talks with Santosh: Angular testing

Tech Talks with Santosh: Angular testing

Santosh Yadav hosted an episode of his talk show, Tech Talks with Santosh, where we discussed Angular testing for 2½ hours.

In this presentation, we discuss testing fundamentals, testing frameworks, unit tests, integration tests, end-to-end tests, Angular testing, testing strategies, testing techniques, component testing, presentational component testing, Storybook, visual regression testing, snapshot testing, and how to test an extensible base class.

We walk through real world testing examples and explore Angular's testing APIs, TestBed and RouterTestingModule.

We mention various testing libraries for Angular and present Spectacular, an integration testing library for Angular.

Finally, we discuss Test-Driven Development, adding test coverage to an existing codebase and how we can convince ourselves and stakeholders of the value of unit testing.

Recording:
https://youtu.be/VUIV4u9VSXU

Lars Gyrup Brink Nielsen

February 19, 2021
Tweet

More Decks by Lars Gyrup Brink Nielsen

Other Decks in Programming

Transcript

  1. 💬 Nacho Vázquez “What path would you recommend for a

    beginner making their way into testing?”
  2. Recommended resources for newcomers Free text-based online workshops: • AURENA-Tech/angular-testing-exercises

    by Christian Janker • jevvilla/workshop-angular-testing by Juan Esteban & Juan Herrera • victor-mejia/angular-testing-workshop by Victor Mejia Free online text and video guides: • Angular.io: Developer Guides > Testing by the Angular team • CodeCraft.tv: Angular > Unit Testing by Asim Hussain
  3. Recommended book for Angular testing Testing Angular Applications, Manning Publications

    by: • Jesse Palmer • Corinna Cohn • Michael Giambalvo • Craig Nishina With foreword by: • Brad Green
  4. What you should learn General testing knowledge • Testing frameworks

    • Test suites • Test cases • Test steps • Test hooks • Assertions • Test doubles • Arrange-Act-Assert or Given-When-Then
  5. Unit testing frameworks • AVA • Intern • Jasmine •

    Jest • Karma • Mocha • QUnit • Tape • Test’em
  6. End-to-end testing frameworks • CodeceptJS • Cypress • Intern •

    Nightwatch.js • Playwright • Protractor • Puppeteer • Selenium • TestCafe • WebdriverIO
  7. describe(LumberjackLogDriverLogger.name, () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [LumberjackModule.forRoot(),

    SpyDriverModule.forRoot()], }); [logDriver] = (resolveDependency(lumberjackLogDriverToken) as unknown) as [SpyDriver]; logFactory = resolveDependency(LumberjackLogFactory); logger = resolveDependency(LumberjackLogDriverLogger); }); let logDriver: SpyDriver; let logFactory: LumberjackLogFactory; let logger: LumberjackLogDriverLogger; it('logs a critical log driver log', () => { const logDriverLog: LumberjackLogDriverLog = { formattedLog: LumberjackLevel.Critical, log: logFactory.createCriticalLog(LumberjackLevel.Critical).build(), }; logger.log(logDriver, logDriverLog); expect(logDriver.logCritical).toHaveBeenNthCalledWith(1, logDriverLog); }); });
  8. describe(LumberjackLogDriverLogger.name, () => { // 👈 Test suite beforeEach(() =>

    { // 👈 Test hook TestBed.configureTestingModule({ imports: [LumberjackModule.forRoot(), SpyDriverModule.forRoot()], }); [logDriver] = (resolveDependency(lumberjackLogDriverToken) as unknown) as [SpyDriver]; logFactory = resolveDependency(LumberjackLogFactory); logger = resolveDependency(LumberjackLogDriverLogger); }); let logDriver: SpyDriver; // 👈 Test double let logFactory: LumberjackLogFactory; let logger: LumberjackLogDriverLogger; it('logs a critical log driver log', () => { // 👈 Test case const logDriverLog: LumberjackLogDriverLog = { formattedLog: LumberjackLevel.Critical, log: logFactory.createCriticalLog(LumberjackLevel.Critical).build(), }; logger.log(logDriver, logDriverLog); // 👈 Test step expect(logDriver.logCritical).toHaveBeenNthCalledWith(1, logDriverLog); // 👈 Assertion }); });
  9. describe(LumberjackLogDriverLogger.name, () => { it('logs a critical log driver log',

    () => { // 👇 Arrange const logDriverLog: LumberjackLogDriverLog = { formattedLog: LumberjackLevel.Critical, log: logFactory.createCriticalLog(LumberjackLevel.Critical).build(), }; // 👇 Act logger.log(logDriver, logDriverLog); // 👇 Assert expect(logDriver.logCritical).toHaveBeenNthCalledWith(1, logDriverLog); }); });
  10. describe(LumberjackLogDriverLogger.name, () => { it('logs a critical log driver log',

    () => { // 👇 Given const logDriverLog: LumberjackLogDriverLog = { formattedLog: LumberjackLevel.Critical, log: logFactory.createCriticalLog(LumberjackLevel.Critical).build(), }; // 👇 When logger.log(logDriver, logDriverLog); // 👇 Then expect(logDriver.logCritical).toHaveBeenNthCalledWith(1, logDriverLog); }); });
  11. What you should learn Angular testing knowledge • Types of

    Angular tests • Angular-specific tests • Use the platform: • TestBed • RouterTestingModule
  12. Types of Angular tests • Unit tests • Snapshot tests

    • Component tests • Isolated component tests • Shallow component tests • Integrated component tests • Visual regression tests • Integration tests • End-to-end tests
  13. Types of Angular tests End-to-end test with stubbed backend Backend

    HeroesService HeroesContainer Component Heroes Component HeroesPresenter DOM
  14. Angular-specific tests • Services • Providers • Injection tokens •

    Application hooks • Application initializers • Bootstrap listeners
  15. Angular-specific tests • Angular pipes • Directives • Attribute directives

    • Structural directives • Components • Presentational components • Container components • Mixed components • Routed components • Routing components
  16. Component tests Sources/targets of changes • URL/navigation • The DOM

    • Data binding API (input and output properties) • Services A typical component test’s structure 1. Configure the Angular testing module 2. Create component fixture 3. Set up test doubles and initial state 4. Trigger change from source 5. Assert on changed target
  17. import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By

    } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; describe(DashboardComponent.name, () => { beforeEach(() => { const routerSpy = { navigateByUrl: jest.fn() }; // 👈 [3] Set up test doubles const heroServiceSpy = {getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ // 👈 [1] Configure the Angular testing module declarations: [DashboardComponent, DashboardHeroComponent], providers: [ { provide: HeroService, useValue: heroServiceSpy }, { provide: Router, useValue: routerSpy }, ], }).compileComponents(); fixture = TestBed.createComponent(DashboardComponent); // 👈 [2] Create component fixture fixture.autoDetectChanges(true); // 👈 [4] Trigger change from source }); let fixture: ComponentFixture<DashboardComponent>; it('displays heroes', () => { const heroes = fixture.debugElement.queryAll(By.css('dashboard-hero')); expect(heroes).toHaveLength(4); // 👈 [5] Assert on changed target }); });
  18. Use the platform RouterTestingModule • Feature modules • Routed components

    • Routing components • Route guards • Route resolvers
  19. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; // 👈 import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { const heroServiceSpy = { getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ declarations: [TestAppComponent, TestHeroDetailComponent], imports: [RouterTestingModule.withRoutes([ // 👈 { path: 'heroes/:id', component: TestHeroDetailComponent }, { path: '', loadChildren: () => DashboardModule }, ])], providers: [{ provide: HeroService, useValue: heroServiceSpy }], }).compileComponents(); rootFixture = TestBed.createComponent(TestAppComponent); location = TestBed.inject(Location); rootFixture.ngZone?.run(() => TestBed.inject(Router).navigateByUrl('/dashboard')); rootFixture.autoDetectChanges(true); }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; });
  20. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { const heroServiceSpy = { getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ declarations: [TestAppComponent, TestHeroDetailComponent /* 👈 */], imports: [RouterTestingModule.withRoutes([ { path: 'heroes/:id', component: TestHeroDetailComponent }, // 👈 { path: '', loadChildren: () => DashboardModule }, ])], providers: [{ provide: HeroService, useValue: heroServiceSpy }], }).compileComponents(); rootFixture = TestBed.createComponent(TestAppComponent); location = TestBed.inject(Location); rootFixture.ngZone?.run(() => TestBed.inject(Router).navigateByUrl('/dashboard')); rootFixture.autoDetectChanges(true); }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; });
  21. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} // 👈 @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { const heroServiceSpy = { getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ declarations: [TestAppComponent /* 👈 */, TestHeroDetailComponent], imports: [RouterTestingModule.withRoutes([ { path: 'heroes/:id', component: TestHeroDetailComponent }, { path: '', loadChildren: () => DashboardModule }, ])], providers: [{ provide: HeroService, useValue: heroServiceSpy }], }).compileComponents(); rootFixture = TestBed.createComponent(TestAppComponent); // 👈 location = TestBed.inject(Location); rootFixture.ngZone?.run(() => TestBed.inject(Router).navigateByUrl('/dashboard')); rootFixture.autoDetectChanges(true); }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; });
  22. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { const heroServiceSpy = { getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ declarations: [TestAppComponent, TestHeroDetailComponent], imports: [RouterTestingModule.withRoutes([ { path: 'heroes/:id', component: TestHeroDetailComponent }, { path: '', loadChildren: () => DashboardModule }, ])], providers: [{ provide: HeroService, useValue: heroServiceSpy }], }).compileComponents(); rootFixture = TestBed.createComponent(TestAppComponent); location = TestBed.inject(Location); // 👈 rootFixture.ngZone?.run(() => TestBed.inject(Router).navigateByUrl('/dashboard')); rootFixture.autoDetectChanges(true); }); let location: Location; // 👈 let rootFixture: ComponentFixture<TestAppComponent>; });
  23. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { const heroServiceSpy = { getHeroes: jest.fn().mockReturnValue(asyncData(getTestHeroes())) }; TestBed.configureTestingModule({ declarations: [TestAppComponent, TestHeroDetailComponent], imports: [RouterTestingModule.withRoutes([ { path: 'heroes/:id', component: TestHeroDetailComponent }, { path: '', loadChildren: () => DashboardModule }, ])], providers: [{ provide: HeroService, useValue: heroServiceSpy }], }).compileComponents(); rootFixture = TestBed.createComponent(TestAppComponent); location = TestBed.inject(Location); rootFixture.ngZone?.run(() => TestBed.inject(Router).navigateByUrl('/dashboard')); // 👈 rootFixture.autoDetectChanges(true); // 👈 }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; });
  24. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; // 👈 import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { // (...) }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; // 👈 it('navigates to hero details when a hero is clicked', ) => { const heroDebugger = rootFixture.debugElement.query( By.directive(DashboardHeroComponent)); // 👈 const heroElement: HTMLElement = heroDebugger.query( By.css('.hero')).nativeElement; // 👈 heroElement.click(); const clickedHero = heroDebugger.componentInstance.hero; expect(location.path()).toBe('/heroes/' + clickedHero?.id); }); });
  25. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { // (...) }); let location: Location; let rootFixture: ComponentFixture<TestAppComponent>; it('navigates to hero details when a hero is clicked', ) => { const heroDebugger = rootFixture.debugElement.query( By.directive(DashboardHeroComponent)); const heroElement: HTMLElement = heroDebugger.query( By.css('.hero')).nativeElement; heroElement.click(); // 👈 const clickedHero = heroDebugger.componentInstance.hero; expect(location.path()).toBe('/heroes/' + clickedHero?.id); }); });
  26. import { Location } from '@angular/common'; import { Component }

    from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { DashboardHeroComponent } from './dashboard-hero.component'; import { DashboardComponent } from './dashboard.component'; import { DashboardModule } from './dashboard.module'; import { HeroService } from './hero.service'; import { asyncData, getTestHeroes } from './testing'; @Component({ template: '<router-outlet></router-outlet>' }) class TestAppComponent {} @Component({ template: '' }) class TestHeroDetailComponent {} describe(DashboardComponent.name, () => { beforeEach(() => { // (...) }); let location: Location; // 👈 let rootFixture: ComponentFixture<TestAppComponent>; it('navigates to hero details when a hero is clicked', ) => { const heroDebugger = rootFixture.debugElement.query( By.directive(DashboardHeroComponent)); const heroElement: HTMLElement = heroDebugger.query( By.css('.hero')).nativeElement; heroElement.click(); const clickedHero = heroDebugger.componentInstance.hero; expect(location.path()).toBe('/heroes/' + clickedHero?.id); // 👈 }); });
  27. My RouterTestingModule article series on This is Angular • Testing

    Angular routing components with the RouterTestingModule • Testing routed Angular components with the RouterTestingModule • Testing Angular route guards with the RouterTestingModule
  28. My article series on testing and faking dependencies • Testing

    and faking Angular dependencies • Faking dependencies in Angular applications • Tree-shakable dependencies in Angular projects
  29. 💬 Vimal Patel “Advantages of unit test compared to integration

    test. In other words, how to choose which functionality we should cover in unit test and which one in integration test.”
  30. 💬 Ajit Singh “How to manage tests and some examples

    of how you manage them. Principles you follow while writing tests.”
  31. Unit test vs. integration test The Remove hero control flow

    in Tour of Heroes. How do we test this?
  32. Unit test vs. integration test The Add hero control flow

    in Tour of Heroes. How do we test this?
  33. End-to-end testing frameworks • CodeceptJS • Cypress • Intern •

    Nightwatch.js • Playwright • Protractor • Puppeteer • TestCafe • WebdriverIO
  34. End-to-end testing Angular applications with Cypress Pros: • Big community

    • In active development by a company currently focusing on this product Cons: • Cypress’ assertion style is an acquired taste • No support for Internet Explorer, Microsoft Edge Legacy, or macOS Safari • Not focusing on Angular support: • Various issues related to testing Angular applications • No clean harness environment for Angular CDK Component Test Harnesses • Angular component tests not fully supported
  35. End-to-end testing Angular applications with Protractor Pros: • Owned by

    the Angular team • Heavily used by Google • Supports legacy browsers, that is Internet Explorer and Microsoft Edge Legacy • Supports macOS Safari Cons: • Barely maintained for years • A generations behind on Selenium support • The Angular team is considering other alternatives going forward
  36. 💬 Ajit Singh “How to write tests while keeping method

    extensions in mind. Methods can be extended and their inside workings can be changed. Should we write tests based only on the return statements or should we test the intermediate steps too?”
  37. Testing extensible classes • Only test implementation details in case

    of side effects, that is commands sent to collaborators. • Verify inheritance and polymorphism.
  38. @Injectable() export abstract class LumberjackLogger<TPayload extends LumberjackLogPayload | void =

    void> { constructor( protected lumberjack: LumberjackService<TPayload>, protected time: LumberjackTimeService ) {} /** Create a logger builder for a critical log with the specified message. */ protected createCriticalLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for a debug log with the specified message. */ protected createDebugLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for an error log with the specified message. */ protected createErrorLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for an info log with the specified message. */ protected createInfoLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for a trace log with the specified message. */ protected createTraceLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for a warning log with the specified message. */ protected createWarningLogger(message: string): LumberjackLoggerBuilder<TPayload>; /** Create a logger builder for a log with the specified log level and message. */ protected createLoggerBuilder( level: LumberjackLogLevel, message: string, ): LumberjackLoggerBuilder<TPayload>; }
  39. @Injectable() export abstract class ScopedLumberjackLogger< TPayload extends LumberjackLogPayload | void

    = void > extends LumberjackLogger<TPayload> { abstract readonly scope: string; /** * Create a logger builder for a log with the shared scope as well as the * specified log level and message. */ protected createLoggerBuilder( level: LumberjackLogLevel, message: string, ): LumberjackLoggerBuilder<TPayload>; }
  40. import { Injectable } from '@angular/core'; import { ScopedLumberjackLogger }

    from './scoped-lumberjack-logger.service'; @Injectable({ providedIn: 'root’ }) export class TestLogger extends ScopedLumberjackLogger { readonly scope = 'Test’; critical = (message: string): void => this.createCriticalLogger(message).build().call(this); debug = (message: string): void => this.createDebugLogger(message).build().call(this); error = (message: string): void => this.createErrorLogger(message).build().call(this); info = (message: string): void => this.createInfoLogger(message).build().call(this); trace = (message: string): void => this.createTraceLogger(message).build().call(this); warning = (message: string): void => this.createWarningLogger(message).build().call(this); }
  41. describe(ScopedLumberjackLogger.name, () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [LumberjackModule.forRoot()],

    providers: [{ provide: LumberjackTimeService, useClass: FakeTimeService }], }); logger = TestBed.inject(TestLogger); // 👈 logFactory = TestBed.inject(LumberjackLogFactory); const lumberjack = TestBed.inject(LumberjackService); lumberjackLogSpy = jest.spyOn(lumberjack, 'log').mockImplementation(() => {}); }); let logFactory: LumberjackLogFactory; let logger: TestLogger; // 👈 let lumberjackLogSpy: jest.SpyInstance<void, [LumberjackLog<void | LumberjackLogPayload>]>; });
  42. describe(ScopedLumberjackLogger.name, () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [LumberjackModule.forRoot()],

    providers: [{ provide: LumberjackTimeService, useClass: FakeTimeService }], }); logger = TestBed.inject(TestLogger); logFactory = TestBed.inject(LumberjackLogFactory); const lumberjack = TestBed.inject(LumberjackService); lumberjackLogSpy = jest.spyOn(lumberjack, 'log').mockImplementation(() => {}); // 👈 }); let logFactory: LumberjackLogFactory; let logger: TestLogger; let lumberjackLogSpy: jest.SpyInstance<void, [LumberjackLog<void | LumberjackLogPayload>]>; // 👈 });
  43. describe(ScopedLumberjackLogger.name, () => { let logFactory: LumberjackLogFactory; let logger: TestLogger;

    let lumberjackLogSpy: jest.SpyInstance<void, [LumberjackLog<void | LumberjackLogPayload>]>; it('can create a critical logger', () => { const testMessage = 'Critical message’; logger.critical(testMessage); expect(lumberjackLogSpy).toHaveBeenCalledTimes(1); expect(lumberjackLogSpy).toHaveBeenCalledWith( logFactory.createCriticalLog(testMessage).withScope(logger.scope).build() ); }); it('can create a debug logger', () => { const testMessage = 'Debug message'; logger.debug(testMessage); expect(lumberjackLogSpy).toHaveBeenCalledTimes(1); expect(lumberjackLogSpy).toHaveBeenCalledWith( logFactory.createDebugLog(testMessage).withScope(logger.scope).build() ); }); });
  44. Breaking a test into smaller steps Rule of thumb: •

    One interaction per test case • One assertion per test case • Follow the Arrange-Act-Assert pattern If the test is doing too much, the subject-under-test is probably doing too much or its design can be improved. Make sure that test cases are clear and concise.
  45. 💬 Ajit Singh “Which tests to skip, like I don't

    expect people to write tests for presentational components. What other cases can we skip or should we write tests for all components?”
  46. 💬 Gérôme Grignon “I currently question unit testing on dumb

    components, relying tests to visual testing with Storybook (and snapshots) and e2e testing. Any thoughts about that?”
  47. Presentational components Is it really a presentational component? • Presents

    application state to the user • Changes application state triggered by user interaction • Uses the OnPush change detection strategy • No dependencies except presenters and content/view children • Only receives changes from input properties and the DOM • Minimal logic in its component template • Simple logic in its component model • Complex presentational logic extracted to presenters
  48. Use-case specific presentational components Use case-specific presentational components which have

    a 1-to-1 relationship with a container component should only contain logic that’s barely worth testing. Instead, complex presentational logic is extracted to presenters which are easily tested as they don’t depend on Angular or even a DOM. Additionally, presenters enable presentational logic to be reused or shared.
  49. Testing use-case specific presentational components When presentational components have minimal

    complexity, we can rely on static analysis, integration tests, and end-to-end tests to catch simple mistakes such as typos, type errors, and mapping errors.
  50. Testing use-case specific presentational components We can use fake container

    components to test use case-specific presentational components. They provide realistic, static data to the presentational components.
  51. Dynamic presentational components The data binding API of dynamic presentational

    components are usually not concerned about application state. Their most important traits are content projection or dynamic rendering in the form of: • Component outlets • Template outlets • Angular CDK portals • Simple content projection
  52. Testing dynamic presentational components Tests for dynamic presentational component serve

    as documentation for their data binding API. These tests use a host component to verify the expected behavior.
  53. Reusable presentational components Common examples of resusable presentational components are

    atoms, molecules, and organisms in the Atomic Design methodology. They are often part of a design system or a UI component library.
  54. Testing reusable presentational components with Storybook We can use fake

    container components to test reusable presentational components. This technique can also be used in combination with Storybook or Angular Playground. Storybook can be combined with end-to-end testing. Make sure to watch Nx Tutorial: High Quality React apps with Nx, Storybook & Cypress (timestamp 12:20) by Nrwl.
  55. Testing reusable presentational components with Storybook We can test reusable

    presentational components by using snapshot tests or visual regression tests. Visual regression tests can be combined with Storybook. Storybook is great for manual testing and exploration of reusable presentational components, but do you really want to test every feature and behavior manually for every merge/release?
  56. Testing reusable presentational components with TestBed TestBed is perfectly fine

    for testing reusable presentational components. These tests require no stubbed dependencies. Input properties Either use a host component or set values on input properties imperatively. Output properties Output properties are not convenient to test without a host component as we then need to treat them as observables.
  57. 💬 Ajit Singh “Best utils to use to reduce testbed

    code and framework test boilerplate.”
  58. Reducing framework boilerplate • Angular CDK Component Test Harnesses, by

    the Angular Components team • Angular Testing Library, member of the Testing Library family • ng-mocks • ng-unit • ngx-speculoos by Ninja Squad • Plumbline • shallow-render • Spectacular, upcoming member of the @ngworker family • Spectator, member of the @ngneat family
  59. Introducing Spectacular “Spectacular Angular integration testing.” Spectacular is an integration

    testing library which offers test harnesses to Angular applications and libraries. • Application testing API • Feature testing API • Pipe testing API Logo by Felipe Zambrano
  60. Spectacular Application testing API The application test harness is used

    to test: • Application initializers • Bootstrap listeners • Configuration Angular modules
  61. import { APP_BOOTSTRAP_LISTENER, FactoryProvider, NgModule } from '@angular/core’; import {

    createApplicationHarness } from '@ngworker/spectacular'; // 👈 let bootstrapped = false; const bootstrapListener: FactoryProvider = { multi: true, provide: APP_BOOTSTRAP_LISTENER, useFactory: () => () => { bootstrapped = true; }, }; describe('Bootstrap listeners', () => { beforeEach(() => { bootstrapped = false; }); it('registers and runs the specified bootstrap listener', () => { createApplicationHarness({ // 👈 providers: [bootstrapListener], }); expect(bootstrapped).toBe(true); }); });
  62. import { APP_BOOTSTRAP_LISTENER, FactoryProvider, NgModule } from '@angular/core’; import {

    createApplicationHarness } from '@ngworker/spectacular'; let bootstrapped = false; const bootstrapListener: FactoryProvider = { multi: true, provide: APP_BOOTSTRAP_LISTENER, // 👈 useFactory: () => () => { bootstrapped = true; }, }; describe('Bootstrap listeners', () => { beforeEach(() => { bootstrapped = false; }); it('registers and runs the specified bootstrap listener', () => { createApplicationHarness({ providers: [bootstrapListener], // 👈 }); expect(bootstrapped).toBe(true); }); });
  63. import { APP_BOOTSTRAP_LISTENER, FactoryProvider, NgModule } from '@angular/core’; import {

    createApplicationHarness } from '@ngworker/spectacular'; let bootstrapped = false; // 👈 const bootstrapListener: FactoryProvider = { multi: true, provide: APP_BOOTSTRAP_LISTENER, useFactory: () => () => { bootstrapped = true; // 👈 }, }; describe('Bootstrap listeners', () => { beforeEach(() => { bootstrapped = false; }); it('registers and runs the specified bootstrap listener', () => { createApplicationHarness({ providers: [bootstrapListener], }); expect(bootstrapped).toBe(true); // 👈 }); });
  64. Spectacular Feature testing API The feature testing API is the

    crown jewel of Spectacular. It bridges the gap between component tests and end-to-end tests. It has the speed and utilities of TestBed with the test-as-a-user approach of end-to-end testing frameworks. Spectacular’s feature testing API can be integrated with Angular CDK component harness and Angular Testing Library.
  65. Spectacular Feature testing API The feature test harness is used

    to test: • Routed feature modules • Shell modules Spectacular’s feature test harness configures RouterTestingModule and manages its related test setup.
  66. Spectacular Feature testing API A feature test harness provides SpectacularFeatureRouter

    and SpectacularFeatureLocation which are both subsets of Angular services adjusted to the feature module under test to get feature-relative navigation and URL path evaluation. Example usage const harness = createFeatureHarness({ // 👈 featureModule: CrisisCenterModule, featurePath: 'crisis-center', }); harness.router.navigateByUrl('~/details'); // 👈 expect(harness.location.path()).toBe('~/details'); // 👈
  67. Spectacular Feature testing API A feature test harness provides SpectacularFeatureRouter

    and SpectacularFeatureLocation which are both subsets of Angular services adjusted to the feature module under test to get feature-relative navigation and URL path evaluation. Example usage const harness = createFeatureHarness({ featureModule: CrisisCenterModule, featurePath: 'crisis-center', }); const angularRouter = harness.inject(Router); // 👈 angularRouter.navigateByUrl('/crisis-center/details'); // 👈 expect(harness.location.path()).toBe('~/details');
  68. Spectacular Feature testing API A feature test harness provides SpectacularFeatureRouter

    and SpectacularFeatureLocation which are both subsets of Angular services adjusted to the feature module under test to get feature-relative navigation and URL path evaluation. Example usage const harness = createFeatureHarness({ featureModule: CrisisCenterModule, featurePath: 'crisis-center', }); const angularLocation = harness.inject(Location); // 👈 harness.router.navigateByUrl('~/details'); expect(angularLocation.path()).toBe('/crisis-center/details'); // 👈
  69. import { createUserInteractions, FakeDialogService, UserInteractions } from '@internal/test-util'; import {

    createFeatureHarness, SpectacularFeatureHarness } from '@ngworker/spectacular'; // 👈 import { Crisis, CrisisCenterModule, crisisCenterPath, CrisisService, DialogService, } from '@tour-of-heroes/crisis-center'; describe('Tour of Heroes: Crisis center integration tests', () => { beforeEach(() => { harness = createFeatureHarness({ // 👈 featureModule: CrisisCenterModule, featurePath: crisisCenterPath, providers: [{ provide: DialogService, useClass: FakeDialogService }], }); fakeDialog = harness.inject(DialogService) as FakeDialogService; ui = createUserInteractions(harness.rootFixture); [aCrisis] = harness.inject(CrisisService).getCrises().value; }); let aCrisis: Crisis; let fakeDialog: FakeDialogService; let harness: SpectacularFeatureHarness; // 👈 const newCrisisName = 'Coral reefs are dying'; let ui: UserInteractions; });
  70. import { createUserInteractions, FakeDialogService, UserInteractions } from '@internal/test-util'; import {

    createFeatureHarness, SpectacularFeatureHarness } from '@ngworker/spectacular'; // 👈 import { Crisis, CrisisCenterModule, crisisCenterPath, CrisisService, DialogService, } from '@tour-of-heroes/crisis-center'; describe('Tour of Heroes: Crisis center integration tests', () => { beforeEach(() => { harness = createFeatureHarness({ featureModule: CrisisCenterModule, featurePath: crisisCenterPath, providers: [{ provide: DialogService, useClass: FakeDialogService }], }); fakeDialog = harness.inject(DialogService) as FakeDialogService; ui = createUserInteractions(harness.rootFixture); // 👈 [aCrisis] = harness.inject(CrisisService).getCrises().value; }); let aCrisis: Crisis; let fakeDialog: FakeDialogService; let harness: SpectacularFeatureHarness; const newCrisisName = 'Coral reefs are dying'; let ui: UserInteractions; // 👈 Demo testing utilities, not included in Spectacular });
  71. describe('Tour of Heroes: Crisis center integration tests', () => {

    function expectCrisisToBeSelected(crisis: Crisis) { expect(ui.getText('.selected')).toBe(crisis.id + crisis.name); } function expectToBeAtTheCrisisCenterHome() { expect(ui.getText('p')).toBe('Welcome to the Crisis Center'); } // (...) let ui: UserInteractions; });
  72. describe('Tour of Heroes: Crisis center integration tests', () => {

    describe('Crisis detail', () => { describe('Editing crisis name', () => { beforeEach(async () => { await harness.router.navigateByUrl('~/' + aCrisis.id); // 👈 ui.enterText(newCrisisName, 'input’); }); describe('Canceling change', () => { describe('When discarding unsaved changes is confirmed', () => { beforeEach(() => { ui.clickButton('Cancel'); fakeDialog.clickOk(); // 👈 }); it('navigates to the crisis center home with the crisis selected', () => { expectToBeAtTheCrisisCenterHome(); // 👈 expectCrisisToBeSelected(aCrisis); }); }); }); }); }); });
  73. describe('Tour of Heroes: Crisis center integration tests', () => {

    describe('Crisis detail', () => { describe('Editing crisis name', () => { beforeEach(async () => { await harness.router.navigateByUrl('~/' + aCrisis.id); ui.enterText(newCrisisName, 'input’); }); describe('Canceling change', () => { describe('When discarding unsaved changes is confirmed', () => { beforeEach(() => { ui.clickButton('Cancel'); fakeDialog.clickOk(); // 👈 }); it('navigates to the crisis center home with the crisis selected', () => { expectToBeAtTheCrisisCenterHome(); expectCrisisToBeSelected(aCrisis); }); }); }); }); }); });
  74. describe('Tour of Heroes: Crisis center integration tests', () => {

    describe('Crisis detail', () => { describe('Editing crisis name', () => { beforeEach(async () => { await harness.router.navigateByUrl('~/' + aCrisis.id); ui.enterText(newCrisisName, 'input’); }); describe('Canceling change', () => { describe('When discarding unsaved changes is confirmed', () => { beforeEach(() => { ui.clickButton('Cancel'); fakeDialog.clickOk(); }); it('navigates to the crisis center home with the crisis selected', () => { expectToBeAtTheCrisisCenterHome(); // 👈 expectCrisisToBeSelected(aCrisis); // 👈 }); }); }); }); }); });
  75. Spectacular Pipe testing API The pipe test harness is used

    to test Angular pipes. Spectacular manages a host component and change detection for the pipe under test: • The rendered text is readable • The value can be changed • The template can be replaced
  76. import { Pipe, PipeTransform } from '@angular/core'; import { createPipeHarness,

    SpectacularPipeHarness } from '@ngworker/spectacular'; // 👈 @Pipe({ name: 'capitalize' }) export class CapitalizePipe implements PipeTransform { transform(value: string) { return value.split(/\s+/g).map(word => word[0].toUpperCase() + word.substring(1)).join(' '); } } describe(CapitalizePipe.name, () => { beforeEach(() => { harness = createPipeHarness({// 👈 pipe: CapitalizePipe, pipeName: 'capitalize', value: 'mr. potato head’, }); }); let harness: SpectacularPipeHarness<string>; // 👈 it('capitalizes every word of the text', () => { expect(harness.text).toBe('Mr. Potato Head'); // 👈 harness.value = 'ms. potato head' // 👈 expect(harness.text).toBe('Ms. Potato Head'); // 👈 }); });
  77. Stay tuned for Specatular… Spectacular will be published as @ngworker/spectacular.

    (Until then, feel free to try it out from the build artifacts of our GitHub Actions workflow) Roadmap • Verified cross-version compatibility with Angular • Examples of integrating Spectacular’s feature testing API with Angular CDK component harnesses • Examples of integrating Spectacular’s feature testing API with Angular Testing Library • Container component test harness
  78. 💬 Armen Vardanyan “Is writing unit tests really increasing the

    time spent on development significantly, or does the early planning and relative bug safety compensate for the time we spend on writing unit tests in Angular? Lots of client companies don’t want developers writing unit tests because they think it will take too much time and they don't want to overpay.”
  79. 💬 Armen Vardanyan “TDD is nice and shiny. Lots of

    huge codebases out there don’t have tests already but would benefit from adding them. Is it worth trying to add unit tests to a large existing project? What is the best sequence of steps we can take to make it less painful?”