Unit-тести для Angular

Unit-тестування – це важлива частина процесу розробки на поточний момент і вона стає невід’ємною частиною. Це підвищує якість коду і впевненість розробників. У даній статті розповідається про те, як створювати unit-тести для Angular, що використовувати і чому.
Для написання unit-тестів використовується фреймворк Jasmine, а виконуються тести в Karma. Karma – це JavaScript движок для запуску тестів, які виконуються у браузері.
Є дві основні концепції тестування TDD і BDD:
- TDD (test driven development) – розробка на основі тестів. Спочатку пишемо тести, а потім під них пишемо код. Концепція орієнтована на перевірку кожного блоку коду, написаного розробником. Саме для цього і використовуються unit-тести.
- BDD (behavior driven development) – розробка на основі поведінки. Це відгалуження від TDD. Концепція орієнтована на перевірку бізнес-сценаріїв, реалізованих в коді.
Фреймворк Jasmine прекрасно підходить і для TDD, і для BDD.
Особливості unit-тестів
- Кожен тест повинен виконуватися дуже швидко
- Кожен тест повинен бути ізольований від всіх залежностей
- Тест повинен мати чітко визначений результат виконання для кожного набору вхідних даних
- Найменший блок коду (unit) може бути перевірений окремим тестом
Коли ми пишемо unit-тести
- На самому початку або в кінці розробки
- Якщо ми додали нову фічу в коді, ми повинні покрити її роботу тестом
- Якщо ми змінили існуючу фичу в коді, ми також повинні змінити / додати для неї тест
Чому варто використовувати Jasmine
- За замовчуванням інтегрована з Karma
- Надає зручний функціонал, наприклад, Spy, fake
- Легко інтегрується зі звітністю
Чому варто використовувати Karma
- Angular CLI вміє працювати з Karma з коробки.
- Дозволяє автоматизувати тести для різних браузерів і девайсів.
- Стежить за змінами файлів без ручного перезапуску
- Добре задокументована
- Легко інтегрується з CI-серверами
Приклад проекту
Для запуску тестування на Angular не потрібно нічого налаштовувати. Завдяки Angular CLI всі налаштування (Jasmine і Karma) створюються автоматично.
// створюємо новий Angular проект ng new angular-unit-testing // запускаємо тестування npm test
І ви відразу ж побачите результат в браузері:

Для тестування різних особливостей Angular був створенний приклад проекту. Це простий to-do список. Нижче скріншот і посилання на проект.

Конфігурація тестів в Angular
Коли ми запускаємо npm test або ng test, Angular використовує настройки з файлу angular.json. Зверніть увагу на test-параметр цього файлу.
Ми вказали test.ts як основний файл для запуску і karma.conf.js як конфігурацію для Karma.
//angular.json
" Test " : {
" Builder " : " @ angular-devkit / build-angular: karma " ,
" Options " : {
" Main " : " src / test.ts " ,
" Polyfills " : " src / polyfills.ts " ,
" TsConfig " : " src / tsconfig.spec.json " ,
" KarmaConfig " : " src / karma.conf.js " ,
" Styles " : [
" Src / styles.css "
],
" Scripts " : [],
" Assets " : [
" Src / favicon.ico " ,
" Src / assets "
]
}
},
У файлі test.ts ми завантажуємо всі файли проекту з розширенням .spec.ts для тестування.
//test.ts // Then we find all the tests. const context = require . context ( './' , true , / \. spec \. ts $ / ) ; // And load the modules. context . keys ( ) . map ( context ) ;
А в файлі karma.conf.js ми визначаємо настройки звітів, порт, типи браузерів для тестування і тп.
//karma.conf.js
module . exports = function ( config ) {
config . set ( {
basePath : '' ,
frameworks : [ 'jasmine' , '@ angular-devkit / build-angular' ] ,
plugins : [
require ( 'karma-jasmine' ) ,
require ( 'karma-chrome-launcher' ) ,
require ( 'karma-jasmine-html-reporter' ) ,
require ( 'karma-coverage-istanbul-reporter' ) ,
require ( '@ angular-devkit / build-angular / plugins / karma' )
] ,
client : {
clearContext : false // leave Jasmine Spec Runner output visible in browser
} ,
coverageIstanbulReporter : {
dir : require ( 'path' ) . join ( __dirname , '../coverage/todoApp' ) ,
reports : [ 'html' , 'lcovonly' , 'text-summary' ] ,
fixWebpackSourcePaths : true
} ,
reporters : [ 'progress' , 'kjhtml' ] ,
port : 9876 ,
colors : true ,
logLevel : config . LOG_INFO ,
autoWatch : true ,
browsers : [ 'Chrome' ] ,
singleRun : false ,
restartOnFileChange : true
} ) ;
} ;
Основні функції
TestBed
Це базовий блок для створення тестового модуля. TestBed створює динамічний тестовий модуль, який емулює роботу NgModule. Вхідні дані для TestBed.configureTestingModule() і @NgModule абсолютно однакові. І саме TestBed.configureTestingModule() є конфігурацією тестового файлу.
//sample.spec.ts
describe ( 'AppComponent' , ( ) => {
let appComponent : AppComponent ;
beforeEach ( async ( ( ) => {
TestBed . configureTestingModule ( {
imports : [
RouterTestingModule
] ,
declarations : [
AppComponent
] ,
schemas : [ NO_ERRORS_SCHEMA ]
} ) . compileComponents ( ) ;
} ) ) ;
} ) ;
Конфігурація завжди розміщується у beforeEach() функції, яка запускається перед кожним тестом.
compileComponents()
У цьому ж файлі ми використовуємо compileComponents() метод. Якщо ви створюєте компонент, який використовує зовнішні файли для визначення styleUrls, templateUrl, тоді ви повинні отримати ці файли з файлової системи до того, як компонент буде створений. Це асинхронна операція і тому ми обертаємо конфігурацію в асинхронну функцію.
Фактично відбувається наступне: при запуску тестування підтягується зовнішній файл, а потім починається компіляція компонента. Якщо ви запускаєте тести з Angular CLI, тоді асинхронна функція не потрібна. Оскільки CLI компілює весь код до запуску тестів.
Чому дві beforeEach() функції
Якщо ви подивіться на файл нижче, то побачите, що ми використовуємо дві beforeEach() функції. Оскільки compileComponents() запускається асинхронно, ми хочемо бути впевнені, що компонент буде скомпільовано до того, як ми створимо fixture (надає доступ до примірника компонента). Друга beforeEach() запускається після першої.
//app.component.spec.ts
describe ( 'AppComponent' , ( ) => {
let appComponent : AppComponent ;
beforeEach ( async ( ( ) => {
TestBed . configureTestingModule ( {
imports : [
RouterTestingModule
] ,
declarations : [
AppComponent
] ,
schemas : [ NO_ERRORS_SCHEMA ]
} ) . compileComponents ( ) ;
} ) ) ;
beforeEach ( ( ) => {
const fixture = TestBed . createComponent ( AppComponent ) ;
appComponent = fixture . debugElement . componentInstance ;
appComponent . itemList = [ { id : 1 , name : 'todolist' , description : 'This is todo list app' } ,
{ Id : 2 , name : 'unit test' , description : 'Write unit tests' } ] ;
} ) ;
it ( 'should create the app' , ( ) => {
expect ( appComponent ) . toBeTruthy ( ) ;
} ) ;
} ) ;
Навіщо використовувати NO_ERRORS_SCHEMA
Давайте виберемо app.component.ts компонент з проекту і протестуємо його. Подивіться на spec файл, який Angular CLI згенерував для нас, коли ми виконали команду
ng nc
Була додана f (рядок 5) до функції describe(), щоб виконувати тільки цей файл під час тестування. Це називається Focused Tests.
//app.component.spec.ts
import { TestBed , async } from '@ angular / core / testing' ;
import { RouterTestingModule } from '@ angular / router / testing' ;
import { AppComponent } from './app.component' ;
fdescribe ( 'AppComponent' , ( ) => {
beforeEach ( async ( ( ) => {
TestBed . configureTestingModule ( {
imports : [
RouterTestingModule
] ,
declarations : [
AppComponent
] ,
} ) . compileComponents ( ) ;
} ) ) ;
it ( 'should create the app' , ( ) => {
const fixture = TestBed . createComponent ( AppComponent ) ;
const app = fixture . debugElement . componentInstance ;
expect ( app ) . toBeTruthy ( ) ;
} ) ;
} ) ;
Запустивши npm test, ми побачимо багато помилок через вкладеності компонентів, тому що основний компонент містить інші компоненти, такі як header, footer. Ми повинні оголосити їх в app.component.spec.ts в масиві declarations (рядок 11).

Інший спосіб – використання NO_ERRORS_SCHEMA. Ця установка дозволяє ангуляр ігнорувати всі невідомі теги при тестуванні компонента.

Tестування компонентів
У нашому компоненті є метод deleteItem(). Давайте його протестуємо.
//app.component.spec.ts
import { TestBed , async } from '@ angular / core / testing' ;
import { RouterTestingModule } from '@ angular / router / testing' ;
import { AppComponent } from './app.component' ;
import { NO_ERRORS_SCHEMA } from '@ angular / core' ;
fdescribe ( 'AppComponent' , ( ) => {
let appComponent : AppComponent ;
beforeEach ( async ( ( ) => {
TestBed . configureTestingModule ( {
imports : [
RouterTestingModule
] ,
declarations : [
AppComponent
] ,
schemas : [ NO_ERRORS_SCHEMA ]
} ) . compileComponents ( ) ;
} ) ) ;
beforeEach ( ( ) => {
const fixture = TestBed . createComponent ( AppComponent ) ;
appComponent = fixture . debugElement . componentInstance ;
appComponent . itemList = [ { id : 1 , name : 'todolist' , description : 'This is todo list app' } ,
{ Id : 2 , name : 'unit test' , description : 'Write unit tests' } ] ;
} ) ;
it ( 'should create the app' , ( ) => {
expect ( appComponent ) . toBeTruthy ( ) ;
} ) ;
it ( 'should delete an item with existing item' , ( ) => {
// given this item
const item = { id : 1 , name : 'todolist' , description : 'This is todo list app' } ;
// execute the test case
appComponent . deleteItem ( item ) ;
// assertion
expect ( appComponent . itemList . length ) . toBe ( 1 ) ;
} ) ;
it ( 'should not delete an item with new item' , ( ) => {
// given this item
const item = { id : 3 , name : 'todolist' , description : 'This is todo list app' } ;
// execute the test case
appComponent . deleteItem ( item ) ;
// assertion
expect ( appComponent . itemList . length ) . toBe ( 2 ) ;
} ) ;
} ) ;
//app.component.ts
deleteItem ( item : any ) {
console . log ( item ) ;
this . itemList = this . itemList . filter ( ( itm : any ) => itm . id ! == item . id ) ;
}
Тестування DOM
Давайте протестуємо footer компонент, в якому у нас є footerText і numberOfItems Змінимо їх в компоненті і перевіримо шаблон footer.

//footer.component.spec.ts
import { async , ComponentFixture , TestBed } from '@ angular / core / testing' ;
import { DebugElement } from '@ angular / core' ;
import { By } from '@ angular / platform-browser' ;
import { FooterComponent } from './footer.component' ;
fdescribe ( 'FooterComponent' , ( ) => {
let component : FooterComponent ;
let fixture : ComponentFixture < FooterComponent > ;
beforeEach ( async ( ( ) => {
TestBed . configureTestingModule ( {
declarations : [ FooterComponent ]
} )
. compileComponents ( ) ;
} ) ) ;
beforeEach ( ( ) => {
fixture = TestBed . createComponent ( FooterComponent ) ;
component = fixture . componentInstance ;
fixture . detectChanges ( ) ;
} ) ;
it ( 'should create' , ( ) => {
expect ( component ) . toBeTruthy ( ) ;
} ) ;
it ( 'Shoud display footerText as Unit Testing' , ( ) => {
component . footerText = 'Unit Testing' ;
fixture . detectChanges ( ) ;
const bannerDe : DebugElement = fixture . debugElement ;
const headingDe = bannerDe . query ( By . css ( 'h2' ) ) ;
const h2 : HTMLElement = headingDe . nativeElement ;
expect ( h2 . textContent ) . toEqual ( 'Unit Testing' ) ;
} ) ;
it ( 'Shoud display numberOfItems as 10' , ( ) => {
component . numberOfItems = 10 ;
fixture . detectChanges ( ) ;
const bannerDe : DebugElement = fixture . debugElement ;
const headingDe = bannerDe . query ( By . css ( 'h1' ) ) ;
const h1 : HTMLElement = headingDe . nativeElement ;
expect ( h1 . textContent ) . toContain ( '10' ) ;
} ) ;
} ) ;
Зверніть увагу на fixture.detectChanges(). Ми змінили footerText і numberOfItems в тесті і викликали механізм виявлення змін ангуляр для того, щоб DOM був оновлений відповідно до змін до запуску тесту.
Нижче результат тесту в браузері. Оскільки footer є автономним компонентом, ми бачимо результат рендеринга в Karma браузері.

Тестування pipe
Тестувати Angular pipe дуже просто, оскільки вони не мають залежностей. Є pipe, який показує поточний рік після тексту в footer. Давайте його протестуємо:
//copyright.pipe.spec.ts
import { CopyrightPipe } from './copyright.pipe' ;
fdescribe ( 'CopyrightPipe' , ( ) => {
it ( 'create an instance' , ( ) => {
const pipe = new CopyrightPipe ( ) ;
expect ( pipe ) . toBeTruthy ( ) ;
} ) ;
it ( 'should display current year at the end' , ( ) => {
const pipe = new CopyrightPipe ( ) ;
const result = pipe . transform ( 'Pipe Testing' ) ;
expect ( result ) . toBe ( 'Pipe Testing' + new Date ( ) . getFullYear ( ) ) ;
} ) ;
} ) ;
Тестування сервісів
Сервіси теж досить просто тестувати. У нас є один сервіс в додатку. Давайте протестуємо метод addItem() в сервісі:
//app.service.spec.ts
import { TestBed } from '@ angular / core / testing' ;
import { AppService } from './app.service' ;
fdescribe ( 'AppService' , ( ) => {
beforeEach ( ( ) => TestBed . configureTestingModule ( { } ) ) ;
let service : AppService ;
beforeEach ( ( ) => {
service = TestBed . get ( AppService ) ;
service . totalItems = [ { id : 1 , name : 'todolist' , description : 'This is todo list app' } ] ;
} ) ;
it ( 'should be created' , ( ) => {
expect ( service ) . toBeTruthy ( ) ;
} ) ;
it ( 'should add item to total items' , ( ) => {
const item = { id : 2 , name : 'seconditem' , description : 'This is second item' } ;
service . addItems ( item ) ;
expect ( service . totalItems . length ) . toBe ( 2 ) ;
} ) ;
} ) ;
Покриття коду тестами
Покриття коду показує як багато відсотків коду покрито unit-тестами. Зазвичай хорошим показником є 80% покриття.
Angular CLI дозволяє згенерувати звіт про покриття додатки тестами в окремій директорії, званої coverage. Щоб згенерувати звіт потрібно запустити тести з прапором – code-coverage.
ng test - no-watch - code-coverage

Якщо ви запустите index.html з цієї папки, то побачите повний звіт.

Практичні поради
- Переконайтеся, що тест запускається в ізольованому оточенні, без зовнішніх залежностей. Тоді він буде виконуватися швидко.
- Переконайтеся, що ваш додаток покрито як мінімум на 80% коду тестами.
- Коли тестируете сервіси, завжди використовуйте
spyз Jasmine фреймворка для залежностей. Це дозволить виконувати тести набагато швидше. - Коли ви підписуєтеся на
Observableпід час тесту, переконайтеся, що ви обробляєте іsuccess, іfailureрезультат. Це позбавить вас від непотрібних помилок. - Коли тестируете компонент з сервіс-залежностями, завжди використовуйте
mockсервіс замість реального сервісу. - Усі зміни
TestBedConfigurationповинні бути вbeforeEach, щоб не дублювати однаковий код для кожного тесту. - Коли тестируете компоненти, завжди звертайтеся до DOM через
debugElementзамістьnativeElement. Тому щоdebugElementнадає абстрактний шар для середовища виконання. Це зменшує кількість помилок. - Використовуйте
By.cssзамістьqueryselector, коли запускаєте тестування на сервері. Справа в тому, щоqueryselectorпрацює тільки в браузері. Якщо ви запустите тестування на сервері, то воно впаде. - Завжди використовуйте
fixture.detectChanges()замістьComponentFixtureAutoDetect.ComponentFixtureAutoDetectне знає про синхронний оновленні компонента. - Викликайте
compileComponents(), якщо ви запускаєте тестування не в CLI середовищі. - Використовуйте
RXJS marbleтестування завжди, коли тестируетеobservable. - Використовуйте
PageObjectмодель для перевикористання функцій між компонентами. - Не зловживайте
NO_ERRORS_SCHEMA. Ця установка дозволяє ангуляр ігнорувати атрибути і нерозпізнані теги. Використовуйте заглушки компонентів. - Ніколи не виконуйте ніяких налаштувань після виклику
compileComponents().compileComponents()є асинхронним і повинен виконуватися вasyncфункції всерединіbeforeEach. Наступні настройки виконуйте в наступному синхронномуbeforeEach.
Висновок
Використовувати unit-тести в Angular додатку дуже зручно, якщо ви розумієте базові принципи тестування Angular, Jasmine і Karma.
Переклад статті Unit-тесты для приложения на Angular 2+