Мій підсумковий аркуш модульних тестів у Angular

Для тих із вас, хто мав можливість подивитися на фреймворк Angular (V2 і більше), ви бачили, як документація на офіційному сайті може бути об’ємною, особливо на частина модульних тестів. Метою цієї статті є надати вам підсумковий аркуш найкращих практик модульного тестування в Angular.
У статті передбачається, що ви вже використовували Angular та його модульні тести, а також знаєте лексичне поле модульних тестів.
Наведені тут поради та рішення походять із шестимісячного досвіду розробки Angular з командою з п’яти чоловік. Звичайно, вони підлягають удосконаленню/обговоренню.

Преамбула: як правильно «знущатися» з Angular?

Невелика преамбула необхідна, щоб прояснити момент, який для мене занадто часто забувається в літературі на цю тему і який буде життєво важливим для решти розуміння:
Насмішки, шпигуни та інші заглушки.
Занадто часто дещо наївний підхід пропонується в прикладах модульних тестів Angular. Давай візьмемо справа з тесту компонента в офіційному документі:

class MockUserService {
  isLoggedIn = true;
  user = { name: "Test User" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Цей код пояснює, що для висміювання реалізації служби (тут UserService) просто створіть підроблений клас, який замінить цю службу (тут MockUserService).
Цей підхід мене в основному дратує, тому що ми втрачаємо всю перевірку типів. Насправді, гарантії на це немає MockUserService поважає інтерфейс класу UserService. До цього додається вартість написання фальшивого класу...
[bctt tweet=”В Angular створення макету вручну не є ні безпечним, ні ефективним підходом.”] Інший спосіб зробити це, який можна побачити тут і там, - це розширити клас, який ми хочемо потім висміювати, перевантажувати бажану поведінку. Якщо ми повернемося до нашого попереднього прикладу, це буде виглядати так:

class MockUserService extends UserService {
  user = { name: "Another user" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      FirstLevelNeededService,
      SecondLevelNeededService,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Цей метод ще гірший за попередній, тому що, незважаючи на те, що ви вирішили проблему введення тексту та багатослівності, ви порушили ізоляцію, необхідну для модульного тесту.
Як і реальна реалізація UserService імпортується, TestBed вимагатиме, щоб ми також надали йому всі класи, на яких UserService. Уявіть схему залежностей нижче:

+--------------+ +---------------------+ +---- - ---------------------+ | | | | | | | UserService +<---+ FirstLevelNeededService +<----+ SecondLevelNeededService | | | | | | | +--------------+ +---------------------+ +---- - ---------------------+

Дотримуючись цієї схеми, ми повинні забезпечити TestBed, FirstLevelNeededService et SecondLevelNeededService. Це не має сенсу, коли ви думаєте про це, тому що мета макету — імітувати поведінку класу. То навіщо надавати імітаційні залежності?
[bctt tweet=”Ніколи не створюйте імітацію класу TypeScript, розширюючи його початкову реалізацію в Angular.”] Інакше вам доведеться надати велику кількість класів для кожного тестового набору.

Використовуйте фіктивну бібліотеку

Для об’єктно-орієнтованих розробників використання макетної бібліотеки є очевидним у контексті модульного тестування, оскільки їхня мова часто не така динамічна, як JavaScript, і не дозволяє писати Mocks на льоту («на льоту»). snatch?» - Хто сказав, що?).
Бібліотека Mock використовуватиме визначення типу, створене за допомогою TypeScript, щоб створити макет класу.
Старий рефлекс «Javaiste», мабуть, мені особисто подобається ts mockito як книгопродавець Мока, але є й інші: ТС-макет або Typemoq
Якщо ми візьмемо наш приклад з ts-mockito, наш код буде виглядати так:

import { instance, mock, when } from "ts-mockito";
/***/
let userServiceMock: UserService;
beforeEach(() => {
  userServiceMock = mock(UserService);
  when(userServiceMock.user).thenReturn({ name: "A user" });
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useValue: instance(userServiceMock) }
    ]
  });
  userService = TestBed.get(UserService);
});

Просто, чи не так? ts-mockito спочатку створить висміяний клас за допомогою функції mock потім створіть імітаційні екземпляри на основі цього класу за допомогою функції instance.
Для моделювання результатів ми часто використовуємо функцію when. Завжди використовуйте when на висміюваний клас і дзвоніть instance лише потім, інакше ваш імітаційний екземпляр не матиме визначеної поведінки.

В решті статті ми будемо систематично використовувати ts-mockito

Керівництво

Випадок № 1: тестування служби без залежності за допомогою Angular

Почнемо з найпростішого, сервісу. У Angular служба є ні більше, ні менше, ніж клас TypeScript, який буде створено за допомогою МОК-контейнер Angular і введений у всі інші елементи, які визначать його як залежності.
Якщо служба не використовує залежність, що надходить від Angular (наприклад, файл HttpClient наприклад) ви можете протестувати його як будь-який клас TypeScript у будь-якому проекті, як це:

import { MyService } from "./my.service";
import { instance, mock, when } from "ts-mockito";
import { MathLib } from "./my.service";
describe("MyService", () => {
  it("should add correctly two positive integers and multiply them by two", () => {
    // given
    const mathMock = mock(MathLib);
    when(mathMock.add(1, 2)).thenReturn(3);
    when(mathMock.multiply(3)).thenReturn(6);
    const myService = new MyService(instance(mathMock));
    // when
    const result = myService.addAndMultiplyByTwo(1, 2);
    // then
    expect(result).toBe(6);
  });
});

Перший пункт, ви помітите, що всупереч кутовий-cli ми не створюємо тест should be created. Цей тест, який буде використаний для перевірки існування класу, де-факто вже перевірений іншими модульними тестами, тому марний.
Другий і потенційно найважливіший пункт цієї статті:
[bctt tweet=”Використовуйте `TestBed` лише рідко в модульному тестуванні Angular.”] TestBed, точніше виклик його методу configureTestingModule, довгий і ускладнює тести. І недарма виклик методу TestBed.configureTestingModule буде, як випливає з його назви, створювати a Кутовий модуль при кожному виклику та створюйте екземпляри всіх елементів цього «підробного модуля» завдяки контейнеру IOC Angular.
У наших тестах ми помітили, що використання TestBed потенційно займе в п’ять разів більше часу, ніж звичайний екземпляр об’єкта та його кепки, як у нашому прикладі (докладніше тут): Angular TestBed занадто довгий)
Ми там нічого не вигадали, це називається а Ізольований тест в Angular doc (посилання на який я більше не можу знайти, тому що воно величезне :D)
Добре, але що станеться, якщо я використаю Module походить від Angular, наприклад HttpModule ?

Випадок № 2: Тестування служби, яка залежить від модуля Angular

Давайте проведемо досить стандартний тест служби аутентифікації, яка використовує HttpClient :

import { AuthService } from "./auth.service";
import { inject, TestBed } from "@angular/core/testing";
import {
  HttpClientTestingModule,
  HttpTestingController
} from "@angular/common/http/testing";
describe("AuthService", () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AuthService],
      imports: [HttpClientTestingModule]
    });
  });
  it(
    "should return null user when server responds HTTP error",
    inject(
      [HttpTestingController, AuthService],
      (httpMock: HttpTestingController, service: AuthService) => {
        service.user.subscribe(user => {
          expect(user).toBe(null);
        });
        httpMock.expectOne("auth/user").error(new ErrorEvent("401"));
        httpMock.verify();
      }
    )
  );
});

У нас немає іншого вибору, як використовувати TestBed тому що саме він дозволяє доступ до httpMock відповідає за моделювання відповідей сервера.
[bctt tweet=”Ми будемо використовувати лише `TestBed` для тестування компонентів або класів, залежних від модуля Angular.”] Ви також помітите використання невеликого помічника. inject, зручно для отримання екземплярів, створених всередині контейнера IOC Angular. Ви також могли помітити, що мій beforeEach не містить заклик до async. Async має тенденцію використовуватися без розбору, але ця функція потрібна для очікування асинхронності.
Якщо подивитися на офіційна документація configureTestingModule не повертатися з Promise, тому він не асинхронний, тому не варто викликати async в такому разі.

Випадок № 3: Тестування «базового» компонента

Це такий компонент, який ви часто пишете в Angular.
У наступному випадку ми побачимо, що компоненти, розташовані високо в ієрархії, також будуть предметом додаткової обробки.
Щоб проілюструвати цей тест, уявімо, що ми перевіряємо компонент ListComponent який відображає список, завантажений із служби ItemsService : кейс для підручника…
Правильний спосіб зробити це буде виглядати так:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { anyString, instance, mock, when } from "ts-mockito";
import { ListComponent } from "./list.component";
import { ItemsService } from "./item/items.service";
import { Item } from "./item/item.model";
import { of } from "rxjs/observable/of";
describe("ListComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let mockedItemsService: ItemsService;
  beforeEach(() => {
    mockedItemsService = mock(ItemsService);
  });
  async function configureTestingModule() {
    await TestBed.configureTestingModule({
      providers: [
        { provide: ItemsService, useValue: instance(mockedItemsService) }
      ],
      declarations: [ListComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }
  it("should display an empty list", async () => {
    // given
    when(mockedItemsService.loadItems()).thenReturn(of([new Item()]));
    // when
    await configureTestingModule();
    // then
    const nbEl = fixture.debugElement.queryAll(By.css("li")).length;
    expect(nbEl).toEqual(0);
  });
});

Перше, що могло вас шокувати, це функція configureTestingModule поза beforeEach. Дійсно, потрібно буде викликати його в наших модульних тестах, а не раніше, щоб підтримувати порядок викликів функцій mockito, згаданих у преамбулі:

макет -> коли -> екземпляр

mock буде викликано в beforeEach потім when на початку модульного тесту і нарешті instance через дзвінок configureTestingModule в середині модульного тестування.
Якщо ми зосередимося на configureTestingModule, ми бачимо, що він асинхронний, оскільки ми викликаємо compileComponents який повертає обіцянку. Це async «забруднює» наш модульний тест, тому що ми повинні позначити його як async теж.
Ми також помітимо, що ми перевантажуємо постачальника ItemsService щоб надати йому екземпляр нашого макету ts-mockito:

{ надати: ItemsService, useValue: instance(mockedItemsService) }

Це забезпечить те, що TestBed вводить цей симулякр ItemsService до компонента під час його створення.
Нарешті, ми моделюємо повернення методу loadItems послуги, щоб вона повертала a Observable&lt;Item[]&gt; порожній, і ми перевіряємо, що список елементів html справді порожній.

Випадок № 4: Тестування компонента високого рівня

Те, що я називаю компонентами «верхнього рівня», — це компоненти, які займають високе місце в ієрархії компонентів нашої програми. Компоненти сторінок або з багатьма дочірніми елементами повністю відповідають цьому номіналу. в AppComponent буде використаний приклад.
Прикро перевіряти поведінку такого роду компонентів: вони залежать за визначенням від багатьох підкомпонентів і субмодулів, і вам доведеться ТУС додайте їх у TestBed. Для AppComponent це як надати йому майже весь додаток у залежність...
Якщо, крім того, я сказав вам, що TestBed.configureTestingModule був повільним у випадку №1, він стає повільнішим із збільшенням кількості залежностей.
Щоб подолати цю проблему, є параметр schemas надаватися TestBed.configureTestingModule зі значенням NO_ERRORS_SCHEMA :

import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
describe("AppComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [],
      imports: [],
      declarations: [AppComponent],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  it("should render title in a h1 tag", async () => {
    // given
    const selectorText = compiled.querySelector("h1").textContent;
    // then
    expect(selectorText).toBe("My awesome app");
  });
});

Цей магічний параметр налаштовує TestBed щоб він не видавав помилку, якщо не може знайти постачальника компонента або модуля, використаного в шаблоні(ах) компонента(ів), що тестується. Цей метод називається shallow testing. Ми тестуємо лише компонент AppComponent і не інтерпретувати його підкомпоненти.
Le NO_ERRORS_SCHEMA слід використовувати з обережністю, оскільки застосовуваний скрізь, він робить перевірки системи залежностей інертними і може призвести до того, що ви забудете завантажити компонент, коли це необхідно у вашому тестовому прикладі.
[bctt tweet=”У Angular `NO_ERRORS_SCHEMA` слід використовувати з обережністю і лише для тестування компонентів високого рівня в ієрархії.”]

Кейс № 5: Труби, класи, інше

Канали, як і інші елементи, які можуть складати вашу програму, будуть перевірені як служби без залежності Angular, а саме базові класи TypeScript.
Уявімо трубу, мета якої — об’єднати у вигляді рядка всі ключі об’єкта, значення якого не є нульовим. Ми б це назвали AliciaKeys… (так, ми дуже пишалися назвою)

import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
  name: "aliciaKeys"
})
export class AliciaKeys implements PipeTransform {
  transform(obj: any, arg: string[]): string {
    return obj
      ? Object.entries(obj)
          .filter(([key, value]) => !!value)
          .map(([key]) => key)
          .join(", ")
      : "";
  }
}

Доданий файл модульного тесту є тривіальним, оскільки це простий екземпляр класу AliciaKeys з двома дзвінками від transform у двох різних випадках:

import { AliciaKeys } from "./aliciakeys.pipe";
describe("AliciaKeys", () => {
  it("should return valued keys", () => {
    const result = new AliciaKeys().transform({
      validKey: {},
      inValidKey: null,
      anotherValidKey: 1
    });
    expect(result).toEqual("validKey, anotherValidKey");
  });
  it("should return blank when input is falsy", () => {
    const result = new AliciaKeys().transform(null;
    expect(result).toEqual("");
  });
});

Висновок і далі

Сподіваємося, що опис кожного випадку також допоможе вам краще зрозуміти, як правильно проводити модульне тестування ваших програм Angular.
Важливо пам’ятати, що класи в Angular залишаються класичними класами TypeScript і що їх можна легко перевірити. Другий момент полягає в тому, що TestBed не є систематичним, що його слід використовувати лише тоді, коли у вас немає вибору. Нарешті, макетами в Angular потрібно керувати за допомогою фіктивної бібліотеки: у нашому випадку ts-mockito.
Ви повинні знати, що в нашому проекті ми проводили близько 700 модульних тестів, виконання яких займало більше 30 секунд. Упорядкування тестів і їх прискорення стали для нас вирішальними.
Ми також продовжили цей пошук прискорення, пройшовши останні є. Це цілком можливо, дотримуючись інструкцій у репо жарт-заздалегідь-кутовий і це економить багато часу!
Ми повернемося до вас із докладнішою інформацією на ці теми під час нашого зворотного зв’язку «JS-Talks» (день спостереження за технологіями, який ми проводимо щомісяця). Кілька останніх фото, щоб побачити, як це виглядає:

Publié par Матьє Бретон CTO в JS-Republic