Angular тестирование component с помощью Jest

Aleksandr Serenko
F.A.F.N.U.R
Published in
8 min readMay 6, 2020
Angular Components + Nx monorepo + Jest

В данной статье поговорим о тестировании Angular component. Рассмотрим основные виды тестов и приведём соответствующие примеры.

Данная статья построена на официальной документации angular, подробнее в статье — Angular testing.

Тестирование компонентов в Angular формально является задачей тестирования двух сущностей: Html шаблона и Typescript класса. И адекватное тестирование компонента заключается проверке корректной работы шаблона и класса.

При тестировании компонента необходимо иметь доступ к DOM и поэтому необходимо использовать TestBed, который позволит создать тестовый компонент в Angular, которому будут доступны DI.

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

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

Подробнее про тестирование сервисов на — Angular testing, а также в статье — Тестирование сервисов в Angular с помощью Jest. Тестирование реактивной/асинхронной логики.

Простые компоненты с Input/Output

Компоненты, которые содержат Input’ы и Output’ы также легко тестировать. Создадим компонент, который получает на вход объект, а при клике на кнопку отдаёт этот объект родителю:

Тесты также можно использовать без TestBed:

Как видно из примера, мы сначала создали компонент, а затем свойству hero присвоили объект, соответствующий данному типу.

Так как eventEmitter это observable, то подпишемся на изменения и в результате произошедшего события, selected должен испустить одно событие, аргументами которого будет наш ранее инициализированный объект Hero.

Компоненты с зависимостями

При тестировании компонента, в котором есть зависимости необходимо использовать TestBed.

Создадим простой компонент Welcome, который либо отображает имя авторизированного пользователя или предлагает авторизоваться.

Для определения авторизации пользователя в компоненте будем использовать сервис.

FakeUserService — будет простой эмуляцией сервиса пользователя:

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

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

TestBed.configureTestingModule({
providers: [WelcomeComponent, FakeUserService]
}).compileComponents();

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

component = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(FakeUserService);

Отметим, что использование Angular Component’а как провайдера нужно лишь с той целью, чтобы максимально соответствовать официальной документации. В реальных проектах этого делать не стоит.

В данном случае, было рассмотрено 3 случая.
Первый, когда компонент ещё не инициализирован:

it('should not have welcome message after construction', () => {
expect(component.welcome).toBeUndefined();
});

Второй, когда пользователь авторизирован и и компонент должен отобразить приветствие:

it('should welcome logged in user after Angular calls ngOnInit', () => {
component.ngOnInit();
expect(component.welcome).toContain(userService.getUser().username);
});

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

it('should ask user to log in if not logged in after ngOnInit', () => {
userService.isLoggedIn = jest.fn(() => false);
component.ngOnInit();
expect(component.welcome).not.toContain(userService.getUser().username);
expect(component.welcome).toContain('log in');
});

В рассмотренном примере, у нас был простой случай, где FakeUserService не имел зависимостей. Но если таковые имелись, то тогда нужно было бы импортировать в TestBed зависимости для FakeUserService. Однако, подобный подход является плохой практикой, так как при тестировании компонента не нужно воспроизводить реальный сервис. Основная цель — проверить корректность работы компонента, а не зависимостей.

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

Создадим усложнённый вариант WelcomeComponent’а, где будет использоваться FakeHardUserService:

Сервис FakeHardUserService будет использовать FakeUserService:

Как видно из реализации, FakeHardUserService является фасадом над FakeUserService.

Сам тест будет выглядеть так:

В данном случае, для FakeHardUserService используется пустой объект.

Отметим, вместо пустого объекта можно использовать реальный класс — FakeHardUserServiceMock:

providers: [
{
provide: FakeHardUserService,
useClass: FakeHardUserServiceMock
}
]

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

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

it('should welcome logged in user after Angular calls ngOnInit', () => {
userService.getUser = jest.fn(() => userStub);
userService.isLoggedIn = jest.fn(() => true);
component.ngOnInit();
expect(component.welcome).toContain(userService.getUser().username);
});

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

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

Создадим простой компонент:

Сгенерированный файл теста:

Для создания fixture компонента используется метод createComponent:

fixture = TestBed.createComponent(BannerComponent);

Объект класса берется из fixture:

component = fixture.componentInstance;

Для данного компонента есть три теста.

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

it('should create', () => {
expect(component).toBeTruthy();
});

Отметим, что в Jest нужно проверять именно — toBeTruthy(), а не toBeDefined(), как было в Jasmine.

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

Второй тест, проверяет правильность отображения:

it('should contain "banner works!"', () => {
const bannerElement = fixture.nativeElement;
expect(bannerElement.textContent).toContain('banner works!'); });

Так как это сгенерированный компонент, мы должны увидеть текст, который был добавлен в шаблон — banner works!.

Третий тест, показывает как выбирать элементы из шаблона:

it('should have <p> with "banner works!"', () => {
const bannerElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});

В данном случае, мы ищем селектор p и ожидаем, что он его текст равен banner works!.

Отметим, что во всех тестах используется — fixture.nativeElement. В данном случае, корректность работы тестов гарантируется только в для браузера. Если запускать Angular на других платформах (server, …), тогда возможны случаи где не все свойства и методы HTMLElement API будут корректно работать.

Для решения данной проблемы, можно использовать debugElement:

const bannerElement = fixture.debugElement;

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

Создадим чуть усложнённую версию banner компонента, в котором будем использовать переменные для отображения в шаблоне компонента:

Создадим тест:

Так как createComponent, не связывает данные шаблона и компонента, необходимо вызвать метод detectChanges() в fixture каждый раз при изменении шаблона:

fixture.detectChanges();

Можно настроить автоматическое определение изменений:

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

Если для теста:

it('should display original title', () => {
expect(h1.textContent).toContain(component.title);
});

Тест сработает из-за первичного detectChanges(), но второй тест уже явно вызывает detectChanges, иначе в фикстуре будет предыдущее значение и тест завершится ошибкой.

it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});

Тестирование асинхронных binding свойств

При тестировании асинхронной логики есть 2 подхода:

  • fakeAsync & timer
  • marble tests

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

Второй подход это использование Marble тестирования. Данный подход подходит для создания простых и сложных кейсов тестирования реактивной логики. Подробнее в статье — Тестирование сервисов в Angular с помощью Jest. Тестирование реактивной/асинхронной логики.

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

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

В данном случае, мы проверяем корректность работы preload$. Для того чтобы просто проверить корректность создания компонента достаточно просто обернуть сервисы и вернуть Observable:

Вопрос: Нужно ли тестировать различные состояния preload$?

На самом деле этого делать не нужно, так как результат работы preload$ зависит от корректной работы сервисов EventFacade и UserFacade.

preload$ = combineLatest([
this.eventFacade.eventLast$,
this.userFacade.user$
]).pipe(
map<[Event, User], boolean>(data => data.every(value => !!value))
);

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

Подобный тест будет выглядеть так:

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

{
provide: EventFacade,
useValue: {
eventLast$: cold('-a|', { a: eventStub })
}
},
{
provide: UserFacade,
useValue: {
user$: cold('-a|', { a: userStub })
}
}

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

getTestScheduler().flush();
fixture.detectChanges();

Вызов данных методов, позволит обновить все Observable в компоненте.

Тестирование компонентов с DOM событиями (input, change …)

Создадим компонент, в котором будет отображаться баннер, а также форма редактирования заголовка (по аналогии с hero-detail.component):

Создадим файл теста:

Как видно, сначала были получены HTMLElement’ы из фикстуры:

const hostElement = fixture.nativeElement;
const nameInput = hostElement.querySelector('input');
const nameDisplay = hostElement.querySelector('h2');

Затем мы создали новое значение input’а:

const newInputVal = 'quick BROWN fOx';
nameInput.value = newInputVal;

Для того, чтобы событие отработало корректно, нужно создать новое DOM событие, которое изменит шаблон:

nameInput.dispatchEvent(new Event('input'));

И соответственно, для обновления шаблона в фикстуре:

fixture.detectChanges();

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

Создадим компонент, при клике на который меняется текст:

Файл теста:

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

const divElement = fixture.debugElement.query(By.css('div'));

И с помощью метода triggerEventHandler, мы вызываем событие DOM:

divElement.triggerEventHandler('click', null);

triggerEventHandler — может испускать любые связанные события Angular.

Тестирование компонентов с внешними файлами

Возьмём компонент:

@Component({
selector: 'medium-stories-banner-edit',
templateUrl: './banner-edit.component.html',
styleUrls: ['./banner-edit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerEditComponent {}

Данный компонент использует внешние файлы:

  • templateUrl — внешний html файл, в котором описан шаблон компонента
  • styleUrls — путь к массиву css (scss) файлов

Для того, чтобы TestBed корректно отработал во всех платформах, необходимо запускать метод — compileComponents().

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

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

И сам используемый компонент:

Файл теста:

Сначала мы подключаем компоненты:

TestBed.configureTestingModule({
declarations: [BannerListComponent, BannerDetailsComponent]
}).compileComponents();

Затем, выбираем для удобства все созданные дочерние компоненты:

bannersDetails = fixture.debugElement.queryAll(By.css('.banner-details'));

И в тесте вызываем событие клика на первый элемент коллекции banners:

it('should raise selected event when clicked', () => {
bannersDetails[0].triggerEventHandler('click', null);
expect(component.selectedBanner.name).toBe(component.banners[0].name);
});

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

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

Создадим компонент, который при клике на кнопку будет перенаправлять на страницу с пользователями — /users.

В документации, приведены какие-то сложные пути за отслеживанием изменений путей. Вместо этого будем использовать RouterTestingModule.

Так как в тесте важно проверить корректность путей, а не сам механизм перенаправления, то достаточно следующего:

it('should create', () => {
fixture.ngZone.run(() => {
component.onClick();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(router.url).toBe('/users');
});
});
});

Тест запускается в fixture.ngZone.run из-за того, что роутер связан c ngZone.

Вызов метода onClick — запускает перенаправление. И так как навигация не является синхронной операцией ждём выполнения всех задач и проверяем url:

fixture.whenStable().then(() => {
expect(router.url).toBe('/users');
});

Замечание

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

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

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

Можно привести небольшую аналогию с back-end’а — тонкие контроллеры. Контроллер должен получить данные из запроса и передать их в нужный сервис. Дождаться ответа и вернуть результат.

Все это объясняется тем, что Angular Components стремятся быть нативными Web Component’ами., с другой увеличивает время поддержки и рефакторинга.

Резюме

В данной статье рассмотрели основные нюансы при тестировании Angular Component. Привели примеры тестов на Jest для следующих случаев:

  • Компоненты с Input/Output
  • Компоненты с зависимостями
  • Тестирование DOM’а компонентов
  • Тестирование binding свойств
  • Тестирование асинхронных binding свойств
  • Тестирование компонентов с DOM событиями
  • Тестирование компонентов с click
  • Тестирование компонентов с внешними файлами
  • Тестирование компонента с дочерними компонентами
  • Тестирование компонентов с Router

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

Исходники

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

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

git checkout testing-components

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

Ссылки

Подписывайтесь на блог, чтобы не пропустить новые статьи про 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. Redux в Angular. Управление состояниями в Angular с помощью Ngrx и Nx.
  2. Структура и подходы к организации экшенов, селекторов, редьюсеров и эффектов в Ngrx и Nx.
  3. Тестирование сервисов в Angular с помощью Jest. Тестирование реактивной/асинхронной логики.
  4. Тестирование Ngrx store в Angular. Методы и подходы для упрощения тестирование stat’ов Ngrx в Nx.
  5. Сборка Typescript приложения с помощью Webpack.
  6. Локализация в Angular 9 с помощью angular/localize с universal.
  7. Архитектура enterprise Angular приложений с использованием монорепозитория Nx.

--

--

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

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