В этой статье я хочу поделиться опытом, который накопил за годы работы с юнит-тестами в Angular. Вот о чём пойдёт речь:

  • Почему важно писать юнит-тесты

  • Зачем мокать зависимости и каковы плюсы и минусы

  • Что такое SIFERS и почему это важно

  • Что такое Angular Testing Library (ATL)

  • Как тестировать с помощью SIFERS

  • Как получать элементы DOM и генерировать события

  • Что такое jest-auto-spies и observer-spy

Почему юнит-тесты важны?

Я видел множество приложений, в которых нет ни одного юнит-теста. Позвольте объяснить, почему их необходимо писать. Юнит-тесты — неотъемлемая часть любого приложения, они дают уверенность и подтверждение того, как должен вести себя код. Они также работают как документация, которая помогает понять, что делает код. Хорошие тесты помогают лучше разобраться в архитектуре решения. Если не удаётся написать юнит-тест, это зачастую свидетельствует о плохом дизайне и указывает на необходимость рефакторинга.

Чем ближе тесты к реальному пользовательскому сценарию, тем больше уверенности они дают.

Моки

Чтобы сосредоточиться на тестируемом коде, необходимо правильно мокать внешние зависимости. Например, следует мокать все сервисы или компоненты, которые использует тестируемый компонент. Импортировать реальные реализации — не рекомендуется (подробнее об этом дальше). Однако можно спокойно импортировать чистые компоненты, если они используются как зависимые. Также можно импортировать общий модуль, включающий все нужные зависимости.

Минусы отсутствия моков

  • Вы будете использовать настоящую реализацию и будете вынуждены мокать все её свойства, методы и так далее. Вы окажетесь в «кроличьей норе» — начнёте мокировать классы, которые находятся глубоко в цепочке зависимостей.

  • Вам придётся объявлять вложенные компоненты и предоставлять все их зависимости.

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

  • Состояние тестов может быть некорректным.

  • Ваши тесты могут внезапно начать падать, если что-то изменится в нижележащих зависимостях.

  • Отладка таких тестов будет крайне затруднена в случае ошибок.

SIFERS

Начнём с настройки. Вместо beforeEach для настройки окружения я использую SIFERS.

SIFERS (Simple Injectable Functions Explicitly Returning State) — это простые внедряемые функции, которые явно возвращают состояние и позволяют точно управлять тестовой средой.

SIFERS используют setup-функцию, которая может принимать аргументы для настройки окружения. Это главное отличие от beforeEach. beforeEach вызывается автоматически перед каждым тестом, не давая возможности задать мок-значения зависимостей, необходимых при инициализации компонента или сервиса.

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

Я стараюсь, чтобы количество аргументов у setup-функции было минимальным. Если параметров становится много, стоит использовать интерфейс — это помогает поддерживать читаемость и порядок в коде.

Пример простой setup-функции может выглядеть так:

function setup({ value = false }: { value?: boolean } = {}) {
  const mockService: Partial<RealService> = {
    someFunction: jest.fn().mockReturnValue(value ? 'foo' : 'bar'),
  };

  const service = new MyService(mockService);
  return {
    service,
    mockService,
  };
}
...
const { service } = setup({ value: true }); 

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

it('returns foo when feature flag is enabled', () => {
  // Передаём true в setup, чтобы 
  // someFunction вернула 'foo'
  const { service } = setup(true);
  expect(service.someFunction()).toEqual('foo');
});

it('returns bar when feature flag is disabled', () => {
  // Передаём false в setup, чтобы
  // someFunction вернула 'bar'
  const { service } = setup(false);
  expect(service.someFunction()).toEqual('bar');
});

Я не буду подробно останавливаться на SIFERS, так как об этом уже отлично рассказано в статье на Medium.

Angular Testing Library (ATL)

Я большой поклонник библиотеки ATL и стараюсь использовать её во всех своих проектах. ATL — это лёгковесное решение для тестирования компонентов в Angular. В описании ATL говорится следующее:

Angular Testing Library предоставляет утилиты для взаимодействия с Angular-компонентами так, как это делал бы пользователь.
— Тим Дешрайвер

Начнём с настройки модуля. Вместо использования TestBed.configureTestingModule здесь нужно применять метод render. Обратите внимание: render следует использовать только для тестирования компонентов. Сервисы можно тестировать без ATL и без метода render.

На GitHub есть множество примеров использования ATL — с компонентами, формами, Input/Output, NGRX, директивами, Angular Material, Signals и так далее. Тим Дешрайвер также написал подробную статью с множеством примеров, которую я рекомендую прочитать.

Вот пример с использованием SIFER и метода render. Обратите внимание, что я использую метод createSpyFromClass для мокирования классов — он автоматически создаёт моки всех функций, свойств и даже observable-потоки. Подробнее об этом я расскажу позже.

import { render } from '@testing-library/angular';
import { createSpyFromClass } from 'jest-auto-spies';
// ... другие импорты

async function setup({ enableFlag = false }) {
  const mockMySomeService = createSpyFromClass(MyService);
  mockMySomeService.doSomething.mockReturnValue(enableFlag);

  const { fixture } = await render(AppComponent, {
    imports: [...],
    providers: [{ 
      provide: MyService, 
      useValue: mockMySomeService 
    }],
  });
}

Определение declarations

Как и в TestBed, список компонентов и директив можно передавать через declarations. Синтаксис аналогичен.

Однако, если вы импортируете модуль, который уже содержит нужный компонент, необходимо установить флаг excludeComponentDeclaration в true.

Ниже — другие полезные свойства, которые можно передавать в render. Полный список — в документации.

Установка providers

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

Настройка @Input/@Output

Для задания свойств @Input и @Output можно использовать componentProperties — это позволяет установить оба типа свойств одновременно.

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

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

Сервисы можно тестировать без ATL и TestBed — зависимости просто передаются в конструктор. Вместо этого можно напрямую передать мокированные зависимости в конструктор тестируемого сервиса. Ниже приведён пример с моками для LogService и TableService.

// some.service.ts
@Injectable({ providedIn: 'root' })
export class SomeService {
  constructor(
    private readonly logService: LogService, 
    private readonly tableService: TableService) {}
}

// some.service.spec.ts
async function setup() {
  const mockLogService = createSpyFromClass(LogService);
  const mockTableService = createSpyFromClass(TableService);

  const service = new SomeService(
    mockLogService, 
    mockTableService
  );

  return {
    service,
    mockLogService,
    mockTableService,
  };
}

Тестирование компонентов

Компонент должен тестироваться через своё публичное API. Приватные API напрямую не тестируются. Чтобы корректно протестировать компонент, необходимо максимально использовать DOM. Именно так взаимодействует с приложением пользователь, и задача теста — максимально точно это воспроизвести. Angular Testing Library (ATL) отлично помогает в этом. Такой подход также называют «поверхностным тестированием» (shallow testing).

Не стоит рассматривать все публичные методы компонента как публичное API, доступное для вызова из юнит-тестов. Эти методы публичны только для шаблона. Они вызываются из DOM (например, при клике на кнопку) — и именно так их и следует тестировать.

Пример компонента, который нужно протестировать:

// app-foo.component.ts
@Component({
  selector: 'app-foo',
  template: `
    <input 
      data-testid='my-input'
      (keydown)='handleKeyDown($event)' />`
})
export class FooComponent {
  constructor(private readonly someService: SomeService) {}

  handleKeyDown(value: string) {
    this.someService.foo(value);
  }
}

Пример setup-функции в стиле SIFER:

// app-foo.component.spec.ts
async function setup() {
  const mockSomeService = createSpyFromClass(SomeService);
  const { fixture } = await render(FooComponent, {
    providers: [{ 
      provide: SomeService, 
      useValue: mockSomeService 
    }],
  });

  return {
    fixture,
    mockSomeService,
    fixture.componentInstance
  }
}

Не делайте так — в этом тесте метод вызывается напрямую, минуя DOM. Этот тест напрямую обращается к публичному методу, минуя шаблон. Вы можете вообще удалить DOM-элемент <input>, и тест всё равно будет проходить. Это ложноположительный тест, не имеющий практического смысла.

it('emits a value', async () => {
  const { mockSomeService, component } = await setup(...);
  component.handleKeyDown(value);

  expect(mockSomeService.foo)
    .toHaveBeenCalledWith(value);
})

Правильный способ протестировать эту функцию — использовать screen для доступа к DOM-элементу и userEvent для имитации взаимодействия.

import { screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

it('emits a value', async () => {
  const { mockSomeService } = await setup();
  const textbox = screen.getByTestId('my-input');

  const value = 'foo';
  await userEvent.type(textbox, value);
  await userEvent.keyboard('{Enter}'); 

  expect(mockSomeService.foo).toHaveBeenCalledWith(value);
});

Получение элементов DOM с помощью screen

API screen предоставляет множество мощных функций для поиска элементов в DOM. Такие функции, как waitFor или findBy, возвращают промис и могут использоваться для поиска элементов, чья видимость меняется динамически в зависимости от условий.

Рекомендуемый порядок запросов к элементам (см. полную документацию для приоритетов и описаний):

Вызов пользовательских действий через DOM

ATL предоставляет два API для генерации событий:

Рекомендуется использовать userEvent, а не fireEvent (из Events API). Как указано в документации, основное различие такое:

  • fireEvent отправляет одно DOM-событие,

  • а userEvent симулирует полноценное взаимодействие пользователя — с цепочкой событий и дополнительными проверками.

jest-auto-spies

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

Пример ниже использует метод createSpyFromClass для создания spy-класса.

Если вам нужно напрямую передать зависимости в модуль, можно воспользоваться provideAutoSpy(MyClass) — это сокращение для { provide: MyClass, useValue: createSpyFromClass(MyClass) }.

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

Вот несколько примеров:

Создание простого spy-класса:

const mockMyService = createSpyFromClass(MyService);

Создание spy-класса и эмуляция события next в observable:

const mockMyService = createSpyFromClass(MyService, {
    observablePropsToSpyOn: ['foo$'],
});

mockMyService.foo$.nextWith('bar');

Создание spy-класса с моком метода:

const mockMyService = createSpyFromClass(MyService, {
    methodsToSpyOn: ['foo'],
});

mockMyService.foo.mockReturnValue('bar');

Больше вспомогательных функций и примеров — в документации jest-auto-spies.

Использование observer-spy вместо subscribe / отказ от колбэка done

Для тестирования асинхронного кода я применяю subscribeSpyTo из библиотеки observer-spy вместо обычной подписки на observable. Это также позволяет полностью отказаться от колбэка done.

Функция done применялась для тестирования асинхронного кода, но на практике она часто приводит к ошибкам и нестабильным результатам. Это может вызывать ложноположительные тесты (прохождение при фактической ошибке), или наоборот — неожиданные таймауты.

Стоит также отметить, что существует отдельное правило линтинга, запрещающее использование done.

В observer-spy реализован механизм отписки, который срабатывает автоматически в afterEach.

Вот примеры использования библиотеки, взятой из readme:

const fakeObservable = of('first', 'second', 'third');
const observerSpy = subscribeSpyTo(fakeObservable);

// No need to unsubscribe, as the have an auto-unsubscribe in place.
// observerSpy.unsubscribe();

// Expectations:
expect(observerSpy.getFirstValue()).toBe('first');
expect(observerSpy.receivedNext()).toBeTruthy();
expect(observerSpy.getValues()).toEqual(fakeValues);
expect(observerSpy.getValuesLength()).toBe(3);
expect(observerSpy.getValueAt(1)).toBe('second');
expect(observerSpy.getLastValue()).toBe('third');
expect(observerSpy.receivedComplete()).toBeTruthy();

Заключение

В статье я поделился практическим опытом написания юнит-тестов в Angular и объяснил, зачем они вообще нужны. Разобрали, как правильно мокировать зависимости, чтобы избежать цепочек реальных реализаций. Рассмотрели SIFERS — подход, который упрощает настройку тестового окружения и делает код тестов чище. Обсудили Angular Testing Library (ATL), которая помогает писать тесты, максимально приближённые к пользовательским сценариям. Плюс — полезные тулзы вроде jest-auto-spies и observer-spy. Если хотите копнуть глубже — ссылки и примеры в документации к этим библиотекам.


Иногда баг не в коде — а в том, как вы к нему подходите. Иногда тест «зелёный», но продукт всё равно не работает как надо. А иногда всё просто слишком медленно, и вы не понимаете почему.

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

Список всех открытых уроков от преподавателей-практиков смотрите в календаре.

Комментарии (0)