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. Angular testing
    Tech talks with Santosh

    View Slide

  2. Angular testing fundamentals

    View Slide

  3. 💬 Nacho Vázquez
    “What path would you recommend for a
    beginner making their way into testing?”

    View Slide

  4. 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

    View Slide

  5. Recommended book for
    Angular testing
    Testing Angular Applications, Manning Publications by:
    • Jesse Palmer
    • Corinna Cohn
    • Michael Giambalvo
    • Craig Nishina
    With foreword by:
    • Brad Green

    View Slide

  6. 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

    View Slide

  7. Unit testing frameworks
    • AVA
    • Intern
    • Jasmine
    • Jest
    • Karma
    • Mocha
    • QUnit
    • Tape
    • Test’em

    View Slide

  8. End-to-end testing frameworks
    • CodeceptJS
    • Cypress
    • Intern
    • Nightwatch.js
    • Playwright
    • Protractor
    • Puppeteer
    • Selenium
    • TestCafe
    • WebdriverIO

    View Slide

  9. 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);
    });
    });

    View Slide

  10. 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
    });
    });

    View Slide

  11. 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);
    });
    });

    View Slide

  12. 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);
    });
    });

    View Slide

  13. What you should learn
    Angular testing knowledge
    • Types of Angular tests
    • Angular-specific tests
    • Use the platform:
    • TestBed
    • RouterTestingModule

    View Slide

  14. 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

    View Slide

  15. Types of Angular tests
    End-to-end test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  16. Types of Angular tests
    End-to-end test with stubbed backend
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  17. Types of Angular tests
    Isolated component test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  18. Types of Angular tests
    Shallow component test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  19. Types of Angular tests
    Integrated component test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  20. Types of Angular tests
    Integration test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  21. Types of Angular tests
    Unit test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  22. Types of Angular tests
    Unit test
    Backend
    HeroesService
    HeroesContainer
    Component
    Heroes
    Component
    HeroesPresenter
    DOM

    View Slide

  23. Angular-specific tests
    • Services
    • Providers
    • Injection tokens
    • Application hooks
    • Application initializers
    • Bootstrap listeners

    View Slide

  24. Angular-specific tests
    • Angular modules
    • Feature modules
    • Service modules
    • UI modules
    • Shell modules

    View Slide

  25. Angular-specific tests
    • Angular pipes
    • Directives
    • Attribute directives
    • Structural directives
    • Components
    • Presentational components
    • Container components
    • Mixed components
    • Routed components
    • Routing components

    View Slide

  26. Angular-specific tests
    • Route guards
    • CanActivate
    • CanActivateChild
    • CanDeactivate
    • CanLoad
    • Route resolvers

    View Slide

  27. Use the platform
    TestBed
    • Dependencies
    • Components
    • Directives
    • Angular pipes
    • Angular modules

    View Slide

  28. 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

    View Slide

  29. 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;
    it('displays heroes', () => {
    const heroes = fixture.debugElement.queryAll(By.css('dashboard-hero'));
    expect(heroes).toHaveLength(4); // 👈 [5] Assert on changed target
    });
    });

    View Slide

  30. Use the platform
    RouterTestingModule
    • Feature modules
    • Routed components
    • Routing components
    • Route guards
    • Route resolvers

    View Slide

  31. 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: '' }) 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;
    });

    View Slide

  32. 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: '' }) 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;
    });

    View Slide

  33. 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: '' }) 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;
    });

    View Slide

  34. 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: '' }) 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;
    });

    View Slide

  35. 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: '' }) 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;
    });

    View Slide

  36. 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: '' }) class TestAppComponent {}
    @Component({ template: '' }) class TestHeroDetailComponent {}
    describe(DashboardComponent.name, () => {
    beforeEach(() => {
    // (...)
    });
    let location: Location;
    let rootFixture: ComponentFixture; // 👈
    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);
    });
    });

    View Slide

  37. 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: '' }) class TestAppComponent {}
    @Component({ template: '' }) class TestHeroDetailComponent {}
    describe(DashboardComponent.name, () => {
    beforeEach(() => {
    // (...)
    });
    let location: Location;
    let rootFixture: ComponentFixture;
    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);
    });
    });

    View Slide

  38. 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: '' }) class TestAppComponent {}
    @Component({ template: '' }) class TestHeroDetailComponent {}
    describe(DashboardComponent.name, () => {
    beforeEach(() => {
    // (...)
    });
    let location: Location; // 👈
    let rootFixture: ComponentFixture;
    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); // 👈
    });
    });

    View Slide

  39. 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

    View Slide

  40. My article series on testing and
    faking dependencies
    • Testing and faking Angular dependencies
    • Faking dependencies in Angular applications
    • Tree-shakable dependencies in Angular projects

    View Slide

  41. Angular testing strategy

    View Slide

  42. 💬 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.”

    View Slide

  43. 💬 Ajit Singh
    “How to manage tests and some examples
    of how you manage them. Principles you
    follow while writing tests.”

    View Slide

  44. Testing strategy
    ”What we test and how we test it.”

    View Slide

  45. Unit test vs. integration test
    Event flow up the component tree. How do we test this flow?

    View Slide

  46. Unit test vs. integration test
    Data flows down the component tree. How do we test this flow?

    View Slide

  47. Unit test vs. integration test
    A complex UI feature. How do we test this dataflow?

    View Slide

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

    View Slide

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

    View Slide

  50. 💬 C. Kettenbach
    “To Protractor or to Cypress. That is the
    question.”

    View Slide

  51. 💬 Vimal Patel
    “Which one to choose: Cypress or
    Protractor?”

    View Slide

  52. End-to-end testing frameworks
    • CodeceptJS
    • Cypress
    • Intern
    • Nightwatch.js
    • Playwright
    • Protractor
    • Puppeteer
    • TestCafe
    • WebdriverIO

    View Slide

  53. 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

    View Slide

  54. 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

    View Slide

  55. Testing techniques

    View Slide

  56. 💬 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?”

    View Slide

  57. Testing extensible classes
    • Only test implementation details in case of side effects, that is commands sent to
    collaborators.
    • Verify inheritance and polymorphism.

    View Slide

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

    View Slide

  59. @Injectable()
    export abstract class ScopedLumberjackLogger<
    TPayload extends LumberjackLogPayload | void = void
    > extends LumberjackLogger {
    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;
    }

    View Slide

  60. 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);
    }

    View Slide

  61. 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]>;
    });

    View Slide

  62. 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]>; // 👈
    });

    View Slide

  63. describe(ScopedLumberjackLogger.name, () => {
    let logFactory: LumberjackLogFactory;
    let logger: TestLogger;
    let lumberjackLogSpy: jest.SpyInstance]>;
    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()
    );
    });
    });

    View Slide

  64. 💬 Ajit Singh
    “When to break down a test into smaller
    tests.”

    View Slide

  65. 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.

    View Slide

  66. 💬 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?”

    View Slide

  67. 💬 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?”

    View Slide

  68. 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

    View Slide

  69. 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.

    View Slide

  70. 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.

    View Slide

  71. 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.

    View Slide

  72. 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

    View Slide

  73. 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.

    View Slide

  74. 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.

    View Slide

  75. 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.

    View Slide

  76. 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?

    View Slide

  77. 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.

    View Slide

  78. Angular testing libraries

    View Slide

  79. 💬 Ajit Singh
    “Best utils to use to reduce testbed code
    and framework test boilerplate.”

    View Slide

  80. 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

    View Slide

  81. 💬 Gérôme Grignon
    “Do you plan to talk about Spectacular?”

    View Slide

  82. 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

    View Slide

  83. Spectacular
    Application testing API
    The application test harness is used to test:
    • Application initializers
    • Bootstrap listeners
    • Configuration Angular modules

    View Slide

  84. 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);
    });
    });

    View Slide

  85. 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);
    });
    });

    View Slide

  86. 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); // 👈
    });
    });

    View Slide

  87. 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.

    View Slide

  88. 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.

    View Slide

  89. 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'); // 👈

    View Slide

  90. 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');

    View Slide

  91. 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'); // 👈

    View Slide

  92. 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;
    });

    View Slide

  93. 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
    });

    View Slide

  94. 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;
    });

    View Slide

  95. 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);
    });
    });
    });
    });
    });
    });

    View Slide

  96. 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);
    });
    });
    });
    });
    });
    });

    View Slide

  97. 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); // 👈
    });
    });
    });
    });
    });
    });

    View Slide

  98. 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

    View Slide

  99. 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; // 👈
    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'); // 👈
    });
    });

    View Slide

  100. 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

    View Slide

  101. Automated testing and legacy
    systems

    View Slide

  102. 💬 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.”

    View Slide

  103. 💬 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?”

    View Slide

  104. Thank you! 👋
    🐦@LayZeeDK

    View Slide

  105. View Slide