С помощью unit тестов мы можем удостовериться, что отдельные части приложения работают именно так, как мы от них ожидаем.Это в некоторой степени спасает от поломок существующий код, помогает прояснить — как он будет работать в тех или иных случаях. И, в конце концов, позволяет посмотреть на код, так скажем, со стороны, чтобы увидеть его слабые стороны.
Даже существует мнение, что сложно тестируемый код — претендент на переписывание.
Цель данной статьи — помочь в написании unit тестов для Angular 5+ приложения. Пусть это будет увлекательный процесс, а не головная боль.
Изолированные или Angular Test Bed?
Что касается unit тестирования Angular приложения, то можно выделить два вида тестов:
- Изолированные — те, которые не зависят от Angular. Они проще в написании, их легче читать и поддерживать, так как они исключают все зависимости. Такой подход хорош для сервисов и пайпов.
- Angular Test Bed — тесты, в которых с помощью тестовой утилиты TestBed осуществляется настройка и инициализация среды для тестирования. Утилита содержит методы, которые облегчают процесс тестирования. Например, мы можем проверить, создался ли компонент, как он взаимодействует с шаблоном, с другими компонентами и с зависимостями.
Изолированные
При изолированном подходе мы тестируем сервис как самый обыкновенный класс с методами.
Сначала создаем экземпляр класса, а затем проверяем, как он работает в различных ситуациях.
Прежде чем переходить к примерам, необходимо обозначить, что я пишу и запускаю тесты с помощью jest, так как понравилась его скорость. Если вы предпочитаете karma + jasmine, то примеры для вас также будут актуальны, поскольку различий в синтаксисе совсем немного.
Jasmine/jest различия
jasmine.createSpy('name') --> jest.fn()
and.returnValue() --> mockReturnValue()
spyOn(...).and.callFake(() => {}) --> jest.spyOn(...).mockImplementation(() => {})
and.returnValue() --> mockReturnValue()
spyOn(...).and.callFake(() => {}) --> jest.spyOn(...).mockImplementation(() => {})
Рассмотрим пример сервиса для модального окна. У него всего лишь два метода, которые должны рассылать определенное значение для переменной popupDialog. И совсем нет зависимостей.
import { Injectable } from '@angular/core'; import { ReplaySubject } from 'rxjs/ReplaySubject'; @Injectable() export class PopupService { private popupDialog = new ReplaySubject<{popupEvent: string, component?, options?: {}}>(); public popupDialog$ = this.popupDialog.asObservable(); open(component, options?: {}) { this.popupDialog.next({popupEvent: 'open', component: component, options: options}); } close() { this.popupDialog.next({popupEvent: 'close'}); } }
При написании тестов не нужно забывать о порядке выполнения кода. Например, действия, которые необходимо выполнить перед каждым тестом, мы помещаем в beforeEach.
Так, созданный в коде ниже экземпляр сервиса нам понадобится для каждой проверки.
import { PopupService } from './popup.service'; import { SignInComponent } from '../components/signin/signin.component'; describe('PopupService', () => { let service: PopupService; // создаем экземпляр PopupService beforeEach(() => { service = new PopupService(); }); // done нужно, чтобы тест не завершился до получения данных it('subscribe for opening works', (done: DoneFn) => { // вызываем метод open service.open(SignInComponent, [{title: 'Попап заголовок', message: 'Успешно'}]); // при изменении значения popupDialog$ должен сработать subscribe service.popupDialog$.subscribe((data) => { expect(data.popupEvent).toBe('open'); done(); }); }); it('subscribe for closing works', (done: DoneFn) => { service.close(); service.popupDialog$.subscribe((data) => { expect(data.popupEvent).toBe('close'); done(); }); }); });
Angular Test Bed тесты
Простой компонент
А теперь посмотрим на всю мощь утилиты TestBed. В качестве примера для начала возьмем простейший компонент:
import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { title = 'app'; }
Файл шаблона:
<h1> Welcome to {{ title }}! </h1>
Файл тестов разберем по кусочкам. Для начала задаем TestBed конфигурацию:
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ], }).compileComponents(); }));
compileComponents — метод, делающий вынесенные в отдельные файлы стили и шаблон встроенными.
Этот процесс является асинхронным, так как компилятор Angular должен получить данные из файловой системы.
Иногда compileComponents не нужен
Если вы используете WebPack, то этот вызов и метод async вам не нужен.
Дело в том, что WebPack автоматически перед запуском тестов встраивает внешние стили и шаблон.
Соответственно, и при прописывании стилей и шаблона внутри файла компонента компилировать самостоятельно не надо.
Дело в том, что WebPack автоматически перед запуском тестов встраивает внешние стили и шаблон.
Соответственно, и при прописывании стилей и шаблона внутри файла компонента компилировать самостоятельно не надо.
Для тестов необходимо, чтобы компоненты скомпилировались до того, как через метод createComponent() будут созданы их экземпляры.
Поэтому тело первого BeforeEach мы поместили в asynс метод, благодаря чему его содержимое выполняется в специальной асинхронной среде. И пока не будет выполнен метод compileComponents(), следующий BeforeEach не запустится:
beforeEach(() => { fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; });
Благодаря вынесению в beforeEach всех общих данных, дальнейший код получается значительно чище.
Для начала проверим создание экземпляра компонента и его свойство:
it('should create the comp', => { expect(comp).toBeTruthy(); }); it(`should have as title 'app'`, () => { expect(comp.title).toEqual('app'); });
Далее мы хотим проверить, что переменная компонента title вставляется в DOM. При этом мы ожидаем, что ей присвоено значение 'app'. А это присваивание происходит при инициализации компонента.
Запустив с помощью detectChanges CD цикл, мы инициализируем компонент.
До этого вызова связь DOM и данных компонента не произойдет, а следовательно тесты не пройдут.
it('should render title in a h1 tag', () => { fixture.detectChanges(); const compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent) .toContain('Welcome to app!'); });
Полный код теста компонента
import { TestBed, async, ComponentFixture } from '@angular/core/testing'; import { AppComponent } from './app.component'; describe('AppComponent', () => { let comp: AppComponent; let fixture: ComponentFixture<AppComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; }); it('should create the comp', () => { expect(comp).toBeTruthy(); }); it(`should have as title 'app'`, () => { expect(comp.title).toEqual('app'); }); it('should render title in a h1 tag', () => { fixture.detectChanges(); const compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent) .toContain('Welcome to app!'); }); });
Компонент с зависимостями
Давайте усложним наш компонент, внедрив в него сервис:
export class AppComponent { constructor(private popup: PopupService) { } title = 'app'; }
Вроде бы пока не особо усложнили, но тесты уже не пройдут. Даже если вы не забыли добавить сервис в providers AppModule.
Потому что в TestBed эти изменения тоже нужно отразить:
TestBed.configureTestingModule({ declarations: [ AppComponent ], providers: [PopupService] });
Мы можем указать сам сервис, но обычно лучше заменить его на класс или объект, который описывает именно то, что нам необходимо для тестов.
Почему?
А вы представьте сервис с кучей зависимостей и вам все придется при тестировании прописать. Не говоря уже о том, что мы тестируем в данном случае именно компонент. Вообще, тестировать что-то одно — это как раз про unit тесты.
Итак, прописываем стаб следующим образом:
const popupServiceStub = { open: () => {} };
Методы задаем только те, которые тестируем.
Если хотим описать стаб как класс
class popupServiceStub { open() {} }
providers: [{provide: PopupService, useClass: popupServiceStub } ]
В TestBed конфигурацию добавляем providers:
providers: [{provide: PopupService, useValue: popupServiceStub } ]
Не стоит путать PopupService и PopupServiceStab. Это разные объекты: первый — клон второго.
Отлично, но мы же сервис внедряли не просто так, а для использования:
ngOnInit() { this.popup.open(SignInComponent); }
Теперь стоит убедиться, что метод действительно вызывается. Для этого сначала получим экземпляр сервиса.
Так как в данном случае сервис задан в providers корневого модуля, то мы можем сделать так:
popup = TestBed.get(PopupService);
А как еще?
Если бы речь шла о сервисе, который прописан в providers компонента, то пришлось бы получать его так:
popup = fixture.debugElement.injector.get(PopupService);
Наконец сама проверка:
it('should called open', () => { const openSpy = jest.spyOn(popup, 'open'); fixture.detectChanges(); expect(openSpy).toHaveBeenCalled(); });
Наши действия:
- Устанавливаем шпиона на метод open объекта popup.
- Запускаем CD цикл, в ходе которого выполнится ngOnInit с проверяемым методом
- Убеждаемся, что он был вызван.
Заметьте, что проверяем мы именно вызов метода сервиса, а не то, что он возвращает или другие вещи, касающиеся самого сервиса. Их
Сервис с http
Совсем недавно (в Angular 4) файл тестов сервиса с запросами мог выглядеть воистину устрашающе.
Вспомнить, как это было
beforeEach(() => TestBed.configureTestingModule({ imports: [HttpModule], providers: [ MockBackend, BaseRequestOptions, { provide: Http, useFactory: (backend, defaultOptions) => new Http(backend, defaultOptions), deps: [MockBackend, BaseRequestOptions] }, UserService ] }));
Впрочем, и сейчас в интернете полно статей с этими примерами.
А меж тем разработчики Angular не сидели сложа руки, и мы теперь можем писать тесты намного проще. Просто воспользовавшись HttpClientTestingModule и HttpTestingController.
Рассмотрим сервис:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs/Observable'; import { ReplaySubject } from 'rxjs/ReplaySubject'; import { Game } from '../models/gameModel'; import { StatisticsService } from './statistics.service'; @Injectable() export class GameService { gameData: Array<Game>; dataChange: ReplaySubject<any>; gamesUrl = 'https://any.com/games'; constructor(private http: HttpClient, private statisticsService: StatisticsService) { this.dataChange = new ReplaySubject(); } getGames() { this.makeResponse() .subscribe((games: Array<Game>) => { this.handleGameData(games); }); } makeResponse(): Observable<any> { return this.http.get(this.gamesUrl); } handleGameData(games) { this.gameData = games; this.doNext(games); this.statisticsService.send(); } doNext(value) { this.dataChange.next(value); } }
Для начала описываем всех наших глобальных героев:
let http: HttpTestingController; let service: GameService; let statisticsService: StatisticsService; const statisticsServiceStub = { send: () => {} };
Тут из интересного — стаб statisticsService. Мы по аналогии с компонентом стабим зависимости, так как тестим сейчас только конкретный сервис.
Как видите, я просто прописала именно то, что понадобится в этом тесте. Просто представьте, что в StatisticsService на самом деле огромное количество методов и зависимостей, а используем в данном сервисе мы только один метод.
Далее объявим данные, которые будем подкидывать в ответ на запрос:
const expectedData = [ {id: '1', name: 'FirstGame', locale: 'ru', type: '2'}, {id: '2', name: 'SecondGame', locale: 'ru', type: '3'}, {id: '3', name: 'LastGame', locale: 'en', type: '1'}, ];
В TestBed необходимо импортировать HttpClientTestingModule и прописать все сервисы:
TestBed.configureTestingModule({ imports: [ HttpClientTestingModule, ], providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }] });
Следующий шаг — получение экземпляров всех сервисов, которые нам понадобятся:
service = TestBed.get(GameService); statisticsService = TestBed.get(StatisticsService); http = TestBed.get(HttpTestingController);
Не помешает сразу же прописать в afterEach проверку на то, что нет отложенных запросов:
afterEach(() => { http.verify(); });
И переходим к самим тестам. Самое простое, что мы можем проверить — создался ли сервис. Если вы забудете в TestBed указать какую-либо зависимость, то этот тест не пройдет:
it('should be created', () => { expect(service).toBeTruthy(); });
Дальше уже интереснее — проверяем, что по ожидаемому запросу получим определенные данные, которые сами же и подкидываем:
it('should have made one request to GET data from expected URL', () => { service.makeResponse().subscribe((data) => { expect(data).toEqual(expectedData); }); const req = http.expectOne(service.gamesUrl); expect(req.request.method).toEqual('GET'); req.flush(expectedData); });
Не помешает проверить еще и как работает ReplaySubject, то есть будут ли отлавливаться у подписчиков полученные игры:
it('getGames should emits gameData', () => { service.getGames(); service.dataChange.subscribe((data) => { expect(data).toEqual(expectedData); }); const req = http.expectOne(service.gamesUrl); req.flush(expectedData); });
И наконец последний пример — проверка, что statisticsService метод send будет вызван:
it('statistics should be sent', () => { const statisticsSpy = jest.spyOn(statisticsService, 'send'); service.handleGameData(expectedData); expect(statisticsSpy).toHaveBeenCalled(); });
Полный код тестов
import { TestBed } from '@angular/core/testing'; import { GameService } from './game.service'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { StatisticsService } from './statistics.service'; import 'rxjs/add/observable/of'; describe('GameService', () => { let http: HttpTestingController; let service: GameService; let statisticsService: StatisticsService; const statisticsServiceStub = { send: () => {} }; const expectedData = [ {id: '1', name: 'FirstGame', locale: 'ru', type: '2'}, {id: '2', name: 'SecondGame', locale: 'ru', type: '3'}, {id: '3', name: 'LastGame', locale: 'en', type: '1'}, ]; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule, ], providers: [GameService, { provide: StatisticsService, useValue: statisticsServiceStub }] }); service = TestBed.get(GameService); statisticsService = TestBed.get(StatisticsService); http = TestBed.get(HttpTestingController); }); afterEach(() => { http.verify(); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should have made one request to GET data from expected URL', () => { service.makeResponse().subscribe((data) => { expect(data).toEqual(expectedData); }); const req = http.expectOne(service.gamesUrl); expect(req.request.method).toEqual('GET'); req.flush(expectedData); }); it('getGames should emits gameData', () => { service.getGames(); service.dataChange.subscribe((data) => { expect(data).toEqual(expectedData); }); const req = http.expectOne(service.gamesUrl); req.flush(expectedData); }); it('statistics should be sent', () => { const statisticsSpy = jest.spyOn(statisticsService, 'send'); service.handleGameData(expectedData); expect(statisticsSpy).toHaveBeenCalled(); }); });
Как облегчить тестирование?
- Выбирайте тот тип тестов, который подходит в данной ситуации и не забывайте про суть unit тестов
- Убедитесь, что знаете все возможности вашей IDE в плане помощи при тестировании
- При генерации сущностей с помощью Angular-cli автоматически генерируется и файл тестов
- Если в компоненте множество таких зависимостей, как директивы и дочерние компоненты, то можно отключить проверку их определения. Для этого в TestBed конфигурации прописываем NO_ERRORS_SCHEMA:
TestBed.configureTestingModule({ declarations: [ AppComponent ], schemas: [ NO_ERRORS_SCHEMA ] })
Без послесловия не обойтись
Охватить в одной статье все моменты, чтобы она при этом не стала устрашающей (а-ля документация), довольно сложно. Но мне кажется, главное — понять, какие у вас есть инструменты и что с ними нужно делать, а дальше уже бесстрашно сталкиваться на практике как с банальными, так и с нетривиальными случаями.
Если после прочтения статьи вам стало что-то немного понятнее — ура!
У вас есть что добавить? Вы с чем-то не согласны?
Что ж, может быть, ради ваших ценных комментариев это статья и затевалась.
P.S. Ах да, вот ссылка на все примеры.
Также для всех интересующихся Angular может быть полезно русское Angular сообщество в телеграмме.
