Unit-тести для Angular

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. Ця установка дозволяє ангуляр ігнорувати всі невідомі теги при тестуванні компонента.

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.

Тестування DOM
//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+