Angular Component Harnesses FTW!

A familiar tale

A familiar tale

Alisa Duncan

  • Senior Developer Advocate Okta
  • Angular GDE
  • Fan of K-Dramas

What I'll cover in this talk

  • How to approach writing automated UI tests
  • What are component test harnesses
  • How test harnesses work behind the scenes
  • How to use component test harnesses for maximum win

What is a "component" test?

🤔

Tests UI interactivity within an Angular application using an unit testing library

A test harness encapsulates implementation details of a component into an API for the purposes of testing

How test harnesses help you

How test harnesses help you

  • Ignore inner workings of a dependent component
  • Easier to read and write tests
  • Less test maintenance

Consumers of Angular Material components have a head start!

Consumers of Angular Material components have a head start!

All Angular Material components have test harnesses starting in v12

Let's write a test

The component template we'll test

Example test without harnesses


            it('should disable subscribe button when email is empty', async () => {
              const btn = fixture.debugElement.query(By.css('button'));
              expect(btn).not.toBeNull();
              expect(btn.nativeElement.disabled).toBeTrue();

              const input = fixture.debugElement.query(By.css('input'));
              expect(input).not.toBeNull();
              input.nativeElement.value = 'email@email.email';
              input.nativeElement.dispatchEvent(new Event('input'));
              fixture.detectChanges();
              await fixture.whenStable();

              expect(btn.nativeElement.disabled).toBeFalse();
            });
          

Example test with harnesses


            it('should disable subscribe button when email is empty', async () => {
              const buttonEl = await loader.getHarness(MatButtonHarness);
              expect(await buttonEl.isDisabled()).toBeTrue();

              const emailInputEl = await loader.getHarness(MatInputHarness);
              await emailInputEl.setValue('email@email.email');

              expect(await buttonEl.isDisabled()).toBeFalse();
            });
        

Identifying selectors can be painful

Identifying selectors can be painful


          it('should show a disabled slide toggle', () => {
            const slideToggleEl
              = fixture.debugElement.query(By.css('mat-slide-toggle'));
            expect(slideToggleEl).not.toBeNull();

            const slideToggleCheck
              = slideToggleEl.nativeElement.querySelector('input[type="checkbox"]');
            expect(slideToggleCheck).not.toBeNull();

            expect(slideToggleCheck.disabled).toBeTrue();
          });
        

Identifying selectors is a piece of 🍰


          it('should show a disabled slide toggle', async () => {
            const slideToggleHarness
              = await loader.getHarness(MatSlideToggleHarness);
            expect(await slideToggleHarness.isDisabled()).toBeTrue();
          });
        

Testing using component harnesses

  • Tests that are easier to read and write
  • More stable and resilient tests
  • Focus on testing the behaviors that matter

The CDK testing API

The CDK testing API

The Angular CDK testing library supports testing interactions with components

Setting up the TestBed


          import { HarnessLoader } from '@angular/cdk/testing';
          import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';

          describe('KDrama Component Test Harness', () => {
            let loader: HarnessLoader;

            beforeEach(() => {
              TestBed.configureTestingModule({...});
              fixture = TestBed.createComponent(MyKDramaComponent);
              loader = TestbedHarnessEnvironment.loader(fixture);
            });
          });
        

The TestbedHarnessEnvironment

Supports unit testing using Karma

The HarnessEnvironment is unique for each testing environment

The HarnessLoader


        

Source from cdk/testing/component-harness.ts

Getting component harnesses in practice


            const input: MatInputHarness
              = await loader.getHarness(MatInputHarness);

            const input: MatInputHarness|null
              = await loader.getHarnessOrNull(MatInputHarness);

            const inputs: MatInputHarness[]
              = await loader.getAllHarnesses(MatInputHarness);

            const childLoader: HarnessLoader
              = await loader.getChildLoader('.my-selector');
            const childInput: MatInputHarness
              = await childLoader.getHarness(MatInputHarness);
          

Filtering for a harness


          loader.getHarness(MatInputHarness.with({options}));
        

Your options depend on the harness you are using

The InputHarnessFilters interface

Source from material/input/testing/input-harness-filters.ts

Filter MatInputHarness by value


          const options = {
            value: 'only cool comments, please!'
          };

          const inputs: MatInputHarness[]
            = await loader.getAllHarnesses(MatInputHarness.with(options));
        

Once you have a harness, it is up to the API of that harness on interactions you can take

An example component harness

Not quite accurate since MatInput extends from MatFormFieldControlHarness, but simplifying for this presentation

The ComponentHarness base class

Source from cdk/testing/component-harness.ts

The TestElement

Source from cdk/testing/test-element.ts

Notice everything is async

Why async?

  • You always have the latest state on the control
  • It handles change detection for us
  • To help, the CDK library has a handy helper method parallel that we can use
    
                const input = await loader.getHarness(MatInputHarness);
                const [name, value] = await parallel(() => [
                  input.getName(),
                  input.getValue()
                ]);
              

You're now winning at writing tests with Material component test harnesses! Let's win some more!

We can write our own component test harnesses

Maximum winning!

When to write a component test harness

  • If you have shared components
  • If you create custom UI components

Custom component harness example

Custom component harness example

Shared component code

Define the filters interface to support querying


            import { BaseHarnessFilters } from '@angular/cdk/testing';

            export interface AddCommentHarnessFilters extends BaseHarnessFilters {
            }
          

Define the filters interface to support querying


            import { BaseHarnessFilters } from '@angular/cdk/testing';

            export interface AddCommentHarnessFilters extends BaseHarnessFilters {
              comment?: string;
            }
          

Implement your component test harness


            import { ComponentHarness } from '@angular/cdk/testing';

            export class AddCommentHarness extends ComponentHarness {

            }
          

Implement your component test harness


            import { ComponentHarness } from '@angular/cdk/testing';

            export class AddCommentHarness extends ComponentHarness {
              static hostSelector = 'app-add-comment';
            }
          

Implement your component test harness

Implement your component test harness

Implement your component test harness

Implement your component test harness


          

Test your component test harness

  • Don't forget to test your harness - treat it like publishing an API
  • Test your harness using a test host

ComponentHarness locator methods scale as needed with your custom component

When writing components, small is winning

Recipe for winning

  • Test harnesses keep UI testing cleaner and easier to maintain
  • Use harnesses when testing with Material components
  • Write a test harness for your shared components
  • Test your test harness - treat it like an API!

Tests to win

Hey there, awesome! You're such a winner at writing tests! 🤩

Questions?

@AlisaDuncan

@AlisaDuncan #testHarnessesFTW