Тесты в Angular

Aug 13, 2018

В 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

Итак, тесты прошли, вы можете увидеть в браузере:

test_service.png

Pаботаем с асинхронным методом в сервисе

Асинхронный метод в сервисе 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. Также для эмуляции асинхронности присутствуют два дополнительных метода, которые мы используем внутри метода fakeAsync: flush (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-тесты: Тестируем директиву

Наша директива эмитит(передает наверх) родительскому компоненту количество кликов.

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

Для поверхностного изучения:

Добавить комментарий