ПРИМЕЧАНИЕ. Хотя инструменты, представленные в этой статье (в основном) отличные, я больше не согласен со стратегией тестирования, которую представил здесь. Да, детали реализации тестирования могут увеличить охват и включить зеленый свет, но обычно это не особенно важно для проверки правильности кода. Пожалуйста, прочтите здесь, чтобы узнать больше, и обязательно ознакомьтесь с тем, что я сейчас рекомендую: Библиотека для тестирования Angular.

В этой статье мы узнаем, как повысить уровень нашего опыта модульного тестирования Angular с помощью альтернативного набора инструментов для тестирования. Я расскажу, как переключить проект с Karma на Jest, как настроить Wallaby в проекте Angular и как протестировать компоненты и сервисы с помощью комбинации Spectator и поверхностного рендеринга.

Предупреждение. Мой подход к модульному тестированию является упрямым. Как и со всем, что думает о разработке программного обеспечения, есть много людей, которые согласны и не согласны. Моя роль состоит в том, чтобы представить подход, который хорошо сработал для моих команд и проектов, и дать вам дорожную карту того, как начать с ним самостоятельно.

вступление

Трудно тестировать код, трудно любить код.

Тестировать свои приложения с помощью набора инструментов, предоставляемого Angular CLI, сложно. Немного поработав, мы можем значительно улучшить наш опыт разработчиков тестирования, который может включать:

  • 🔥 Тесты могут выполняться намного быстрее!
  • 👀 Мы можем автоматически видеть, когда тесты проходят или не проходят в нашем редакторе, даже без необходимости запускать тесты в командной строке!
  • ✨ Мы можем легко запускать только те тесты, на которые повлияет наша предстоящая фиксация git!
  • 🥳 Мы можем писать тесты с гораздо меньшим количеством шаблонов, что помогает нам работать быстрее как команда или инженерная организация!

Все это возможно за счет использования альтернативных инструментов в наших проектах Angular CLI. Давайте начнем.

👉 Улучшение №1: используйте Jest в качестве средства выполнения тестов.

Jest, быстрый инструмент запуска тестов на основе Node от Facebook, может значительно улучшить опыт разработки вашего приложения Angular несколькими ключевыми способами:

  • Тесты выполняются параллельно, а не синхронно, что может значительно ускорить выполнение тестов для больших проектов.
  • Тесты запускаются непосредственно в командной строке в смоделированной среде DOM. Вам больше не нужно запускать экземпляр браузера для выполнения ваших тестов.
  • Тесты, на которые влияет ваш текущий git diff, можно легко и разумно изолировать.

С переходом на Jest у нас больше не будет необходимости в Жасмин в нашем проекте. Но это не повод для беспокойства! API Jest * почти * идентичен API Jasmine.

Хотя существуют схемы для мгновенного переключения проекта на Jest, на момент написания этой статьи у них были известные проблемы с Angular 8, и они еще не перенастроили вашу команду ng test должным образом. Из-за этих проблем мы будем преобразовывать наш проект Angular 8 вручную.

Руководство по установке Jest

Шаг 1. Удалите все зависимости Karma и файлы конфигурации.

Выполните следующие команды в корневой папке рабочего пространства:

rm karma.conf.js src/test.ts
npm rm -D karma karma-chrome-launcher karma-coverage-istanbul-reporter karma-jasmine karma-jasmine-html-reporter

Шаг 2. Удалите мертвые ссылки на файлы test.ts из ваших tsconfigs.

В tsconfig.app.json и tsconfig.spec.json удалите ссылку на ”src / test.ts”.

Шаг 3. Добавьте зависимости Jest.

Выполните следующую команду в корневой папке рабочего пространства:

npm i -D jest jest-preset-angular @angular-builders/jest @types/jest

Шаг 4. Добавьте типы Jest в свой tsconfig.json.

В корне приложения откройте tsconfig.json и добавьте массив types в свой объект compilerOptions:

"compilerOptions": {
  ...
  "types": ["jest"]
}

Шаг 5. Добавьте jest.config.js в корневую папку (вместе с tsconfigs).

Обратите внимание, что в этой конфигурации мы используем объект moduleNameMapper. Это напрямую связано с объектом paths вашего tsconfig. Jest не будет знать никаких путей tsconfig, пока мы не укажем их в jest.config.js.

Если paths вам незнаком, не волнуйтесь! У проектов Angular обычно есть CoreModule и SharedModule, и разумно, когда мы импортируем данные из них, мы хотели бы ссылаться на вещи из этих модулей, используя @shared/my-service, а не ../../../../shared/my-service. Мы установили эти псевдонимы с помощью paths. Если вы хотите сделать это сейчас, измените свой tsconfig.json следующим образом:

"compilerOptions": {
 ...
 "paths": {
   "@core/*": ["src/app/core/*"],
   "@shared/*": ["src/app/shared/*"]
 }

Шаг 6. Измените свой проект в angular.json, чтобы использовать Jest вместо Karma.

"projects" {
  ...
  "your-app-name": {
  ...
    "test": {
      "builder": "@angular-builders/jest:run",
      "options": {
        "configPath": "./jest.config.js"
      }
    }
  }
}

Шаг 7. Запустите ng test!

Потрясающие! Но что, если мы хотим запускать только тесты, на которые повлияло наше текущее изменение? Это можно сделать с помощью ng test --onlyChanged.

✅ Улучшение №2: используйте Wallaby.js в своем редакторе.

Wallaby.js - это интегрированная программа для непрерывного тестирования проектов на основе JavaScript. Он поддерживает самые популярные редакторы, включая VSCode, Atom, Sublime Text и Visual Studio. Валлаби не бесплатен, поэтому, если стоимость является проблемой, безопасно пропустить этот раздел. У меня есть личная лицензия Валлаби, и я использую ее во всех своих проектах из-за продуктивности Повышение, которое я получаю от этого, и для полного раскрытия информации о моем рабочем процессе тестирования, я также хочу поделиться своей конфигурацией Wallaby для проектов Angular.

Руководство по установке Wallaby.js для Angular + Jest

Шаг 1. Установите зависимости.

В корневом каталоге выполните следующую команду:

npm i -D ngx-wallaby-jest

Шаг 2. Добавьте wallaby.js в корневой каталог вместе с файлами tsconfig.

Обратите внимание, что у нас есть шаблон в нашей setup функции, который сообщает Валлаби, как интерпретировать наши значения Jest moduleNameMapper (также известные как tsconfig paths).

Шаг 3. Добавьте расширение Wallaby в свой редактор и запустите его.

То, что вы увидите отсюда, будет зависеть от того, какой редактор вы используете, но теперь вы должны увидеть в своем желобе и журнале консоли, как показано на гифке выше, прошел / не прошел.

Приступаем к написанию тестов…

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

Большинство тестов Angular написано с помощью TestBed. Фактически, самые популярные библиотеки, которые помогают устранить шаблон тестирования Angular, по-прежнему полагаются на TestBed под капотом.

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

В обозримом будущем модульные тесты Angular будут полагаться на TestBed, даже если он скрыт под сгибом вспомогательной библиотекой. Я покажу вам, как использовать одну такую ​​вспомогательную библиотеку, чтобы абстрагироваться от TestBed тяжелой работы и добиться положительного сдвига в соотношении шаблонов и тестов в наших файлах Angular .spec.

😎 Улучшение №3: Тестируйте компоненты и сервисы с помощью Spectator.

Spectator - это библиотека модульного тестирования Angular, построенная на основе TestBed и разработанная теми же людьми, которые создали Akita. Он может помочь значительно сократить шаблон вашего набора тестов и предоставляет очень чистый API для написания модульных тестов.

Для большинства приложений Angular, с которыми я работал, есть два типа сервисов: те, которые выполняют HTTP-вызовы, и те, которые этого не делают. Мы будем использовать Spectator для тестирования обоих типов.

Не-HTTP-инъекции

Представим, что в нашем приложении есть служба уведомлений, которая обтекает Angular Material MatSnackBar. Это выглядит так:

У этой службы есть одна зависимость: MatSnackBar и одна функция: notify. Давайте разберемся со спецификацией с помощью Spectator. Сначала установите необходимые зависимости:

npm i -D @netbasal/spectator ng-mocks

В конечном итоге файл спецификации для этого может выглядеть так:

Ого, здесь много чего происходит. Давайте поговорим обо всем, что происходит при инициализации нашего набора тестов, шаг за шагом.

let snackBar: SpyObject<MatSnackBar>;

Нам не важно, как работает MatSnackBar. Введение настоящего MatSnackBar в этот набор тестов нарушит изоляцию модуля и будет считаться анти-шаблоном. Есть несколько подходов к поддержанию изоляции модулей в таких случаях, и я предпочитаю преобразовать каждую из зависимостей инъекций в SpyObject. SpyObject дает нам возможность легко проверить, NotificationService вызывает MatSnackBar функции с ожидаемыми аргументами, независимо от того, что MatSnackBar может сделать с этими аргументами после их получения.

let spectator: SpectatorService<NotificationService> = createService({
  service: NotificationService,
  mocks: [MatSnackBar]
});

Здесь начинается волшебство. Мы используем createService, чтобы подставить SpectatorService экземпляр за NotificationService. Нам не нужно беспокоиться о том, чтобы вручную подставить или высмеять MatSnackBar, волшебный массив mocks позаботится об этом автоматически.

Теперь мы можем сжать наше beforeEach содержимое до одной строки кода:

beforeEach(() => {
  snackBar = spectator.get(MatSnackBar);
});

Перед каждым тестом (повторно) инициализируйте наш SpyObject. Простой! А теперь пора написать несколько тестов!

it('exists', () => {
  expect(spectator.service).toBeDefined();
});

Независимо от того, что вы тестируете, неплохо, если первым тестом в вашем пакете будет проверка работоспособности. Это может сэкономить вам много времени в будущем, а в сочетании с Wallaby вы можете сразу увидеть в своем редакторе, если вы забыли имитировать зависимость. Обратите внимание, что мы используем spectator.service для доступа к нашему экземпляру NotificationService.

it('can pop open a snackbar notification', () => {
  spectator.service.notify('mock notification');
  expect(snackBar.open).toHaveBeenCalledWith(
    'mock notification', 
    'CLOSE', 
    {
      duration: 7000
    }
  );
});

Копнув немного глубже, мы вызываем нашу notify функцию и убеждаемся, что она сообщает MatSnackBar о том, что она должна делать. Обратите внимание, что нам не нужно писать код для spyOn, чтобы использовать toHaveBeenCalledWith. Наш шаблон SpyObject позаботится об этом автоматически. Это понятный и легкий для глаз тест.

Инъекции с HTTP-вызовами

Нам нужно получить некоторые общие сущности из нашей серверной части. Вот как может выглядеть очень простой вводимый HTTP-сервис (без обработки ошибок):

Наш тест должен подтвердить, что вызов GET действительно выполняется. Со Spectator это довольно просто!

Надеюсь, это немного проще, чем наш предыдущий набор тестов, но все равно не помешает его разобрать.

const httpService: () => SpectatorHTTP<EntityDataService> = createHTTPFactory<
  EntityDataService
>(EntityDataService);

Это самая сложная часть пакета для распаковки, и вам действительно не нужно понимать, что здесь происходит, кроме как знать, что это рецепт для создания httpService, который вы можете использовать для тестирования этого Angular инъекционный.

SpectatorHTTP - это причудливая оболочка для нескольких классов, которые команда Angular предоставляет для тестирования вызовов HTTP API.

it('exists', () => {
  const { dataService } = httpService();
  expect(dataService).toBeDefined();
});

Как и в случае с нашим предыдущим набором тестов, неплохо начинать с проверки работоспособности. dataService - это фактический EntityDataService экземпляр, созданный createHTTPFactory.

it('can get entities from the server', () => {
  const { dataService, expectOne } = httpService();
  dataService.getEntities().subscribe();
  expectOne('/api/entities', HTTPMethod.GET);
});

Это должно быть хорошо читаемым. Мы получаем экземпляр нашей службы и expectOne, утверждение, которое принимает путь URL и метод HTTP. Мы подписываемся на наблюдаемое, которое возвращает наша функция getEntities, чтобы выполнить вызов API, затем мы используем expectOne, чтобы подтвердить, что сам вызов был сделан и отправлен правильно.

Не-HTTP-инъекции, которые зависят от HTTP-инъекций

В качестве третьего и последнего примера уровня сервиса будьте готовы к инъекционному восприятию, поскольку мы тестируем инъекционный файл, который использует нашу службу API из предыдущего примера. Эта служба будет отвечать за взаимодействие с нашей службой API, а затем за отправку ответа API в хранилище нашей системы управления состоянием. Другими словами, этот инъекционный файл будет иметь две зависимости. См. ниже:

Эта служба напрямую взаимодействует с EntityDataService и AwesomeEntityStore. Когда мы получаем ответ от нашего API-вызова, мы отправляем объекты в хранилище, предположительно для использования каким-либо компонентом. К счастью, проверить это с помощью Spectator довольно просто!

Как и в предыдущих тестах, давайте рассмотрим этот шаг за шагом.

let dataService: SpyObject<EntityDataService>;
let store: SpyObject<AwesomeEntityStore>;
let spectator: SpectatorService<EntityService> = createService({
  service: EntityService,
  mocks: [EntityDataService, AwesomeEntityStore]
});

Теперь все должно стать более знакомым! Мы объявляем несколько экземпляров SpyObject для наших двух зависимостей и передаем те же зависимости в массив mocks.

beforeEach(() => {
  dataService = spectator.get(EntityDataService);
  store = spectator.get(AwesomeEntityStore);
});

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

it('can try to get all the awesome entities and put them in the store', () => {
  dataService.getEntities.andReturn(
    of(
      [{ id: 1 }]
    )
  );
spectator.service.getAllEntities().subscribe();
expect(store.set).toHaveBeenCalledWith(
    [{ id: 1 }]
  );
});

Вот где происходит волшебство! Мы говорим dataService, что если вызывается getEntities функция, она вернет наблюдаемый объект с массивом, содержащим AwesomeEntity. Затем мы можем отслеживать функцию store set и гарантировать, что передан правильный аргумент.

Все это круто, но все становится действительно интересным, когда мы переходим к тестированию наших компонентов. Типичное приложение имеет два разных типа компонентов: презентационные (немые / без состояния) компоненты и контейнерные (интеллектуальные / с отслеживанием состояния) компоненты. Spectator упрощает тестирование обоих типов.

Презентационные компоненты

Тесты для презентационных компонентов - одни из самых простых для написания с помощью Spectator, но рецепт тестирования компонентов в целом немного отличается от тестирования сервисов.

В приведенном ниже примере у нас есть PresentationalButtonComponent. Он имеет один вход: метку и один выход: строковое излучение.

Если вы не используете презентационные компоненты в своем приложении Angular, сейчас самое время их рассмотреть! Это идеальный шаблон проектирования, когда корневые компоненты управляют состоянием, а их шаблоны собирают представления вашего приложения с помощью небольших презентационных компонентов без сохранения состояния. Одним из преимуществ этого шаблона является то, что эти компоненты без сохранения состояния очень легко тестировать. Взглянем:

Еще один воах. Пройти это испытание со Spectator очень легко! Наш общий шаблон составляет всего семь строк, и все наши тесты очень удобочитаемы благодаря сверхчистому API Spectator! Давайте сделаем еще одно прохождение.

let spectator: Spectator<PBComponent>;
const createComponent = createTestComponentFactory<PBComponent>({
  component: PBComponent
});
beforeEach(() => {
  spectator = createComponent();
});

Это рецепт, благодаря которому любой набор тестов презентационных компонентов может быть успешно использован с помощью Spectator. Единственное, что нужно объекту параметров createTestComponentFactory, - это значение component с указанным классом вашего компонента. Затем, beforeEach тест, запустите createComponent().

it('exists', () => {
  expect(spectator.component).toBeDefined();
});

Наша проверка на вменяемость. Доступ к экземпляру класса компонента осуществляется через spectator.component. Есть и другие вещи, доступные через нашу spectator переменную, например, spectator.fixture для фиксации компонента.

// JSDOM
it('renders a button with a default label if no label is given', () => {
  expect(spectator.query('button')).toHaveText('Submit');
});

В этом наборе тестов у меня есть два теста JSDOM и один тест для класса компонента. Здесь мы используем spectator.query, чтобы найти элемент <button> в шаблоне, и утверждаем, что он должен иметь текст «Отправить».

Есть те, кто считает, что тесты JSDOM лучше обрабатывать с помощью таких инструментов, как Protractor или Cypress, и этим двум спецификациям нет места в наборе модульных тестов. Мое мнение по этому поводу выходит за рамки данной статьи. Я предлагаю тесты JSDOM в качестве примера, если вы хотите применить такой подход. Помните, что ваши тесты JSDOM не повлияют на покрытие кода , потому что они тестируются на смоделированной модели DOM, а не на вашем коде TypeScript.

// JSDOM
it('renders a button with an input label if one is given', () => {
  spectator.component.buttonLabel = 'Mock Label';
  spectator.detectChanges();
  expect(spectator.query('button')).toHaveText('Mock Label');
});

Здесь происходит еще кое-что, но за этим все еще нетрудно уследить. Сначала мы передаем значение нашему входу buttonLabel. Затем мы начинаем detectChanges(), чтобы он появился в DOM (в противном случае значение все равно будет «Отправить»). После того, как подготовка к тесту завершена, мы можем запустить наше toHaveText утверждение, как и в предыдущем тесте!

// Component Class
it('can emit a message', () => {
  const pressEmitSpy = spyOn(spectator.component.press, 'emit');
  spectator.component.handleButtonClick();
  expect(pressEmitSpy).toHaveBeenCalledWith('Submit was pressed!');
});

Как говорится в комментарии, это тест против нашего фактического кода TypeScript в классе компонента. Мы не моделируем нажатие кнопки и не гарантируем, что был вызван handleButtonClick() (это приведет к переходу на территорию тестирования интеграции), мы просто убеждаемся, что когда он вызывается, он выдает сообщение, которое мы ожидать.

Для этого мы spyOn нашу EventEmitter emit функцию и используем утверждение toHaveBeenCalledWith, как мы это делали с нашими инъекционными тестами.

😱 Контейнер + компоненты с отслеживанием состояния

И последнее, но не менее важное: мы напишем несколько тестов для компонента с отслеживанием состояния. Я обнаружил, что модульные тесты компонентов с отслеживанием состояния - самые напряженные тесты для написания приложений Angular, но Spectator значительно упрощает ситуацию. Проверить это:

Наш компонент с отслеживанием состояния обманчиво прост. Здесь два ключевых вывода:

  • Мы внедряем службу: AwesomeEntitiesQuery, которая вызывается в ngOnInit().
  • В нашем шаблоне используется презентационный компонент из предыдущего примера с тегом <app-presentational-button>.

Обычно для создания набора тестов для этого компонента требуется много шаблонов, но Spectator может сделать это для нас очень понятным. Вот спецификация:

Наши друзья SpyObject и mocks вернулись, но у нас также есть новое дополнение: shallow: true. Пришло время еще одного прохождения!

let query: SpyObject<AwesomeEntitiesQuery>;
let spectator: Spectator<StatefulComponent>;
const createComponent = createTestComponentFactory<StatefulComponent>({
  component: StatefulComponent,
  mocks: [AwesomeEntitiesQuery],
  shallow: true
});

Подобно нашим предыдущим тестам, где у нас были зависимости служб, наш компонент с отслеживанием состояния тоже: AwesomeEntitiesQuery. И, как и в наших предыдущих тестах, у нас есть волшебный массив mocks, который обо всем позаботится за нас.

Мы также видим shallow: true. Почему это здесь? Что ж, наш дочерний компонент <app-presentational-button> в шаблоне все усложняет. Нам также все равно, что делает этот компонент или как он работает. Мелкий рендеринг имитирует дочерние компоненты. Воспользовавшись мелким рендерингом, мы можем притвориться, что <app-presentational-button> преобразован в нечто вроде пустого div. Для более подробного объяснения поверхностного рендеринга документация React предлагает краткую аннотацию по этой теме.

beforeEach(() => {
  spectator = createComponent();
  query = spectator.get<AwesomeEntitiesQuery>(AwesomeEntitiesQuery);
});

Это комбинация beforeEach вызовов в наших предыдущих примерах со службами и компонентами. Здесь мы создаем наш компонент, а также настраиваем наш SpyObject.

it('gets all the awesome entities on initialization', done => {
  query.selectAll.andReturn(of([]));
  spectator.component.ngOnInit();
  spectator.component.awesomeEntities$.subscribe(val => {
    expect(val).toEqual([]);
    done();
  });
});

Мы указываем, что при вызове selectAll() из нашей службы он вернет наблюдаемый объект, содержащий пустой массив. Затем мы запускаем наш ngOnInit() и делаем утверждение, что это значение действительно попало в переменную awesomeEntities$ нашего компонента с отслеживанием состояния.

🎉 Куда идти дальше

Если вы решили использовать Jest, Wallaby и / или Spectator в своих проектах, вот несколько отличных ресурсов для продолжения:

Спасибо за прочтение!