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