Это в некоторой степени спасает от поломок существующий код, помогает прояснить — как он будет работать в тех или иных случаях. И, в конце концов, позволяет посмотреть на код, так скажем, со стороны, чтобы увидеть его слабые стороны.
Даже существует мнение, что сложно тестируемый код — претендент на переписывание.
Цель данной статьи — помочь в написании unit тестов для Angular 5+ приложения. Пусть это будет увлекательный процесс, а не головная боль.
Изолированные или Angular Test Bed?
Что касается unit тестирования Angular приложения, то можно выделить два вида тестов:
- Изолированные — те, которые не зависят от Angular. Они проще в написании, их легче читать и поддерживать, так как они исключают все зависимости. Такой подход хорош для сервисов и пайпов.
- Angular Test Bed — тесты, в которых с помощью тестовой утилиты TestBed осуществляется настройка и инициализация среды для тестирования. Утилита содержит методы, которые облегчают процесс тестирования. Например, мы можем проверить, создался ли компонент, как он взаимодействует с шаблоном, с другими компонентами и с зависимостями.
Изолированные
При изолированном подходе мы тестируем сервис как самый обыкновенный класс с методами.
Сначала создаем экземпляр класса, а затем проверяем, как он работает в различных ситуациях.
Прежде чем переходить к примерам, необходимо обозначить, что я пишу и запускаю тесты с помощью jest, так как понравилась его скорость. Если вы предпочитаете karma + jasmine, то примеры для вас также будут актуальны, поскольку различий в синтаксисе совсем немного.
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 должен получить данные из файловой системы.
Дело в том, что 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);
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 сообщество в телеграмме.
Комментарии (15)
justboris
18.02.2018 19:08В статье есть фрагмент кода
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ], }).compileComponents(); }));
Здесь обявлена асинхронная функция, но нет
await
. Получается, что окончанияcompileComponents
ждать не будет, а сразу передаст управление дальше.
Это опечатка или так задумано?
frontNika Автор
18.02.2018 19:34async в данном случае ангуляр утилита, а не jasmine фича. Код обернутый в async запускается в специальной асинхронной среде, в которой запрятан весь механизм. В общем, по сути упрощение написание кода, которое может видимо сбить с толку.
justboris
18.02.2018 19:37Понятно, спасибо.
Очень хороший пример, почему лучше не называть свои функции зарезирвированными словами:
async(() => {}) // вызов функции 'async' async () => {} // объявление асинхронной функции
Внешне разница в пару скобочек, но логическая разница — огромная.
dpischalka
19.02.2018 15:04Статья прям под стать! Спасибо. Как раз осваиваю тестирование в Angular :)
vintage
20.02.2018 07:57Ну а пока вы мучаетесь, давайте я расскажу вам как происходит тестирование в $mol, чтобы поглумиться над вашей участью :-)
Вам не нужно заучивать все 100500 методов jasmine-api. Вы просто используете один из 3 видов ассертов:
$mol_assert_equal — все аргументы идентичны
$mol_assert_unique — все аргументы разные
$mol_assert_fail — исполнение валится с ошибкой
Например, возьмём кота:
Заголовок спойлераclass Cat { lifecycle() { this.eat() this.crap() } eat() {} crap() {} }
justboris
20.02.2018 11:38Ну уж нет. Банальный манки-патчинг под видом хороших тестовых практик вы нам не продадите.
При всех недостатках и сложности Angular — использование DI делает код супер-тестируемым.
redyuf
20.02.2018 13:42Такой ли это банальный манки-патчинг в этом случае? Тут патчинг используется в тех же целях, что и конструктор.
1. Типобезопасность остается, т.к. ts проверяет интерфейс заменяемых методов
2. Патч находится всегда рядом с местом создания экземпляра класса и применяется на новый экзепляр, одновременно несколько патчей тут наложить невероятно, читабельность остается приемлемой, т.к. патч рядом с созданием.
3. Патчинг app.$ похож на один из паттернов DI — ambiant context, который как раз в примерах vintage используется. Его реализация намного проще любого di, основанном на инжекции в конструктор, а возможности не хуже.
4. Класс проектируется сразу с дефолтными реализациями зависимостей, которые потом можно легко переопределить.
Инжекция через конструктор в DI хоть и делает код тестируемым, но ценой копипаста. В нормальных языках есть что-то вроде scala case classes или kotlin data classes, которые упрощают настройку объекта.
В ts такое поведение нормально не сымитировать, поэтому патчинг используется для настройки экземпляра класса. Это не иммутабельно и если этим правилом пренебречь, можно нарушить безопасность. Тут вопрос приоритетов, можно ли этим пренебречь, ведь взамен упрощается и унифицируется настройка экземпляров классов.
Сахар в ts не способствует улучшению читабельности. При создании объекта нет названий аргументов:class A { constructor(public v?: string = '') {} } var a = new A('test')
Что б создание объекта читалось чуть лучше, можно условиться, что аргумент — объект, то это уже ведет к еще большему копипасту аргументов и их типов:class A { public v: string constructor(opts: {v?: string}) { this.v = opts.v || '' } } var a = new A({v: 'test'})
А вот патчинг:class A { public v: string = '' } var a = new A() a.v = 'test'
justboris
20.02.2018 23:52Такой ли это банальный манки-патчинг в этом случае? Тут патчинг используется в тех же целях, что и конструктор.
При инжекции зависимостей через конструктор у вас есть четкая граница "мое — чужое", сразу понятно что именно тестируется.
При манки-патчинге таких границ нет. Как вы определяете какие методы нужно запатчить? Как донести эту информацию до членов команды, чтобы они были в состоянии это поддерживать?
vintage
21.02.2018 10:10Есть два противоположных подхода:
Жёсткие зависимости. Это когда ваш юнит не заработает, пока вы не предоставите ему все необходимые зависимости. Жёсткие зависимости логично выносить в конструктор, чтобы точно не забыть какую-либо из них. Чем больше у вас декомпозиция, тем больше каждому юниту требуется зависимостей и тем многословней происходит инстанцирование и тем больше кода занимается только лишь пробрасыванием зависимостей с верхних уровней к нижним. Чтобы побороть эту проблему используются IoC контейнеры, типа ангуляровских модулей, которые сами резолвят для вас эти зависимости на основе контекста. Всё, что вы говорите в шаблоне вашего компонента — это "хочу вот здесь такой-то компонент", при этом какие ему потребуются зависимости в общем случае вы не знаете. То есть объявление в конструкторе всех зависимостей компонента, становится бесполезным, так не даёт исчерпывающую информацию обо всех зависимостях всего вложенного в него дерева компонент, без которых он не заработает. Соответственно, когда вы настраиваете IoC, то должны либо в явном виде запровайдить все эти фактически неявные, но необходимые зависимости (жёсткий вариант), либо компонент должен давать подсказки для IoC контейнера, какую реализацию брать по умолчанию (мягкий вариант). И тут мы плавно переходим к другой крайности...
Мягкие зависимости. Мы объявляем зависимости, тут же предоставляем реализацию по умолчанию и реализуем простой механизм переопределения этой зависимости. Чтобы воспользоваться юнитом ничего не нужно кроме как воспользоваться им. Не нужно настраивать какой-то внешний реестр зависимостей. И только если какое-либо его поведение вас не устраивает — вы можете подменить соответствующую зависимость. А чтобы эта подмена действовала не только на сам юнит, но и на все ниже по иерархии (иерархия может быть самой разнообразной и в том числе динамической), используется паттерн "окружающий контекст". Простейшая реализация этой логики выглядит так:
Заголовок спойлера// global context alias for code consistency const $ = this class Thing { // local context for self and inner protected $ : typeof $ constructor( context : typeof $ ) { this.$ = context } } function derivedContext( patch : Partial< typeof $ > ) { return Object.assign( Object.create( this ) , patch ) } class Man extends Thing { hands = [ new this.$.Hand( this.$ ) , new this.$.Hand( this.$ ) , ] } class Hand extends Thing { fingers = [ new this.$.Finger( this.$ ) , new this.$.Finger( this.$ ) , new this.$.Finger( this.$ ) , new this.$.Finger( this.$ ) , new this.$.Finger( this.$ ) , ] } class Finger extends Thing {} class TriggerFinger extends Thing {} // new Man with default hands and fingers const Jin = new this.$.Man( this.$ ) // new Man with trigger fingers const John = new this.$.Man( this.$.derivedContext({ Finger : TriggerFinger }) ) // new Man that can't have trigger fingers const Jack = new this.$.Man( this.$.derivedContext({ TriggerFinger : Finger }) )
justboris
21.02.2018 12:18Ну вот, совсем другое дело.
Только это совсем не похоже на подход, который вы продемонстрировали в изначальном комменте.
vintage
21.02.2018 19:34Основная суть та же. Да, забыл ещё рассказать про киллер-фичу — возможность мочить даже нативные апи. Например, используем локальное хранилище:
const Config extends Thing { get teme() { this.$.localStorage['theme'] || 'light' } set teme( next : string ) { this.$.localStorage['theme'] = next } }
Подсовываем вместо реального хранилище фейковое:
new Settings( this.$.derivedContext({ localStorage : {} }) )
В Ангуляре для этого пришлось бы заворачивать нативное апи в сервис. И так для каждого апи. В стандартной поставке, конечно же, такой сервис в комплекте не идёт. А единственный уже готовый модуль, который я нашёл, не ставится из-за устаревшего npm хука.
vintage
20.02.2018 18:46Хороший/плохой — весьма скользкие понятия. Вы думаете что spyOn под капотом делает? Неявно заменяет метод, чем вполне может сломать ваш метод. Мой пример тоже супер-тестируемый. Разница лишь в объёме бойлерплейта и в понимании что происходит.
justboris
20.02.2018 23:55spyOn делает тоже самое, но речь совсем не об этом.
Разговор здесь о нормальном DI против манки-патчинга. На маленьких примерах в пару строк с котиками можно и в патчах разобраться. На больших классах с десятком методов, которые писались разными людьми, лучше иметь более строгий протокол.
jbubsk
Стабы замучаетесь писать для каждых многометодных сервисов. В помощь ts-mockito!
frontNika Автор
Интересно, гляну. А вообще, как минимум, некоторые IDE уже могут автоматом стабы генерировать. Правда, я не пробовала.