В предыдущей части я закончил рассказ про API и стык с фронтендом. В этой статье я расскажу про сам фронтенд и начну с темы, которая обычно раскрывается ближе к концу. Тестирование.
Для начала, структура проекта, сформированного angular-cli. За ненадобностью, часть файлов была отфильтрована.
Unit-тестирование в Angular базируется на Jasmine, утилитах самого Angular и Karma. Описание данных пакетов приведено на официальном сайте, поэтому не буду его дублировать. Вкратце, Jasmine – фреймворк, предоставляющий необходимую функциональность для базовых тестов, утилиты Angular позволяют использовать тестовое окружение, Karma служит для запуска тестов.
Сами файлы тестов именуются с добавлением spec перед расширением. По такому шаблону: *.spec.ts Karma будет их находить. Единого мнения насчет расположения тестов нет. Допускается расположение «поближе» к объекту тестирования и отдельный каталог для всех тестов приложения. У команды Angular приведены плюсы расположения тестов рядом с объектом:
Я остановил выбор на варианте, предложенном angular-cli – тесты располагаются рядом с объектом.
Точкой входа для тестирования приложения Angular является файл src/test.ts, который импортирует все необходимые пакеты, собирается файлы тестов по шаблону *.spec.ts и запускает систему исполнения тестов, в нашем случае – Karma. Сам файл после создания проекта выглядит так:
Здесь хочу обратить внимание на проблему, с которой успел столкнуться. Порядок импортов важен и менять его не стоит, иначе можно будет встретить ошибки, корни которых крайне неявны. Поэтому рекомендую добавлять сторонние пакеты в конец списка импортов и добавить в начало файла флаг /* tslint:disable */ для отключение линтера, либо исключить этот файл в его конфигурации, потому что tslint может посчитать существующий порядок неверным и исправить.
Перед написанием тестов необходимо настроить Karma, которая будет их запускать. Начальная конфигурация выглядит следующим образом:
Так как описание всех возможных настроек Karma – отдельная тема для обсуждения, остановлюсь только на тех, которые используем мы:
Итак, подготовительный этап закончен, можно запускать тесты. Для проекта, сформированного с помощью angular-cli, используется команда ng test. У неё есть несколько параметров, среди которых я бы хотел отметить --sourcemap. Исходя из названия, этот параметр включает sourcemaps в тесты. Но с этим связана ошибка ‘XMLHttpRequest: failed to load’. Выставление false для этого параметра решает данную проблему.
Первая и самая важная утилита тестирования компонента Angular – TestBed. Её идея заключается в создании отдельного модуля, который идентичен модулю компонента, но создается в тестовом окружении и изолирован от приложения.
Для создания TestBed вызывается метод configureTestingModule, которому передается объект метаданных. Этот метод следует вызывать асинхронно перед каждым тестом, чтобы компонент находился в исходном состоянии. Для этого есть метод beforeEach(async() => {});
Как видно из примера, мы объявляем массив компонентов для тестирования (в данном случае только один). В дальнейшем возможно добавить еще компонентов, импортировать необходимые для работы модули, директивы, пайпы. Метод configureTestingModule возвращает класс TestBed, что позволяет вызывать статические методы, такие как compileComponents. Этот метод асинхронно компилирует все компоненты модуля, переводит шаблон и стили в “inline”. После этого мы можем в синхронном режиме получать экземпляры компонента с помощью метода TestBed.createComponent(). Этот метод возвращает экземпляр ComponentFixture, который дает доступ непосредственно к компоненту и его DOM через DebugElement.
Разработчики акцентируют внимание на том, что не стоит использовать реальные сервисы и нужно в тесты добавлять «заглушки».
В данном случае я использую объект, который повторяет поведение SomeService, то есть содержит метод getName. Чтобы использовать его вместо реального сервиса нужно добавить в массив providers объект со свойством provide, указав в качестве значения сервис и useValue со значением «заглушки».
Так как мы используем API на базе swagger, необходимости в написании сервисов для общения с сервером нет. Нам остается лишь вызывать методы и обрабатывать результат. И тут мы можем себе позволить отказаться от реализации методов и получения сервиса через injector.
Делается это за счет использования MockBackend. Вкратце, мы подменяем не сервис, а сам бэкенд.
Мы имеем компонент, который должен взять с формы логин, пароль, отправить на некий url и либо перейти по ссылке 'home/main', либо обработать и отобразить ошибку.
Чтобы не использовать заглушку для сервиса и не реализовывать её методы, мы используем MockBackend, который будет обрабатывать запросы и возвращать предопределенные ответы. Для этого мы сначала объявляем MockBackend, указываем url, который ожидаем и определяем данные, которые должны быть в запросе:
Далее, определяем, как будут выглядеть ответы. Предположим, что успешный запрос вернет пустой ответ, а ошибочный — ответ со статусом 401 и именем ошибки.
Далее, после конфигурации тестового модуля мы получаем MockBackend и подписываемся на соединения. Мы ожидаем метод POST и соответствие url запроса тому, что мы указали выше. Также, проверяем, чтобы тело запроса соответствовало ожидаемому.
Всё, что остается сделать в тесте — это получить router (так как у нас при успешном запросе происходит переход), заполнить данные для входа и проверить, что переход был выполнен:
Как известно, компоненты могут обмениваться данными через свойства с декораторами @Input и @Output. Например, вот так:
Для того, чтобы тест Parent component корректно запустился, необходимо объявить child-component в массиве declarations.
А для Child component необходимо передать входные параметры. Самый просто способ – вручную указать их значения после создания компонента:
Организация файлов
Для начала, структура проекта, сформированного angular-cli. За ненадобностью, часть файлов была отфильтрована.
Unit-тестирование в Angular базируется на Jasmine, утилитах самого Angular и Karma. Описание данных пакетов приведено на официальном сайте, поэтому не буду его дублировать. Вкратце, Jasmine – фреймворк, предоставляющий необходимую функциональность для базовых тестов, утилиты Angular позволяют использовать тестовое окружение, Karma служит для запуска тестов.
Сами файлы тестов именуются с добавлением spec перед расширением. По такому шаблону: *.spec.ts Karma будет их находить. Единого мнения насчет расположения тестов нет. Допускается расположение «поближе» к объекту тестирования и отдельный каталог для всех тестов приложения. У команды Angular приведены плюсы расположения тестов рядом с объектом:
- Такие тесты проще найти.
- Вы сразу видите, что у части вашего приложения недостает теста.
- Когда вы перемещает исходный код сущности, вы не забудете переместить и тест.
- Когда вы переименовываете исходный код сущности, вы не забудете переименовать и тест.
Я остановил выбор на варианте, предложенном angular-cli – тесты располагаются рядом с объектом.
Настройка Karma
Точкой входа для тестирования приложения Angular является файл src/test.ts, который импортирует все необходимые пакеты, собирается файлы тестов по шаблону *.spec.ts и запускает систему исполнения тестов, в нашем случае – Karma. Сам файл после создания проекта выглядит так:
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare const __karma__: any;
declare const require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();
Здесь хочу обратить внимание на проблему, с которой успел столкнуться. Порядок импортов важен и менять его не стоит, иначе можно будет встретить ошибки, корни которых крайне неявны. Поэтому рекомендую добавлять сторонние пакеты в конец списка импортов и добавить в начало файла флаг /* tslint:disable */ для отключение линтера, либо исключить этот файл в его конфигурации, потому что tslint может посчитать существующий порядок неверным и исправить.
Перед написанием тестов необходимо настроить Karma, которая будет их запускать. Начальная конфигурация выглядит следующим образом:
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular/cli'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular/cli/plugins/karma'),
require('karma-mocha-reporter'),
],
client:{
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true
},
angularCli: {
environment: 'dev'
},
reporters: ['mocha'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};
Так как описание всех возможных настроек Karma – отдельная тема для обсуждения, остановлюсь только на тех, которые используем мы:
- browsers: список браузеров для запуска. Так как используется полноценный Chrome, я предпочитаю использовать ChromeHeadless, который меньше нагружает память. Но стоит помнить, что для его использования необходим Chrome >= 59.
- logLevel: я использую LOG_ERROR и LOG_DEBUG в случае ошибок вида “Script error” для получения более подроного лога. Всего допустимых значений 5:
- config.LOG_DISABLE
- config.LOG_ERROR
- config.LOG_WARN
- config.LOG_INFO
- config.LOG_DEBUG
- reporters: массив, отвечающий за вывод сообщений и результатов. На просторах npmjs можно найти практический любое оформление вывода, для себя я определил следующий набор: karma-mocha-reporter для консоли, istanbul-threshold – для вывода в браузере. Лог, предоставляемый mocha достаточно информативен для консоли, Istanbul нас устроил выводом в браузер (я использую полноценный запуск браузера вместе с logLevel: config.LOG_DEBUG.
- Plugins. Как видно из названия – это массив плагинов, которые используются karma. Здесь указываются репортеры, браузеры, плагины для отображения и расчет покрытия кода. Список доступных плагинов можно увидеть тут (https://www.npmjs.com/browse/keyword/karma-plugin).
- В стандартной конфигурации отсутствует параметр files, список значения которого будут загружены Karma в процессе тестирования. Мы используем этот список для того, чтобы добавить стандартную тему material и убрать предупреждения об её отсутствии.
Применение следующее:
files: [{ pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', included: true, watched: true }],
Параметр included включает файл в сборку, watched – отслеживает изменения в нем.
- Также, мы используем параметр preprocessors для отключения проверки покрытия кода API:
preprocessors: { '!(./src/api)': ['coverage'] },
Итак, подготовительный этап закончен, можно запускать тесты. Для проекта, сформированного с помощью angular-cli, используется команда ng test. У неё есть несколько параметров, среди которых я бы хотел отметить --sourcemap. Исходя из названия, этот параметр включает sourcemaps в тесты. Но с этим связана ошибка ‘XMLHttpRequest: failed to load’. Выставление false для этого параметра решает данную проблему.
Тестирование компонента
Первая и самая важная утилита тестирования компонента Angular – TestBed. Её идея заключается в создании отдельного модуля, который идентичен модулю компонента, но создается в тестовом окружении и изолирован от приложения.
Для создания TestBed вызывается метод configureTestingModule, которому передается объект метаданных. Этот метод следует вызывать асинхронно перед каждым тестом, чтобы компонент находился в исходном состоянии. Для этого есть метод beforeEach(async() => {});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
}));
Как видно из примера, мы объявляем массив компонентов для тестирования (в данном случае только один). В дальнейшем возможно добавить еще компонентов, импортировать необходимые для работы модули, директивы, пайпы. Метод configureTestingModule возвращает класс TestBed, что позволяет вызывать статические методы, такие как compileComponents. Этот метод асинхронно компилирует все компоненты модуля, переводит шаблон и стили в “inline”. После этого мы можем в синхронном режиме получать экземпляры компонента с помощью метода TestBed.createComponent(). Этот метод возвращает экземпляр ComponentFixture, который дает доступ непосредственно к компоненту и его DOM через DebugElement.
Итоговый файл самого простого теста компонента
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
it(`should have as title 'app'`, async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('app');
}));
it('should render title in a h1 tag', async(() => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!');
}));
});
Код компонента
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'app';
}
Шаблон компонента
<div style="text-align:center">
<h1>
Welcome to {{title}}!
</h1>
</div>
Тестирование компонента с зависимостью
Разработчики акцентируют внимание на том, что не стоит использовать реальные сервисы и нужно в тесты добавлять «заглушки».
Код компонента
import {Component} from '@angular/core';
import {SomeServiceService} from "./some-service.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'app';
name: string;
constructor(private someService: SomeServiceService) {
this.name = this.someService.getName();
}
}
Код теста:
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { SomeServiceService } from "./some-service.service";
const SomeServiceStub = {
getName: () => 'some name'
};
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
],
providers: [
// Обозначаем сервис
{ provide: SomeServiceService, useValue: SomeServiceStub }
]
}).compileComponents();
}));
it('token should test component with dependency', () => {
//Получаем сервис от root-injector
// Если этот сервис используется в нескольких тестах, имеет смысл вынести его получение в метод beforeEach
const someService = TestBed.get(SomeServiceService);
expect(someService.getName()).toEqual('some name');
});
//Сработает аналогично, так как функция inject, которая оборачивает тест, использует тот же root-injector.
//Преимущество в ясности, какой тест какой сервис использует
it('token should test component with dependency', inject(([SomeServiceService], (someService: SomeServiceService)) => {
const someService = TestBed.get(SomeServiceService);
expect(someService.getName()).toEqual('some name');
}));
});
В данном случае я использую объект, который повторяет поведение SomeService, то есть содержит метод getName. Чтобы использовать его вместо реального сервиса нужно добавить в массив providers объект со свойством provide, указав в качестве значения сервис и useValue со значением «заглушки».
Так как мы используем API на базе swagger, необходимости в написании сервисов для общения с сервером нет. Нам остается лишь вызывать методы и обрабатывать результат. И тут мы можем себе позволить отказаться от реализации методов и получения сервиса через injector.
Делается это за счет использования MockBackend. Вкратце, мы подменяем не сервис, а сам бэкенд.
Код компонента
@Component({
selector: 'app-login',
templateUrl: 'login.component.html',
styleUrls: ['login.component.less']
})
export class LoginComponent implements OnInit {
loading: boolean;
loginForm: FormGroup;
error: any;
constructor(private router: Router,
private authService: UserService,
private fb: FormBuilder,
private ref: ChangeDetectorRef) {
}
ngOnInit() {
this.loading = false;
this.loginForm = this.fb.group({
login: [''],
password: ['']
});
}
get login() {
return this.loginForm.get('login');
}
get password() {
return this.loginForm.get('password');
}
submit() {
this.loading = true;
this.submitted = true;
// Получаем данные с формы
const data = {
'login': this.loginForm.controls['login'].value,
'password': this.loginForm.controls['password'].value
};
// Выполняем запрос авторизации
// В случае успеха - переходим по адресу /home/main
// Если вернулась ошибка с кодом 400 или 401 - записываем её текст
this.authService.signIn(data).subscribe(
resp => this.router.navigateByUrl('/home/main'),
error => {
this.loading = false;
error.status === 401 || error.status === 400 ?
this.error = {errorText: error.title} :
'';
this.ref.detectChanges();
}
);
}
}
Код теста
class MockError extends Response implements Error {
name: any;
message: any;
}
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
let mockBackend: MockBackend;
const authUrl = 'login_url';
const testUser = {
login: 'test',
password: 'test'
};
const successResponse = new Response(
new ResponseOptions({
status: 200,
body: ''
})
);
const errorResponse = new MockError(
new ResponseOptions({
type: ResponseType.Error,
status: 401,
body: JSON.stringify({
status: 401,
title: 'Unauthorized',
}
)
})
);
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [LoginComponent],
imports: [
FormsModule,
HttpModule,
RouterTestingModule.withRoutes([]),
],
providers: [
AuthService,
{provide: XHRBackend, useClass: MockBackend}
],
}).compileComponents();
mockBackend = TestBed.get(XHRBackend);
mockBackend.connections.subscribe((connection: MockConnection) => {
if (connection.request.method === RequestMethod.Post) {
expect(connection.request.url).toEqual(authUrl);
(connection.request.getBody() === JSON.stringify(testUser)) ?
connection.mockRespond(successResponse) :
connection.mockError(errorResponse);
} else {
connection.mockError(errorResponse);
}
});
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should login correctly',
inject([Router], (router: Router) => {
const spy = spyOn(router, 'navigateByUrl');
const login = component.loginForm.controls['login'];
const password = component.loginForm.controls['password'];
login.setValue('test');
password.setValue('test');
fixture.detectChanges();
component.submit();
fixture.detectChanges();
const navArgs = spy.calls.first().args[0];
expect(navArgs).toEqual('/home/main');
}));
it('should fail login', () => {
const login = component.loginForm.controls['login'];
const password = component.loginForm.controls['password'];
const errorResponse = {
errorText: 'Unauthorized'
};
login.setValue('testad');
password.setValue('testad');
fixture.detectChanges();
component.submit();
fixture.detectChanges();
expect(component.error).toEqual(errorResponse);
});
});
Мы имеем компонент, который должен взять с формы логин, пароль, отправить на некий url и либо перейти по ссылке 'home/main', либо обработать и отобразить ошибку.
Чтобы не использовать заглушку для сервиса и не реализовывать её методы, мы используем MockBackend, который будет обрабатывать запросы и возвращать предопределенные ответы. Для этого мы сначала объявляем MockBackend, указываем url, который ожидаем и определяем данные, которые должны быть в запросе:
let mockBackend: MockBackend;
const authUrl = 'login_url';
const testUser = {
login: 'test',
password: 'test'
};
Далее, определяем, как будут выглядеть ответы. Предположим, что успешный запрос вернет пустой ответ, а ошибочный — ответ со статусом 401 и именем ошибки.
const successResponse = new Response(
new ResponseOptions({
status: 200,
body: ''
})
);
const errorResponse = new MockError(
new ResponseOptions({
type: ResponseType.Error,
status: 401,
body: JSON.stringify({
status: 401,
title: 'Unauthorized',
}
)
})
);
Далее, после конфигурации тестового модуля мы получаем MockBackend и подписываемся на соединения. Мы ожидаем метод POST и соответствие url запроса тому, что мы указали выше. Также, проверяем, чтобы тело запроса соответствовало ожидаемому.
mockBackend = TestBed.get(XHRBackend);
mockBackend.connections.subscribe((connection: MockConnection) => {
if (connection.request.method === RequestMethod.Post) {
expect(connection.request.url).toEqual(authUrl);
(connection.request.getBody() === JSON.stringify(testUser)) ?
connection.mockRespond(successResponse) :
connection.mockError(errorResponse);
} else {
connection.mockError(errorResponse);
}
});
Всё, что остается сделать в тесте — это получить router (так как у нас при успешном запросе происходит переход), заполнить данные для входа и проверить, что переход был выполнен:
it('should login correctly', inject([Router], (router: Router) => {
const spy = spyOn(router, 'navigateByUrl');
const login = component.loginForm.controls['login'];
const password = component.loginForm.controls['password'];
login.setValue('test');
password.setValue('test');
fixture.detectChanges();
component.submit();
fixture.detectChanges();
const navArgs = spy.calls.first().args[0];
expect(navArgs).toEqual('/home/main');
}));
Тестирование компонента с входными и выходными данными
Как известно, компоненты могут обмениваться данными через свойства с декораторами @Input и @Output. Например, вот так:
Parent Component:
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
template: '<child-comp [userName]="name" [userSurname]="surname"></child-comp>'
})
export class ParentComponent {
name = 'Some name';
surname = 'Some surname';
}
ChildComponent:
import { Input, Component} from '@angular/core';
@Component({
selector: 'child-comp',
template: `<p>Имя пользователя: {{userName}}</p>
<p>Фамилия пользователя: {{userSurname}}</p>`
})
export class ChildComponent{
@Input() userName: string;
@Input() userSurname:string;
}
Для того, чтобы тест Parent component корректно запустился, необходимо объявить child-component в массиве declarations.
parent.component.spec.ts
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {ParentComponent} from './parent.component';
import {ChildComponent} from '../child/child.component';
describe('ParentComponent', () => {
let component: ParentComponent;
let fixture: ComponentFixture<ParentComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ParentComponent, ChildComponent]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
А для Child component необходимо передать входные параметры. Самый просто способ – вручную указать их значения после создания компонента:
child.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChildComponent } from './child.component';
describe('ChildComponent', () => {
let component: ChildComponent;
let fixture: ComponentFixture<ChildComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ ChildComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ChildComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
component.userName = 'some name';
expect(component.userName).toEqual('some name');
});
});