Тести в Angular

У Angular для тестів використовуються два інструменти:
Jasmine – фреймворк для написання тестів, і
Karma – інструмент для запуску цих тестів в браузері.

Jasmine

методи Jasmine

  • describe(description, function) – метод застосовується для угруповання взаємозалежних тестів
  • beforeEach(function) – метод застосовується для призначення завдання, яке повинно виконуватися перед кожним тестом
  • afterEach(function) – метод застосовується для призначення завдання, яке повинно виконуватися після кожного тестом
  • it(description, function) – метод застосовується для виконання тесту
  • expect(value) – метод застосовується для ідентифікації результату тесту
  • toBe(value) – метод застосовується для завдання очікуваного значення тесту: метод порівнює результат із значенням
  • toEqual(object) – перевіряє, що результатом є той самий об’єкт, що і заданий значення
  • toMatch(regexp) – перевіряє, що результат відповідає заданому регулярному виразу
  • toBeDefined() – перевіряє, що результат визначений
  • toBeUndefined() – перевіряє, що результат не визначений
  • toBeNull() – перевіряє, що результат дорівнює Null
  • toBeTruthy() – перевіряє, що результат є квазіістінним
  • toBeFalsy() – перевіряє, що результат є квазіложним
  • toContain(substring) – перевіряє, що результат містить задану підрядок
  • toBeLessThan(value) – перевіряє, що результат менше заданого значення
  • toBeGreaterThan(value) – перевіряє, що результат більше заданого значення

Клас TestBed і його методи

Клас TestBed– відповідає за моделювання середовища додатку Angular для виконання тестів.

  • TestBed.configureTestingModule – налаштовуємо тестовий модуль;
  • TestBed.createComponent – отримуємо екземпляр компонента;
  • compileComponents– застосовується для компіляції компонентів. compileComponents повертає Promise, який дозволяє налаштувати основні змінні для тесту після компіляції компонента (наприклад, коли ми використовуємо зовнішній шаблон і т.д.);
beforeEach( async(() => { 
    TestBed.configureTestingModule({ 
        declarations:[TestComponent]
    });
    TestBed.compileComponents().then(() => {
        fixture = TestBed.createComponent(TestComponent);
        component = fixture.componentInstance;
    });
}));

Зверніть увагу на метод async, який займається тим, що відстежує всі асинхронні завдання всередині нього, приховуючи від нас складність асинхронного виконання.

ComponentFixture

Результатом методу createComponent є об’єкт ComponentFixture, який надає властивості і методи для тестування компонента:

  • componentInstance – повертає об’єкт компонента;
  • nativeElement – повертає об’єкт DOM, який представляє керуючий елемент для компонента;
  • debugElement – повертає тестовий керуючий елемент для компонента;
  • detectChanges() – примушує тестову середу виявляти зміни стану і відображати їх в шаблоні компонента;
  • whenStable() – повертає об’єкт Promise, дозволяються при повній обробці всіх змін (використовується з асинхронність, наприклад, коли ви отримуєте дані асіхронно від будь-якої фейковий служби)
let fixture: ComponentFixture <MyComponent>>; 
     let component: MyComponent; 
     let dataSource = new MockDataSource();
    
    beforeEach(async(() => { TestBed.configureTestingModule({
            declarations: [ MyComponent ], 
            providers: [ { provide: RestDataSource, useValue: dataSource } ] }); 
    TestBed.compileComponents().then(() => { 
            fixture = TestBed.createComponent( MyComponent );

          // debugElement = fixture.debugElement; 
            component = fixture.componentInstance;
     });
}));

ComponentFixture.debugElement

Властивість ComponentFixture.debugElement – повертає об’єкт типу DebugElement (кореневог елементу шаблону компонента).

debugElement має ряд властивостей і методів:

  • nativeElement – повертає об’єкт, який представляє об’єкт HTML в DOM;
  • children – повертає масив ‘дочірніх’ об’єктів типу DebugElement;
  • query(selectorFunction) – функція selectorFunction отримує в якості параметра об’єкт типу DebugElement для кожного елемента HTML в шаблоні; функція повертає перший об’єкт типу DebugElement, для якого функція поверне true;
  • queryAll(selectorFunction) – аналогічно query, але поверне всі об’єкти типу DebugElement;
  • triggerEventHandler(name, event) – ініціюємо подію, наприклад, за допомогою декоратора @HostListener;

Клас By

Клас By дозволяє знаходити елементи в шаблоні компонента завдяки своїм методам:

  • By.all
  • By.css(selector)
  • By.directive(type)

Unit-тести: тестування сервісів

angular-cli при створенні сервісу генерує файл виду name_service.spec.ts як болванку для тесту. В даному файлі за допомогою хелперів TestBed і inject створюється наша сутність.

import { TestBed, inject } from '@ angular/core/testing';

TestBedдопомагає настроїти модуль, в якому ми повинні вказати provider для нашого сервісу. inject – це хелпер, який допомагає запустити injector по заданому провайдеру, завдяки йому ми отримуємо примірник нашого сервісу (service: ArticleService).

Далі ми проводимо простий тест на те, що сервіс існує:

expect(service).toBeTruthy();

Повний код тесту, який спочатку згенерував angular-cli:

import { TestBed, inject } from '@ angular/core/testing';
import { ArticleService } from './article.service';

  describe('ArticleService', ()=>{
     beforeEach(() =>{ TestBed.configureTestingModule({
      providers: [ ArticleService ] 
    });
  });  
     
  it( 'should be created', inject([ArticleService], ( service: ArticleService ) => {
    expect(service).toBeTruthy>();
  }));
});

Припустимо, в сервісі у нас є метод sum, який повертає результат складання двох чисел:

sum(a: number, b: number): number { return a + b; }

Протестуємо метод sum:

it( 'should return sun', inject([ ArticleService ], ( service: ArticleService ) => { 
    expect(service.sum( 5, 5)).toBe(10); 
}));

Щоб запустити тести необхідно виконати команду (ng test в package.json):

npm run test

Працює з асинхронним методом в сервісі

Асинхронний метод в сервісі sum.service.ts:

// async method 
sumAsync( a: number,  b: number): Promise<any> { return new Promise(function(resolve, reject) { 
        setTimeout(function(){
            resolve( a + b); 
       }, 500 ); });
}

Тестуємо в sum.service.spec.ts:

Для емуляції асинхронности в Angular присутній метод fakeAsync. Також для емуляції асинхронности присутні два додаткових методи, які ми використовуємо всередині методу fakeAsyncflush (flush всередині fakeAsync скине всі асинхронности, тобто виконає їх синхронно), tick – приймає параметр (к-ть мілісекунд), після закінчення яких виконається код в методі fakeAsync.

// test async method
it('should return async sum', fakeAsync(inject([ SumService ], ( service: SumService ) => { 
    service.SumAsync(5, 5).Then( function(data){ // повернутий результат ми можемо затверджувати:
        expect(data).toBe(10);});     
        // тести пройшли і якщо випаде помилка, так як виконується асинхронна операція ніхто чекати не буде
        // можна скористатися методом flush(). flush всередині fakeAsync скине всі асинхронности, тобто виконає їх синхронно.
        // flush ();
        // OR:
        // або ми можемо скористатися всередині fakeAsync методом tick, в який можна передати кількість мілісекунд
        tick>(500); 
})));

Метод whenStable

Метод whenStable повертає об’єкт Promise, дозволяється при повній обробці всіх змін (використовується з асинхронність, наприклад, коли ви отримуєте дані асіхронно від будь-якої фейковий служби). Наприклад, це дає нам можливість дочекатися виконання Observable і лише потім протестувати наші зміни.

@Injectable() class MockDataSource { public data = [ { item: 1, item: 2 } ];
    getData(): Observable { return new Observable( obs => {
            setTimeout(() => obs.next(this.data), 500); 
      })
    }
}
             
describe("TestComponent", ()=>{
    let fixture: ComponentFixture; 
    let component: TestComponent;
    // створюємо фіктивну службу, яка для тесту підміняє службу компонента 
    let dataSource = new MockDataSource();

    beforeEach( async(() => { 
      TestBed.configureTestingModule({
            declarations: [ TestComponent ],
            providers: [ { provide: RestDataSource, useValue: dataSource } ]
      });
      TestBed.compileComponents().then(() => {
            fixture = TestBed.createComponent( TestComponent );
            component = fixture.componentInstance;
      });
    }));

    it("async operation success", () => {
        fixture.detectChanges();
        fixture.whenStable().then(() => {
            expect(component.getItems().length).toBe(2);
        }); 
    });
});

Unit-тести: тестування httpClient

Розглянемо як тестувати сервіси, які містять http-запити.

Щоб тестувати http-запити необхідно також налаштувати proxy. У файлі karma.conf.js вкажіть параметр proxies:

proxies: { '/api': { 'target': 'http://localhost:3000/api', 'secure': false, 'changeOrigin': true, 'logLevel': 'info'} }

Якщо консоль буде майоріти помилками виду StaticInjectorError(DynamicTestModule)[HttpClient], то це означає, що в компоненті використовуються http-запити і необхідно імпортувати HttpClientModule:

imports: [ HttpClientModule, ],
@Injectable ({ providedIn: 'root' })
export class CatsService{
    constructor(private httpClient: HttpClient){ }

    getCats(): Observable<any> { return this.httpClient.get('/api/cats'); }
         
    getCat(_id): Observable<any> { return this.httpClient.get(`/api/cat/${_ id}`); }
}

Протестуємо запит на отримання cat по id.

import { TestBed, inject } from '@angular/core/testing'; 
import { CatsService } from './cats.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('CatsService', () => { 
    // HttpClientTestingModule - 
    // HttpTestingController - 
    // допомогою HttpClientTestingModule і HttpTestingController ми можемо управляти http-запитами 
    beforeEach(()  =>  { 
        TestBed.configureTestingModule ({
            imports: [ HttpClientTestingModule ],
            providers: [ CatsService ] 
        });
    });

    it('should be created', inject([ CatsService ], (service: CatsService) => {
        expect(service).toBeTruthy();
    }));
    
    // так як ми робимо unit-тести, то ми не тестуємо сам httpClient 
    // (він протестований розробниками Angular)

    it('should get one cat', inject([CatsService, HttpTestingController], (service: CatsService, backend: HttpTestingController) => {
        // створимо фейковий об'єкт cat (він приходить з сервера)
        const mockCat =  { name: 'asdasd' };
        service.getCat('5b22e86df062b20530788e2d').subscribe(function(cat) {
            // так як приходить об'єкт, то для порівняння використовуємо toEqual
            expect(cat).toEqual(mockCat);
        });
                
        // сформуємо сервіс backend, щоб він повертав нам потрібні дані

        backend.expectOne({
            method: 'Get',
            url: '/api/cat/5b22e86df062b20530788e2d'
        }).flush(mockCat) 
        // flush дозволяє скинути і вказати, що повинен повернути запит 
    }));
>});

Unit-тести: тестування pipe

Створимо pipe, який буде перевертати рядок – reverse-string.

@Pipe ({ name: 'reverseString' }) 
export class ReverseStringPipe implements PipeTransform {
    transform (value: string): string {
        if(typeof value !== 'string') {
            throw new Error('Error on Reverse: not string') 
        }
        
        let str = '';
        for(let i = value.length - 1; i >= 0; i-){
            str +=  value.charAt(i);
        }
        Return str; 
    } 
}

Фільтри ми можемо тестувати як звичайний js-код, тобто тестувати клас, а в цьому класі тестувати метод.

import { ReverseStringPipe } from './reverse-string.pipe';

describe('ReverseStringPipe', () => {
    let pipe;
    beforeEach(() => {
        pipe = new ReverseStringPipe(); 
    });

    // тест на те, що pipe існує
    it('create an instance', () => {
        expect(pipe).ToBeTruthy();
    });

    // тест на те, щo рядок 'перевертається'
    it('reverse success', () => {
        expect(pipe.Transform('abcde')).ToBeTruthy('edcba');
    });

    // * перевіряємо на те, що виключення (exception) було викинуто
    it('should throw on error', () => {
        // всередині expect exception потрібно звернути в функцію
        expect(() => {
            pipe.Transform(1212);
        }).toThrowError('Error on Reverse: not string');
    });
});

Хелпери beforeEach

Хелпер beforeEach дозволяє задати код, який буде виконаний перед кожним тестом. Наприклад, щоб кожен раз не створювати екземпляр pipe (дивіться приклад – Unit-тести: тестування pipe) ми можемо винести його в beforeEach:

describe('ReverseStringPipe', () => {
    let pipe;
    beforeEach(() => {
        pipe = new ReverseStringPipe();
    });
});

Unit-тести: Тестуємо компонент

У компоненті ми будемо отримувати за допомогою методу компонента getCat і сервісу по id певну cat і виводити cat.name в шаблоні. Ми повинні перевірити, що в методі викликається сервіс і то що значення записується в змінну компонента cat.

// cat-info.component.ts 
import { Component, OnInit } from '@angular/core'; 
import { CatsService } from '../../services/cats.service';
      
@Component ({
  selector: 'app-cat-info',
  templateUrl: './cat-info.component.html',
  styleUrls: [ './cat-info.component.css' ] 
}) 
export class CatInfoComponent implements OnInit {
    public cat: any;

    constructor(private catService: CatsService) { }

    ngOnInit() { this.getCat(); }
        
    getCat() { 
        let self = this; 
        self.catService.getCat("5b22e86df062b20530788e2d").subscribe(function(data) {
            self.cat = data;
        });
    }
}
// cat-info.component.spec.ts 
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CatInfoComponent } from './cat-info.component'; 
// імпортуємо так як в компоненті використовуємо запити до апі 
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { CatsService } from "../../services/cats.service"; 
import { of } from "rxjs";

describe('CatInfoComponent', () => { 
    let component: CatInfoComponent; 
    let fixture: ComponentFixture <CatInfoComponent>;
    let catService: CatsService;
    let spy: jasmine. Spy;
    let mockCat;

    beforeEach(async(() => { TestBed.configureTestingModule({
            imports: [ HttpClientTestingModule ],
            declarations: [ CatInfoComponent ],
            providers: [ CatsService ]
    }).compileComponents();
})); 

beforeEach(() => {
    fixture = TestBed.createComponent(CatInfoComponent);
    
    // instance компонента
    component = fixture.componentInstance;
    
    // injector - це сутність, яка дозволяє створювати залежності для компонента
    // (ми отримуємо injector для нашого конкретного компонента) . Отримуємо CatsService:
    catService = fixture.debugElement.injector.get(CatsService);
    mockCat = {
       Name: "asas"
    };
    // нам потрібен шпигун на метод getCat; також повернемо значення, яке повертає метод getCat
    spy = spyOn(catService, 'getCat').and.returnValue(of(mockCat))
        fixture.detectChanges();
    });

    // перевіряємо на існування компонента
    it('should create', () => {
        expect (component ).ToBeTruthy();
    });

    // перевіримо що метод сервісу викликається
    // для цього нам будуть потрібні спеціальні суті - шпигуни
    // (jasmine spy, https://jasmine.github.io/api/2.9/Spy.html).
    // Коли ви створюєте сутність spy, то вона буде зберігати всі виклики
    // і звернення до цього spy, також вона може повертати будь-які
    // значення замість реальної суті. 

    it('should call catService.getCat', () => { 
        // шпигун був викликаний як завгодно раз
        expect(spy.calls.any()).toBeTruthy();
    });

    // перевіримо що сервіс записує значення після виклику сервісу
    it('should set cat(cat.name)', () => {
        expect(component.Cat.Name).ToEqual('asas');
    });
});

Unit-тести: Тестуємо директиву

Наша директива Emit (передає наверх) батьківського компоненту кількість кліків.

// count-click.directive.ts
@Directive({ selector: '[appCountClick]'}) 
export class CountClickDirective {

    @Output() changes = new EventEmitter<number>();
    private count = 0;

    @HostListener('click')
    onClick() {
        this.count++;
        this.changes.emit(this.count);
    }
}

Використання директиви:

<div appCountClick(changes) = "getClick ($ event)"> click me </ div>

Для директиви Angular в заготівки робить екземпляр від класу директиви; цього мало, але так як директиви дуже різнобічні розробники Angular вирішили, що цього достатньо. Нам необхідно протестувати роботу декораторів @Output і @HostListener, для цього потрібно навісити кудись подію і висновок. Тому створимо тестовий компонент в файлі count-click.directive.spec.ts:

// count-click.directive.spec.ts
// тестовий компонент 
@Component({ 
    template: `<div appCountClick (changes) = "outCount = $event"> click me </ div> ` 
})

export class TestCountComponent { public outCount = 0; }

describe('CountClickDirective', () => {
    let testCountComponent, fixture;

    // инициализируем тестовий компонент
    beforeEach(() => { 
        // налаштуємо модуль
        TestBed.ConfigureTestingModule({
            declarations: [ TestCountComponent, CountClickDirective ]
        });

        fixture = TestBed.createComponent(TestCountComponent);
        // отримаємо екземпляр компонента
        testCountComponent = fixture.componentInstance;
    })

    it('should create an instance', () => { 
        const directive = new CountClickDirective();
        expect(directive).toBeTruthy();
    });  
          
    // перевіримо кліки по диву
    it('should count click', () => {
        // отримуємо div, для якого ми ставимо директиву appCountClick 
        let div =  fixture.NativeElement.QuerySelector('div');
        div.Click();
        expect(testCountComponent.outCount).toBe(1);
    });
});