Angular-Workshop: Unit-Testing

Preparation

If you have .spec.ts files in your project delete them now. Those test files were generated by the CLI automatically, but we did not update them during this training and therefore they would fail.

Unit-Test for a Component

In this exercise you will create a Unit-Test in the file flight-search.component.spec.ts. This Unit-Test uses special Testing implementations for some Modules in the beforeEach() method inside TestBed. Afterwards it test whether the FlightSearchComponent has no Flights assigned after the instantiation.

Show code

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';

import { FlightSearchComponent } from './flight-search.component';
import { FlightBookingModule } from '../flight-booking.module';

describe('Unit test: flight-search.component', () => {
  let component: FlightSearchComponent;
  let fixture: ComponentFixture<FlightSearchComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
          HttpClientTestingModule,RouterTestingModule,FlightBookingModule,
          SharedModule
      ],
      providers: [
        // Add Providers if you need them for your tests
      ]
    })
    .compileComponents();

    fixture = TestBed.createComponent(FlightSearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should not have any flights loaded initially', () => {
    expect(component.flights.length).toBe(0);
  });
});

Start your test with the command npm test.

Simulate HTTP calls

  1. Import the HttpClientTestingModule instead of the HttpClientModule in the TestBed.

  2. Test whether the search() method loads Flights. Use the HttpTestingController to simulate a HTTP respond:

    Show code

    describe('Unit test: flight-search.component', () => {
        […]
    
        beforeEach(async () => {
            […]
        });
    
        it('should load flights when user entered from and to', () => {
            component.from = 'Graz';
            component.to = 'Hamburg';
            component.search();
    
            const httpTestingController: HttpTestingController
                = TestBed.get(HttpTestingController);
    
            const req = httpTestingController.expectOne(
                'http://www.angular.at/api/flight?from=Graz&to=Hamburg'
            );
            // req.request.method === 'GET'
    
            req.flush([{
                id: 22,
                from: 'Graz',
                to: 'Hamburg',
                date: ''
            }]);
    
            expect(component.flights.length).toBe(1);
        });
    });
    

  3. Start your test with the command npm test.

Bonus: Code Coverage Analysis *

  1. Test your test again with the following parameter to create a Code-Coverage Analysis:

    npm test -- --code-coverage
    

    Note: Using -- is necessary to forward the parameter --code-coverage to the underlying ng test commnand that is assigned to the npm test script.

  2. Wait for the test to be finished. Depending on the CLI Testing configuration you may not see a test result, but nevertheless the generation of the Code-Coverage Analysis works.

  3. Make yourself familiar with the Code-Coverage Analysis which can be found here: coverage/html/index.html

Bonus: Unit-Test with Mocking *

In this exercise you will create a Mock for the FlightService that will be provided for FlightSearchComponent in the TestBed. Afterwards you will implement two test based on this Mock.

  • Test whether the Flights are loaded if from and to are set.

  • Test whether no Flights are loaded if from and to are not set.

Extend the search() method of the FlightSearchComponent so that the test works.

Follow the steps below:

  1. Open the file flight-search.component.ts and extend the search method by a simple validation logic which will be tested in the Unit-Test afterwards:

    search(): void {
        if (!this.from || !this.to) {
            return;
        }
        […]
    }
    
  2. Open the file flight-search.spec.ts and add a Mock-Object for the FlightService as well as for Components, Directives and Pipes used.

    Show code

    describe('Unit test with service mock: flight-search.component', () => {
        let component: FlightSearchComponent;
        let fixture: ComponentFixture<FlightSearchComponent>;
        const result = [
            { id: 17, from: 'Graz', to: 'Hamburg', date: 'now', delayed: true },
            { id: 18, from: 'Vienna', to: 'Barcelona', date: 'now', delayed: true },
            { id: 19, from: 'Frankfurt', to: 'Salzburg', date: 'now', delayed: true },
        ];
    
        const flightServiceMock = {
             find(): Observable<Flight[]> {
                 return of(result);
             },
             // Implement the following members only if this code is used in your Component
             flights: [],
             load(): void {
                 this.find().subscribe(f => { (this.flights as Flight[]) = f; });
             }
         };
    
         // eslint-disable-next-line @angular-eslint/component-selector
         @Component({ selector: 'app-flight-card', template: '' })
         class FlightCardComponent {
             @Input() item: Flight | undefined;
             @Input() selected = false;
             @Output() selectedChange = new EventEmitter<boolean>();
         }
    
         // eslint-disable-next-line @angular-eslint/directive-selector
         @Directive({ selector: 'input[city]' })
         class CityValidatorDirective {
             @Input() city: string[] = [];
             validate = () => null;
         }
    
         @Pipe({ name: 'city' })
         class CityPipe implements PipeTransform {
             transform = (v: unknown) => v;
         }
    
         […]
    });
    

  3. Extend the beforeEach() method of your Test to define that your FlightSearchComponent uses the Mock-Object instead the regularly used FlightServices implementation. Use the overrideComponent() method of the TestBed.

    In case you implemented an AbstractFlightService Token, be aware to use your Mock for this Token.

    Show code

    describe('Unit test with service mock: flight-search.component', () => {
        […]
    
        beforeEach(async () => {
            await TestBed.configureTestingModule({
                imports: [
                    FormsModule
                ],
                declarations: [
                    FlightSearchComponent,
                    FlightCardComponent,
                    CityPipe,
                    CityValidatorDirective
                ]
            })
            .overrideComponent(FlightSearchComponent, {
                set: {
                    providers: [
                        {
                            provide: FlightService,
                            useValue: flightServiceMock
                        }
                    ]
                }
            })
            .compileComponents();
    
            fixture = TestBed.createComponent(FlightSearchComponent);
            component = fixture.componentInstance;
            fixture.detectChanges();
        });
    
        […]
    });
    

    Because flightServiceMock is an object and not a class, you need to use useValue instead of useClass.

  4. Implement a Test should not load flights w/o from and to and another should not load flights w/ from and to. Those Tests shall test the behavior described above.

    Show code

    describe('Unit test with service mock: flight-search.component', () => {
        […]
    
        it('should not load flights w/o from and to', () => {
            component.from = '';
            component.to = '';
            component.search();
    
            expect(component.flights.length).toBe(0);
        });
    
        it('should load flights w/ from and to', () => {
            component.from = 'Hamburg';
            component.to = 'Graz';
            component.search();
    
            expect(component.flights.length).toBeGreaterThan(0);
        });
    });
    

  5. Start your test with the command npm test.

Bonus: Interact with the Template **

Unit-Tests can interact with Template as well. You could for exmaple test whether the Search button is disabled if no search parameter is set. The following code does exactly this:

describe('Unit test: flight-search.component', () => {
  […]

  beforeEach(async () => {
    […]
  });

  […]

  it('should have a disabled search button w/o params', fakeAsync(() => {
    tick();

    // Get input field for from
    const from = fixture
      .debugElement
      .query(By.css('input[name=from]'))
      .nativeElement;

    from.value = '';
    from.dispatchEvent(new Event('input'));

    // Get input field for to
    const to = fixture
      .debugElement
      .query(By.css('input[name=to]'))
      .nativeElement;

    to.value = '';
    to.dispatchEvent(new Event('input'));

    fixture.detectChanges();
    tick();

    // Get disabled
    const disabled = fixture
      .debugElement
      .query(By.css('button'))
      .properties['disabled'];

    expect(disabled).toBeTruthy();
  }));
});

Notice the following aspect in this exercise:

  • Data Binding is asynchronous. Nevertheless Angular can execute asynchronous tasks synchronously. This leads to easier Testing code. To accomplish this the fakeAsync() function is used. To handle queued asynchronous tasks the tick() function will be called in between.

  • This Test uses the nativeElement of the <input>s to set the search filter. This simulates user interaction. Afterwards the input event needs to be dispatched manually. Normally the browser would do this automatically duringt the runtime of the application if the user inputs text.