Тестирование сервисов в Angular с помощью Jest. Тестирование реактивной/асинхронной логики.

Aleksandr Serenko
F.A.F.N.U.R
Published in
11 min readMar 11, 2020

В данной статье рассмотрим Unit тестирование в Angular. Поговорим о основных назначениях тестов, а также приведём примеры тестирования сервисов.

Тестирование в Angular является особой темой. Официальная документация по тестированию — https://angular.io/guide/testing, описывает все практики тестирования. Однако, все примы приводятся с использованием стандартной для Angular связке Karma + Jasmine.

Если вы писали тесты для JavaScript, то должны были слышать о таком тестовом фреймворке — Jest.

Jest, как и Karma является отличным инструментом, только с тем отличием, что тесты на Jest выполняются молниеносно.

Настройка Jest

В данной статье мы будем использовать Jest, как основную систему для написания тестов. Но так как Jest не поставляется с Angular из коробки, есть как минимум 2 пути:

  • Использовать Nx
  • Самостоятельно подключить Jest в проект

Первый вариант является самым простым. Ребята из Nx, сделали всю грязную работу и написали свои сборщики и обработчики для корректной работы Jest’а.

Второй вариант является чуть сложнее, так как вам придётся создать пару файлов и установить несколько пакетов. Так как Angular подразумевает использование Typescript, то есть большой шанс использования сокращений (paths), с чем у Jest по умолчанию работает с трудом.

Весь процесс самостоятельной настройки Jest описано в статье — Создаем моно репозиторий на основе angular/cli без Nx или Lerna.

Основное назначение тестов

Только начиная писать Unit тесты в Angular, не всегда есть чёткое понимание того, что нужно тестировать.

Основной постулат при создании теста можно сформулировать так:

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

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

При тестировании сущностей, включающих в себя другие сущности необходимо использовать mock’и или stub’ы. Хорошим решением является использование ng-mocks.

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

Например, если используется Ngrx и Ngrx/Effects, для правильной проверки запуска цепочки событий: load —> detect —> save, необходимо использовать внешний сервис (Facade), который запускает событие load. Примеры подобных тестов, будут приведены ниже.

Основы тестирования

Обычно, любой фреймворк для тестирования реализует следующую парадигму:

Создаётся сущность теста, задаются основные входные параметры и добавляются наборы случаев, которые должны проверять корректность выполнения определённых фрагментов кода.

Для создания теста необходимо создать файл с префиксом расширения (обычно это .spec : file.spec.js или file.spec.ts), который определён в tsconfig или конфиге используемого фреймворка для тестирования.

Допустим есть простой сервис, метод которого возвращает единицу:

Для создания простого теста используем функцию — it:

Как видно из реализации, в коде используется describe. Describe позволяет сгруппировать тесты в набор тестов.

Функции beforeEach, afterEach запускаются перед/после каждого теста (it).

Функция expect сравнивает полученное и ожидаемое значения. Если значения совпадают, то тест считается пройденным.

Подробнее о основах тестирования на Jest.io.

Типы тестов в Angular

Процесс тестирования в Angular заключается в написании тестов для всех сущностей, которые используются в приложении.

Основные виды сущностей angular:

  • Components, directives и pipes — все сущности связанные с отображением и изменением DOM.
  • Services — Dipencency Injection сервисы, которые отвечают за реализацию клиентской/бизнес логики.
  • Modules — Angular модули, которые включают в себя подключение компонент и сервисов.

Все тесты в Angular можно разделить на 2 группы: простые и сложные.

Простые тесты — тесты, для запуска и работы не нужен Angular, то есть в тесте не используется TestBed.
Примерами простых тестов могут быть — services, pipes, directives, тестирование helpers, handlers и utils.

Для примера приведём сервис, который возвращает данные:

Простой тест, который проверяет значение метода getUser:

Сложные тесты — тесты, для работы которых нужен Angular или его составляющие.
Примерами сложных тестов — components, services с DI, modules и т.д.

Для примера, используем UserService, который загружает пользователя с помощью GraphQL:

Тест будет эмулировать работу UserApollo и возвращать фиксированное, реактивное значение:

Для теста использовалось marble diagram’ы. Тестирование реактивной логики будет рассмотрено ниже.

Тестирование для асинхронной логики

Так как большая часть Angular использует RxJS, то соответственно нужны механизмы тестирования реактивной/асинхронной логики.

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

Лучшим решением для тестирования асинхронной логики является использование Marble диаграмм.

Подробнее о marble diagrams на reactivex.io

Спасибо Benjamin Cabanes за содержательную статью про marble тестирование — Marble testing Observable Introduction.

Большую часть документации можно найти в примерах rxjs — marble-testing, но приведём ключевые моменты.

Формально marble диаграммы описывают поток событий в виде специально оговорённых символов.

  • - (тире): имитирует течение времени, где одно тире это 10 мс, 5 тире будут соответствовать 50 мс.
  • a-z ( от a до z): испускаемые значения,-а--b-c, где a произойдёт через 20 мс, b через 50мс, c через 70 мс.
  • | (вертикальная черта): знак, означающий завершение потока, -а--| означает, что поток будет завершён через 50 мс.
  • # ( решётка): говорит о появлении ошибки в потоке.
  • () (круглые скобки): позволяют испускать несколько событий в один момент времени, -(ab)-|, где a и b произойдут 20 мс.
  • ^(крышечка сверху): говорит от начале подписки на поток, --^--a-|.
  • ! (восклицательный знак): говорит об окончании подписки на поток, -^-!-.

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

Для использования marble диаграмм есть две функции: hot и cold.

cold()

cold(marbles: string, values?: object, error?: any)

В cold, подписка на поток начинается с самого начала:

cold(-a-b-|, { a: 'Hello', b: 'World' })

Событие a произойдёт через 20 мс, b через 40 мс.

hot()

hot(marbles: string, values?: object, error?: any)

В hot, подписка на поток начинается с верхней крышечки:

hot(-^--a-b-|, { a: 'Hello', b: 'World' })

Событие a произойдёт через 30 мс, b через 50 мс.

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

Тестирование Angular Service

Согласно документации Angular рассмотрим тестирование сервисов.

Создадим 2 связанных сервиса:

  • Multiply — сервис, метод которого перемножает числа
  • Prettify — сервис, который использует multiply, но делает более красивый вывод.

Примером простого Unit тестирования будет тестирование сервиса Multiply:

ng test frontend-testing -t "MultiplyService"

Сервисы с зависимостями

Тестирование Prettify Service уже является чуть более сложной задачей, так как сервис использует другой сервис (multiply).

Первым решением является просто передача в конструктор сервиса экземпляр нужного сервиса:

Однако, данный подход является очень ресурсоёмким.

service = new PrettifyService(new MultiplyService());

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

ng test frontend-testing -t "PrettifyService"

Вторым подходом является использование псевдо-сервиса, для зависимости:

ng test frontend-testing -t "PrettifyFakeService"

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

Третьим решением является эмуляция сервиса с помощью объекта:

Как видно из реализации, в сервис передаётся объект, где на уровне Typescript говориться что fakeService является объектом класса Multiply Service:

const fakeService = { calc: (a, b) => a * b } as MultiplyService;

Если запустить тесты:

ng test frontend-testing -t "PrettifyFakeObjectService"

Четвёртым решением является использование mock с помощью jest:

Формально, мы подменяем оригинальный класс Multiply, на псевдо класс.

Конечно, Jest представляет огромное множество того, что он может “мокировать”, в данном примере мы только подменили класс, но также можно подменять методы, свойства и т.д. Подробнее в документации Jest.

Angular TestBed

В ранее приведённых примерах, были рассмотрены основные подходы по подмене зависимостей без использования Angular. Формально выше описанные подходы будут работать в любом JavaScript/Typescript фреймворке.

Теперь поговорим о том, что предоставляет Angular — TestBed.

The TestBed is the most important of the Angular testing utilities. The TestBed creates a dynamically-constructed Angular test module that emulates an Angular @NgModule.

Как сказано в документации, TestBed одна из важнейших утилит Angular. TestBed создаёт динамический модуль Angular, который эмулирует NgModule.

Использовать TestBed очень просто:

  • Предоставляем в TestBed необходимые Angular компоненты, сервисы и модули
  • С помощью функции inject, получаем DI сервисы
  • С помощью функции createComponent — создаём тестовые Angular компоненты.

Приведём пример, для выше представленного сервиса Multiply с помощью TestBed:

И если для простых сервисов, данные возможности не раскрываются, то для сервисов с DI, использование становиться более очевидным:

В данном примере, мы используем реальные сервисы в обоих случаях.

Однако, создавать mock сервисы с помощью TestBed очень легко, используя jest:

Как видно из примера, вместо того, чтобы использовать реальный MultiplyService, вместо него мы просто подставили объект, эмулировав работу метода calc:

{
provide: MultiplyService,
useValue: {
calc: jest.fn((a, b) => a * b)
}
},

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

Тестирование Http сервисов

Единственной частью, что выходит за рамки обычного тестирования сервисов в Angular — это тестирование Http сервисов.

Основное назначение Http тестов это проверить:

  • типы запросов
  • пути запросов
  • корректность обработки получаемых значений и корректность ответа
  • обработка ошибок

Для этого, создадим простенький сервис, который реализует два запроса:

  • get() — получение текущего авторизованного пользователя
  • update() — обновление текущего пользователя

Для простоты будем считать, что пользователь определяется с помощью сессионной куки или авторизационного токена, который передаётся в заголовке.

Добавим простую реализацию:

Создадим тест для данного http сервиса.

Сначала объявим все используемые переменные и stub’ы:

Добавим в TestBed сервис и получим его имплементацию с помощью inject:

Напишем тест для первого метода UserHttp.get:

Так как Http запрос потенциально имеет 2 ответа — успешно/неуспешно, в тесте проверяется каждая из ситуаций.

Первый тест:

it('should return current user', () => {
service.get().subscribe(data => expect(data).toEqual(userStub));
const req = httpTestingController.expectOne(path);
expect(req.request.method).toEqual('GET');

req.flush(userStub);
});

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

HttpTestingController позволяет создать тестовый запрос с нужным путём.

Метод flush позволяет эмулировать ответ сервера, отправив ответ с переданными данными.

Второй тест:

it('should return user get api error', () => {
service.get().subscribe(
() => {},
data => {
expect(data.error).toEqual(apiErrorStub);
}
);

const req = httpTestingController.expectOne(path);
req.flush(apiErrorStub, apiErrorResponseOptions);
});

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

Трансформация данных

Обычно, формат данных от сервера может не соответствовать данным, используемых в UI. Поэтому при разработке, фронтенд разработчик может трансформировать данные в свои интерфейсы. В данном примере, запрос возвращает сущность User.

get(queryParams?: object): Observable<User | ApiError> {
return this.httpClient.get<User>(userApiRoutes.get, getHttpOptions(queryParams)).pipe(catchError(error => throwError(error)));
}

Но мог быть такой случай, где API возвращало:

{
currentUser: { ... } // Сущность пользователя
otherData: any // Другие данные
}

И соответственно обработчик должен был бы трансформировать ответ.

...
map(response => response.currentUser),
...

Данный пример, приведён во втором методе — update().

Как можно увидеть из реализации, в ответ сервера, передаётся ожидаемая структура данных, которая уже самим Http сервисом будет трансформирована.

req.flush({ currentUser: { ...userStub, email }, status: true });

Общий файл тестирования для UserHttp сервиса будет следующим:

Тестирование Apollo

Если вы используете GraphQL, то скорее всего используете реализацию Apollo. И так как Apollo использует Http запросы на получение данных, приведём тестирование сервиса использующих Apollo.

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

Вспомогательный ApolloResponse содержит предпочтительный вариант ответа от сервера. Все используемые утилиты:

Добавим реализацию UserApollo сервиса:

Как можно увидеть, реализация очень похожа на использование HttpClient:

loadUser(queryParams: object = {}): ApolloResponse<User> {
return this.apollo
.query<{ user: User }>({ query: UserQueries.userRequest.query })
.pipe(
map(result => extractApolloResponse(result, UserQueries.userRequest.keys)),
catchError((error: ApolloError) => throwError(error))
);
}

UserQueries это формально все запросы к Apollo server:

  • keys — список сущностей, которые должны быть выбраны из ответа
  • query — graphql запрос на получение данных.

Тесты, как и в случае с Http запросами нацелены на правильную обработку статуса ответа — успех/неуспех, а также за корректную трансформацию данных.

Создадим тест для UserApollo:

Отметим, что в данном случае мы не проверяем реактивность, а только обработку ответов сервера.

Корректность работы ответов уже протестированы внутри сервиса Apollo (apollo-angular).

Первый тест передаёт данные для успешного выполнения, второй же тест эмулирует ошибку.

it('get() should return user', async done => {
try {
const queryMock = { query: null };
const response: ApolloQueryResult<{ user: User }> = { data: { user: userStub }, loading: false, networkStatus: null, stale: false };
apollo.query = () => of(response as any);

const result: ApolloQueryResult<{ user: User }> = await readFirst(apollo.query<{ user: User }>(queryMock));

expect(result.data.user).toEqual(userStub);

done();
} catch (err) {
done.fail(err);
}
});

Наверное, есть более изящное решение для данных тестов, но как говориться хотя бы так :)

ng test users -t BaseUserApollo

Резюме

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

Во второй части статьи, детально разобрали тестирование Angular сервисов. Привели примеры с использованием mock/stub данных, показали как использовать TestBed для создания mock сервисов.

В конце статьи привели примеры для тестирования Http сервисов, а также привели примеры по тестированию сервисов, использующих GraphQL Apollo.

P.S. Из-за большого объёма статьи, тестирование Angular Component, Pipe и Directive будет рассмотрено в следующих статьях.

Спасибо за внимание!

Исходники

Все исходники находятся на github, в репозитории:

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — testing-services.

git checkout testing-services

Код можно посмотреть в разделе https://github.com/Fafnur/medium-stories/tree/master/apps/frontend/testing/src/app/users

Ссылки

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

Medium: https://medium.com/fafnur
Добавляйтесь в группу ВК: https://vk.com/fafnur
Добавляйтесь в группу в Fb: https://www.facebook.com/groups/fafnur/
Телеграм канал: https://t.me/f_a_f_n_u_r
Twitter: https://twitter.com/Fafnur1
Instagram: https://www.instagram.com/fafnur

Предыдущие статьи:

  1. Angular 9, Universal и Nx. Новые правила сборки SSR приложения.
  2. Кроссплатформенные web storage в Angular 9. Реализация LocalStorage, SessionStorage и Cookies в Angular Universal.
  3. Мультиязычность ngx-translate в Angular 9 c монорепозиторием Nx.
  4. Бесконечный скролл в Angular 9 с помощью Intersection Observer API
  5. Redux в Angular. Управление состояниями в Angular с помощью Ngrx и Nx.
  6. Структура и подходы к организации экшенов, селекторов, редьюсеров и эффектов в Ngrx и Nx

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock