Table des matières

Unit testing in Angular

Testing Overview

End to End

Unit testing

Integration and functional testing

Mocking

Permet d'isoler le code que l'on veut tester.

Types de mocks:

Unit Tests in Angular

Types de tests unitaires dans Angular

Tools of Unit Testing with Angular

Autres

Installing and running

Écrire le premier test unitaire

Exemple de test de base:

describe('my first test', () => {
  let sut; // system under test

  beforeEach(() => {
    sut = {};
  
  });

  it('should be true if true', () => {
    // arrange
    sut.a = false;
    
    // act
    sut.a = true;
    
    //assert
    expect(true).toBe(true);
  });
});

Exécuter les tests unitaires

Par défaut, avec un projet Angular:

$ npm test

Écrire de bons tests unitaires

Structure:

DAMP vs DRY:

Tell the story:

Isolated Unit Tests

Tester un Pipe

Exemple de pipe:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'strength'
})
export class StrengthPipe implements PipeTransform {
  transform(value: number): string {
    if(value < 10) {
      return value + " (weak)";
    } else if(value >= 10 && value < 20) {
      return value + " (strong)";
    } else {
      return value + " (unbelievable)";
    }
  }
}

Le test:

import { StrengthPipe } from "./strength.pipe";

describe('StrengthPipe', () => {
  it('should display weak if strength is 5', () => {
    let pipe = new StrengthPipe();

    expect(pipe.transform(5)).toEqual('5 (weak)');
  })

  it('should display strong if strength is 10', () => {
    let pipe = new StrengthPipe();

    expect(pipe.transform(10)).toEqual('10 (strong)');
  })
})

Tester un service

import { Injectable } from '@angular/core';

@Injectable()
export class MessageService {
  messages: string[] = [];

  add(message: string) {
    this.messages.push(message);
  }

  clear() {
    this.messages = [];
  }
}
import { MessageService } from "./message.service";
 
describe('MessageService', () => {
  let service: MessageService;
 
  beforeEach(() => {
 
  });
 
  it('should have no messages to start', () => {
    service = new MessageService();
 
    expect(service.messages.length).toBe(0);
  });
 
  it('should add a message when add is called', () => {
    service = new MessageService();
 
    service.add('message1');
 
    expect(service.messages.length).toBe(1);
  });
 
  it('should remove all messages when clear is called', () => {
    service = new MessageService();
    service.add('message1');
 
    service.clear();
 
    expect(service.messages.length).toBe(0);
  });
 
});

Tester un Component

import { Component, OnInit } from '@angular/core';
 
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
 
@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
  heroes: Hero[];
 
  constructor(private heroService: HeroService) { }
 
  ngOnInit() {
    this.getHeroes();
  }
 
  getHeroes(): void {
    this.heroService.getHeroes()
    .subscribe(heroes => this.heroes = heroes);
  }
 
  add(name: string): void {
    name = name.trim();
    var strength = 11
    if (!name) { return; }
    this.heroService.addHero({ name, strength } as Hero)
      .subscribe(hero => {
        this.heroes.push(hero);
      });
  }
 
  delete(hero: Hero): void {
    this.heroes = this.heroes.filter(h => h !== hero);
    this.heroService.deleteHero(hero).subscribe();
  }
 
}
import { HeroesComponent } from "./heroes.component";
import { of } from "rxjs";
 
describe('HeroesComponent', () => {
  let component: HeroesComponent;
  let HEROES;
  let mockHeroService;
 
  beforeEach(() => {
    HEROES = [
      {id:1, name: 'SpiderDude', strength: 8},
      {id:2, name: 'Wonderful Woman', strength: 24},
      {id:3, name: 'SuperDude', strength: 55}
    ]
 
    mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero'])
 
    component = new HeroesComponent(mockHeroService);
  })
 
  describe('delete', () => {
 
    it('should remove the indicated hero from the heroes list', () => {
      mockHeroService.deleteHero.and.returnValue(of(true))
      component.heroes = HEROES;
 
      component.delete(HEROES[2]);
 
      expect(component.heroes.length).toBe(2);
    })
 
    it('should call deleteHero', () => {
      mockHeroService.deleteHero.and.returnValue(of(true))
      component.heroes = HEROES;
 
      component.delete(HEROES[2]);
 
      expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]);
    })
  })
})

Interaction test

Dans le component, si on a une fonction tel que:

delete(hero: Hero): void {
  this.heroes = this.heroes.filter(h => h !== hero);
  this.heroService.deleteHero(hero).subscribe();
}

La troisième ligne, avec le subscribe(), n'est pas nécessairement testée si on appelle delete et qu'on valide que this.heroes a changé. C'est dû au fait que le subscribe() ne change pas l'état du component.

Donc on peut faire un test d'intégration avec une vérification d'appel:

it('should call deleteHero', () => {
  mockHeroService.deleteHero.and.returnValue(of(true))
  component.heroes = HEROES;

  component.delete(HEROES[2]);

  expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]);
});

Shallow Integration Tests

Using NO_ERRORS_SCHEMA

La mention NO_ERRORS_SCHEMA permet d'isoler le composant sous test en faisant en sorte que les sous-composants n'ont pas besoin d'être connus.

import {NO_ERRORS_SCHEMA} from '@angular/core';
TestBed.configureTestingModule({
  declarations: [WriteUsDialogComponent],
  imports: [],
  providers: [],
  schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

L'effet pervers de ne pas avoir d'erreurs quand un sous-composant est _inconnu_, c'est que si on écrit <buttons></buttons>, il n'y aura pas de problème de relevé alors qu'il y en a véritablement un.

Testing Rendered HTML

component.client = {firstName: 'John', lastName: 'Doe'};

fixture.detectChanges();

expect(fixture.nativeElement.querySelector('a').textContent).toContain('John');

NativeElement vs DebugElement

DebugElement est un wrapper sur NativeElement qui a plusieurs fonctionnalités semblable au NativeElement.

expect(fixture.debugElement.query(By.css('a')).nativeElement.textContent).toContain('John');
const de = fixture.debugElement.query(By.css('a')); // de = debugElement
expect(de.nativeElement.textContent).toContain('John');

DebugElement permet d'avoir accès aux directives, par exemple routerLink.

More complex Shallow Tests

Mocking an injected Service

Pour les tests de component qui ont besoin de services, on peut utiliser le TestBed.

describe('HeroComponent (shallow tests)', () => {
  let fixture: ComponentFixture<HeroComponent>;  // le fixture est un wrapper sur le component à tester
 
  let mockHeroService;
 
  beforeEach(() => {
 
    HEROES = [
      {id:1, name: 'SpiderDude', strength: 8},
      {id:2, name: 'Wonderful Woman', strength: 24},
      {id:3, name: 'SuperDude', strength: 55}
    ];
 
    mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
 
    TestBed.configureTestingModule({
      declarations: [HeroComponent],
      providers: [{provide: HeroService, useValue: mockHeroService}],
      schemas: [NO_ERRORS_SCHEMA],
    });
 
    fixture = TestBed.createComponent(HeroComponent);
  });
 
  it('should have the correct hero', () => {
    fixture.componentInstance.hero = {id: 1, name: 'SuperDude', strenght: 3};
 
    expect(fixture,componentInstance.
  });
 
  it('should set heroes correctly from the service', () => {
    mockHeroService.getHeroes.and.returnValue(of(HEROES))
    fixture.detectChanges();
 
    expect(fixture.componentInstance.heroes.length).toBe(3);
  });
});

Pour info, getHeroes() appelle getHeroes() du service qui est un observable:

getHeroes(): void {
  this.heroService.getHeroes()
  .subscribe(heroes => this.heroes = heroes);
}

Mocking Child Components

Créer un mock de component:

@Component({
  selector: 'app-hero',
  template: '<div></div>',
})
class FakeHeroComponent {
  @Input() hero: Hero; // Pas obligé de typer ici vu que c'est un mock
}  

Ajouter FakeHeroComponent dans declarations et enlever le NO_ERRORS_SCHEMA du TestBed.

TestBed.configureTestingModule({
  declarations: [
    HeroesComponent,
    FakeHeroComponent
  ],
  providers: [
    { provide: HeroService, useValue: mockHeroService }
  ],
});

Dealing with Lists of Elements

it('should create one li element for each hero', () => {
  mockHeroService.getHeroes.and.returnValue(of(HEROES))
  fixture.detectChanges();
 
  expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(3);
});

Deep Integration Tests

Creating a Deep Integration Test

Dans ces tests, nous voulons évaluer l'intégration entre deux components parent-enfant.

Le test de base, ressemble à ceci:

describe('HeroesComponent (deep tests)', () => {
  let fixture: ComponentFixture<HeroesComponent>;
  let mockHeroService;
  let HEROES;
 
  beforeEach(() => {
    mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']);
 
    TestBed.configureTestingModule({
      declarations: [
        HeroesComponent,
        HeroComponent
      ],
      providers: [
        { provide: HeroService, useValue: mockHeroService }
      ],
      schemas: [NO_ERRORS_SCHEMA]
    })
 
    fixture = TestBed.createComponent(HeroesComponent);    
  });
 
  it('should be true', () => {
    expect(true).toBe(true);
  });
});

On inclus HeroesComponent et HeroComponent.

Finding Elements by Directive

Le component HeroComponent doit se répéter pour chaque héro trouvé.

Remarquez qu'on utilise By.directive().

it('should render each hero as a HeroComponent', () => {
  mockHeroService.getHeroes.and.returnValue(of(HEROES));
 
  // run ngOnInit
  fixture.detectChanges();
 
  const heroComponentDEs = fixture.debugElement.queryAll(
    By.directive(HeroComponent)
  );
  expect(heroComponentDEs.length).toEqual(3);
  for (let i = 0; i < heroComponentDEs.length; i++) {
    expect(heroComponentDEs[i].componentInstance.hero).toEqual(HEROES[i]);
  }
});

Integration Testing of Services

On veut tester l'intégration du service HeroService avec le HttpClient.

import { TestBed } from "@angular/core/testing";
import { HeroService } from "./hero.service";
import { MessageService } from "./message.service";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
 
describe('HeroService', () => {
  let mockMessageService;
  let httpTestingController: HttpTestingController;
  let service: HeroService;
 
  beforeEach(() => {
    mockMessageService = jasmine.createSpyObj(['add']);
 
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ],
      providers: [
        HeroService,
        {provide: MessageService, useValue: mockMessageService}
      ]
    });
 
    httpTestingController = TestBed.get(HttpTestingController);
    service = TestBed.get(HeroService);
  });
});

Implementing a Test with Mocked HTTP

Ce qu'on veut tester, c'est la méthode suivante dans le service:

getHero(id: number): Observable<Hero> {
  const url = `${this.heroesUrl}/${id}`;
 
  return this.http.get<Hero>(url).pipe(
    tap(_ => this.log(`fetched hero id=${id}`)),
    catchError(this.handleError<Hero>(`getHero id=${id}`))
  );
}

Alors, un exemple de test serait :

it('should call get with the correct URL', () => { 
  service.getHero(4).subscribe();
 
  const req = httpTestingController.expectOne('api/heroes/4');
  req.flush({id: 4, name: 'SuperDude', strength: 100});
  httpTestingController.verify();
});

Obtenir l'instance de service

Pour obtenir une instance de service, on peut faire:

import { TestBed, inject } from '@angular/core/testing';
 
// ...
 
it('should call get with the correct URL', inject([HeroService], (service: HeroService) => {
  // ...
}));

Testing DOM Interaction and Routing Components

Sources