Давайте обсудим модульное тестирование в Angular, это будет просто введение в модульное тестирование Angular и преимущества использования Jest в качестве средства запуска тестов и пакета для вашего выполнения.

Какие?

Jest – это интегрированное решение для тестирования, написанное Facebook и особенно известное в мире React.

Ключевые особенности Jest:

  • Простая настройка
  • Мгновенная обратная связь
  • Мощное издевательство
  • Работает с машинописным текстом
  • Моментальное тестирование

Простая настройка означает, что вам не нужно практически ничего настраивать, чтобы начать работу.

Мгновенная обратная связь, потому что он будет запускать только тестовые файлы, связанные с изменениями.

Мощный макет с помощью простых в использовании функций макета

Почему?

Первая причина, по которой вы хотите начать использовать шутку, — это скорость.

Модульный тест должен работать быстро

Сравнение нового приложения, созданного с @angular/cli

karma-chrome: 14,911 с.

karma-phantomjs: 13,119 с.

шутка: 4,970 с.

Это разница между двумя запусками составляет 8,606 секунды, karma-chrome занимает более чем вдвое больше времени, чтобы выполнить всего 1 набор и 3 теста.

Я включаю PhantomJS в эти сравнения, даже если он больше не поддерживается, главным образом потому, что это, вероятно, самый быстрый вариант для запуска тестов в среде CI (Дженкинс, Трэвис).

Jest не требует реального браузера (безголового или нет) для запуска теста (есть некоторые недостатки). Если вы ищете замену PhantomJS в средах непрерывной интеграции, вы можете быстро переключиться на Jest без необходимости какой-либо настройки вашего CI.

Он основан на Jasmine, который, вероятно, является фреймворком по умолчанию для приложений Angular и включен в CLI.

Как?

Первый шаг — установить jest в ваш новый проект:

$ yarn add -D @types/jest jest jest-preset-angular

Установка типов, необходимых для компилятора машинописного текста, самого фреймворка и в файле jest-preset-angular, который содержит конфигурацию для проекта Angular.

Следующий шаг, изменить package.json:

"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "jest": {
    "preset": "jest-preset-angular",
    "setupTestFrameworkScriptFile": "<rootDir>/src/jest.ts"
  }

Я меняю npm-скрипты, которые будут запускать jest framework и добавляю единственную конфигурацию (почти нулевой конфиг), которая нам нужна для проекта.

Я требую, чтобы "setupTestFrameworkScriptFile": "<rootDir>/src/jest.ts" нам нужно было создать этот файл, который Jest будет использовать для запуска внутри папки src проекта.

import 'jest-preset-angular';
import './jest-global-mocks';

Последний шаг, так как Jest не запускает настоящий браузер, а основан на jsdom, заключается в создании макетов для конкретного API браузера, такого как localStorage и т. д.:

const mock = () => {  
  let storage = {};
  return {
    getItem: key => (key in storage ? storage[key] : null),  
    setItem: (key, value) => (storage[key] = value || ''),
    removeItem: key => delete storage[key],
    clear: () => (storage = {})
  };
};

Object.defineProperty(window, 'localStorage', { value: mock() });  
Object.defineProperty(window, 'sessionStorage', { value: mock() });  
Object.defineProperty(window, 'getComputedStyle', { value: () => ['-webkit-appearance']  
});

И последнее, но не менее важное: мы должны удалить @types/jasmine из node_modules и убедиться, что вы включили jest в качестве новых типов в tsconfig.spec.json.

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/spec",
    "baseUrl": "./",
    "module": "commonjs",
    "target": "es5",
    "types": [
      "jest",
      "node"
    ]
  },
  "include": [
    "**/*.spec.ts",
    "**/*.d.ts"
  ]
}

Jest основан на Jasmine, поэтому нет необходимости изменять тест, уже присутствующий в шаблонном приложении.

Единственная заметная разница между Jest и Jasmine — это шпионы. Шпион в Jest по умолчанию будет похож на callThrough, вам нужно будет издеваться над ним, если вы хотите подражать шпионам Jasmine.

Теперь мы можем приступить к тестированию нашего приложения и использовать функции имитации, которые предоставляет Jest.

Услуги

Сервисы, вероятно, являются одним из самых простых элементов для тестирования. В конце концов, это всего лишь классы ES6, и вы можете (и должны) тестировать их напрямую, без использования TestBed. Причина, по которой я говорю, что это множественность.

Этот код основан на новом HttpClient, выпущенном с Angular 4.3, который также упрощает работу при использовании TestBed.

У нас есть следующий API:

{
  "DUB": {
    "name": "Dublin"
    ...
  },
  "MAD": {
    "name": "Madrid"
    ...
  },
  ...
}

И наш сервис должен получить эти аэропорты и упорядочить их по коду, а также получить один аэропорт. Я буду использовать ramda, чтобы уменьшить шаблон нашего кода и оживить его функциональным программированием.

Итак, первый будет называться fetchAll$

public fetchAll$(): Observable<any> {  
  return this.http.get('https://foo.bar.com/airports')
  .map(toPairs)
  .map(sortBy(compose(
    toLower,
    head
  )));
}

Сначала мы преобразуем API в пары [ключ, значение] с помощью функции toPairs, а после сортируем все аэропорты по их кодам.

Вместо этого второй метод нашего сервиса должен получить один аэропорт:

public fetchByIATA$(iata: string): Observable<any|undefined> {  
  return this.http.get('https://foo.bar.com/airports')
  .map(prop(iata));
}

fetchByIATA$ просто верните значение определенного ключа, в данном случае код IATA, или неопределенное, используя функцию prop из ramda.

Вот и вся служба:

import { Injectable } from '@angular/core';  
import { HttpClient } from '@angular/common/http';  
import { Observable } from 'rxjs';  
import 'rxjs/add/operator/map'  
import {  
  toPairs,
  compose,
  head,
  toLower,
  prop,
  sortBy
} from 'ramda';

@Injectable()
export class SampleService {  
  constructor(private http: HttpClient) {}

  public fetchAll$(): Observable<any> {
    return this.http.get('https://foo.bar.com/airports')
    .map(toPairs)
    .map(sortBy(compose(
      toLower,
      head
    )));
  }

  public fetchByIATA$(iata: string): Observable<any|undefined> {
    return this.http.get('https://foo.bar.com/airports')
    .map(prop(iata));
  }
}

Здесь нам нужно имитировать ответ http, потому что мы не хотим загружать сервер для каждого теста, и помните, что мы проводим модульные тесты, а не сквозные или интеграционные тесты.

С новым HttpClient нет необходимости настраивать mockBackend, и это такое облегчение.

Прежде всего, мы должны настроить TestBed для загрузки нашего сервиса с помощью HttpClientTestingModule, это даст нам возможность перехватывать и имитировать наши внутренние вызовы.

beforeEach(() => {  
  TestBed.configureTestingModule({
    imports: [ HttpClientTestingModule ],
    providers: [
      SampleService
    ]
  });
});

После настройки TestBed мы теперь можем получить наш сервис для тестирования и фиктивный http-контроллер из инжектора:

beforeEach(  
  inject([SampleService, HttpTestingController], (_service, _httpMock) => {
    service = _service;
    httpMock = _httpMock;
  }));

Теперь, когда у нас есть все настройки, мы можем приступить к
одиночным тестам.

it('fetchAll$: should return a sorted list', () => {  
  const mockAirports = {  
    DUB: { name: 'Dublin' },
    WRO: { name: 'Wroclaw' },
    MAD: { name: 'Madrid' }
  };
  service.fetchAll$().subscribe(airports => {
    expect(airports.length).toBe(3);
    expect(airports[2][0]).toBe('WRO');
  });

  const req = httpMock.expectOne('https://foo.bar.com/airports');

  req.flush(mockAirports);
  httpMock.verify();
});

Новый HttpClient на самом деле напоминает мне AngularJS v1.x способ тестирования HTTP-вызовов. Мы определяем, что мы ожидаем от вызова функции, а затем через объект httpMock мы указываем, какие вызовы мы ожидаем и что возвращать flush. В конце мы вызываем функцию verify(), чтобы убедиться, что нет ожидающих соединений.

Вот ссылка на полный исходный код

При использовании Jest предыдущий набор займет это время:

Другой вариант, который мы можем изучить, — это имитировать непосредственно HttpClient, в этом примере нас интересует только функция get, которая должна возвращать наблюдаемую, содержащую наши данные.

Мы вообще не будем использовать TestBed, а просто воспользуемся фиктивной функцией, которую предоставляет Jest.

SampleService требует HttpClient в конструкторе:

const http = {  
  get: jest.fn()
};

beforeEach(() => {  
  service = new SampleService(http);
});

Мы передадим нашу заглушку в SampleService, и typescript сообщит об отсутствии свойств для HttpClient. Чтобы преодолеть это, мы можем:

Всегда используйте as any:

beforeEach(() => {  
  service = new SampleService(http as any);
});

Если вы не хотите повторять как любое ключевое слово или можете создать небольшую функцию, которая сделает это за вас, а затем
при импорте:

const provide = (mock: any): any => mock;  
...

beforeEach(() => {  
  service = new SampleService(provide(http));
});

На этом этапе мы можем указать тест следующим образом:

it('fetchAll$: should return a sorted list', () => {  
  http.get.mockImplmentationOnce(() => Observable.of(mockAirports));
  service.fetchAll$().subscribe((airports) => {
    expect(http.get).toBeCalledWith('https://foo.bar.com/airports');
    expect(airports.length).toBe(3);
    expect(airports[2][0]).toBe('WRO');
  });
});

Мы вызываем mockImplementationOnce, как следует из названия, чтобы смоделировать его только один раз и вернуть Observable нашего mockAirports, мы повторяем то же самое утверждение, что и раньше.

Вот ссылка на полный исходный код

А это время казни:

Подытожим: пакет, выполняющий два теста с TestBed, занимает в общей сложности 99 мс, а без TestBed — всего 12 мс.

Обратите внимание, что я уже использовал расширенные мок-функции во второй тестовой шутке. Я запрашиваю фиктивную функцию напрямую через jest.fn(), если вы хотите узнать больше об этих фиктивных функциях, посмотрите здесь.

Окончательное сравнение

Теперь, когда у нас есть эти два дополнительных модульных теста, давайте попробуем еще раз запустить набор двух модульных тестов, один с Karma + Chrome, другой с Jest, и посмотрим на результаты.

Я создал следующий скрипт для отслеживания времени на моей локальной машине:

start=$SECONDS  
yarn test -- --single-run  
end=$SECONDS

echo "duration: $((SECONDS-start)) seconds elapsed.."

карма + хром

Я добавил karma-verbose-reporter, чтобы получить больше информации о тестах, и общий результат составил 22 секунды.

Вместо этого для Jest скрипт для отслеживания времени выглядит следующим образом:

start=$SECONDS  
yarn test -- --verbose  
end=$SECONDS

echo "duration: $((SECONDS-start)) seconds elapsed.."

--verbose будет отслеживать выполнение каждого теста внутри пакета.

шутка

Jest по-прежнему лидирует с выполнением всего 5 секунд.

Бонус

Я упоминал перед мгновенным отзывом, что Jest автоматически запускает тесты, связанные с файлом, который вы изменили. Это особенно хорошо во время просмотра.

Если мы зафиксируем изменения в нашем репозитории и попытаемся изменить только app.component.ts во время работы yarn test:watch, вы заметите, что работает только app.component.spec.ts:

Вывод

Я действительно считаю, что фреймворки должны поощрять тестирование, и решающим моментом является их скорость. Jest предоставляет все это, и мы уже перешли в Ryanair с karma + chrome на Jest.

PS: Если вам небезразличны модульные тесты и Angular, которых мы нанимаем в Ryanair, присоединяйтесь к нам.

Первоначально опубликовано на izifortune.com 26 июля 2017 г.