Angular тестирование component с помощью Jest
Тестирование компонентов в Angular формально является задачей тестирования двух сущностей: Html шаблона и Typescript класса. И адекватное тестирование компонента заключается проверке корректной работы шаблона и класса.
При тестировании компонента необходимо иметь доступ к DOM и поэтому необходимо использовать TestBed, который позволит создать тестовый компонент в Angular, которому будут доступны DI.
Создадим компонент, который будет отображать сообщение в зависимости нажата была кнопка или нет:
lightswitch.component.html
<div>
<button (click)="clicked()">Click me!</button>
<span>{{ message }}</span>
</div>
lightswitch.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'medium-stories-lightswitch',
templateUrl: './lightswitch.component.html',
styleUrls: ['./lightswitch.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LightswitchComponent {
isOn = false;
clicked() {
this.isOn = !this.isOn;
}
get message() {
return `The light is ${this.isOn ? 'On' : 'Off'}`;
}
}
И так как класс не имеет зависимостей, его можно тестировать как сервис, простым созданием:
lightswitch.component.spec.ts
import { LightswitchComponent } from './lightswitch.component';
describe('LightswitchComponent', () => {
it('#clicked() should toggle #isOn', () => {
const comp = new LightswitchComponent();
expect(comp.isOn).toBeFalsy();
comp.clicked();
expect(comp.isOn).toBeTruthy();
comp.clicked();
expect(comp.isOn).toBeFalsy();
});
it('#clicked() should set #message to "is on"', () => {
const comp = new LightswitchComponent();
expect(comp.message).toMatch(/is off/i);
comp.clicked();
expect(comp.message).toMatch(/is on/i);
});
});
Простые компоненты с Input/Output
Компоненты, которые содержат Input’ы и Output’ы также легко тестировать. Создадим компонент, который получает на вход объект, а при клике на кнопку отдаёт этот объект родителю:
dashboard-hero.component.html
<div>
<button (click)="onSelectHero()">Select hero</button>
</div>
dashboard-hero.component.ts
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
export interface Hero {
/**
* Hero id
*/
id: number;
/**
* Hero name
*/
name: string;
}
@Component({
selector: 'medium-stories-dashboard-hero',
templateUrl: './dashboard-hero.component.html',
styleUrls: ['./dashboard-hero.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardHeroComponent {
@Input() hero: Hero;
@Output() selected = new EventEmitter();
onSelectHero(): void {
this.selected.emit(this.hero);
}
}
Тесты также можно использовать без TestBed
:
import { DashboardHeroComponent, Hero } from './dashboard-hero.component';
describe('DashboardHeroComponent', () => {
it('raises the selected event when clicked', () => {
const comp = new DashboardHeroComponent();
const hero: Hero = { id: 42, name: 'Test' };
comp.hero = hero;
comp.selected.subscribe(selectedHero => expect(selectedHero).toBe(hero));
comp.onSelectHero();
});
});
Как видно из примера, мы сначала создали компонент, а затем свойству hero присвоили объект, соответствующий данному типу.
Так как eventEmitter
это observable
, то подпишемся на изменения и в результате произошедшего события, selected должен испустить одно событие, аргументами которого будет наш ранее инициализированный объект Hero.
Компоненты с зависимостями
При тестировании компонента, в котором есть зависимости необходимо использовать TestBed.
Создадим простой компонент Welcome, который либо отображает имя авторизированного пользователя или предлагает авторизоваться.
Для определения авторизации пользователя в компоненте будем использовать сервис.
welcome.component.html
<div *ngIf="welcome">{{ welcome }}</div>
welcome.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FakeUserService } from '../../services/fake-user.service';
@Component({
selector: 'medium-stories-welcome',
templateUrl: './welcome.component.html',
styleUrls: ['./welcome.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WelcomeComponent implements OnInit {
/**
* Welcome
*/
welcome: string;
constructor(private userService: FakeUserService) {}
ngOnInit(): void {
this.welcome = this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.getUser().username : 'Please log in.';
}
}
FakeUserService
— будет простой эмуляцией сервиса пользователя:
fake-user.service.ts
import { Injectable } from '@angular/core';
import { User } from '@medium-stories/entities';
@Injectable()
export class FakeUserService {
/**
* User
*/
private user: User;
constructor() {
this.user = {
created: '2020-03-06',
email: 'ivan@dorn.ru',
phone: '9231002020',
id: 1,
username: 'Ivan Dorn',
updated: '2020-03-06'
};
}
getUser(): User {
return this.user;
}
isLoggedIn(): boolean {
return this.user != null;
}
logout(): void {
this.user = null;
}
}
user.interface.ts
/**
* User interface
*/
export interface User {
/**
* Created at
*/
created: string;
/**
* Email
*/
email?: string;
/**
* Phone
*/
phone: string;
/**
* ID
*/
id: number;
/**
* Password
*/
password?: string;
/**
* Username
*/
username: string;
/**
* Updated at
*/
updated: string;
}
Создадим тесты, который будет проверять работоспособность нашего компонента:
welcome.component.spec.ts
import { TestBed, async } from '@angular/core/testing';
import { FakeUserService } from '../../services/fake-user.service';
import { WelcomeComponent } from './welcome.component';
describe('WelcomeComponent', () => {
let component: WelcomeComponent;
let userService: FakeUserService;
beforeEach(async(() => {
TestBed.configureTestingModule({
providers: [WelcomeComponent, FakeUserService]
}).compileComponents();
component = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(FakeUserService);
}));
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');
});
});
Как видно из теста, сначала идёт объявление всех используемых сервисов и компонент:
TestBed.configureTestingModule({
providers: [WelcomeComponent, FakeUserService]
}).compileComponents();
Затем, мы получаем экземпляры сервисов, которые будут участвовать в тестировании:
component = TestBed.inject(WelcomeComponent);
userService = TestBed.inject(FakeUserService);
В данном случае, было рассмотрено 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:
welcome-mock.component.html
<div *ngIf="welcome">{{ welcome }}</div>
welcome-mock.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FakeHardUserService } from '../../services/fake-hard-user.service';
@Component({
selector: 'medium-stories-welcome-mock',
templateUrl: './welcome-mock.component.html',
styleUrls: ['./welcome-mock.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WelcomeMockComponent implements OnInit {
/**
* Welcome
*/
welcome: string;
constructor(private userService: FakeHardUserService) {}
ngOnInit(): void {
this.welcome = this.userService.isLoggedIn() ? 'Welcome, ' + this.userService.getUser().username : 'Please log in.';
}
}
Сервис FakeHardUserService будет использовать FakeUserService:
fake-hard-user.service.ts
import { Injectable } from '@angular/core';
import { User } from '@medium-stories/entities';
import { FakeUserService } from './fake-user.service';
@Injectable()
export class FakeHardUserService {
constructor(private userService: FakeUserService) {}
getUser(): User {
return this.userService.getUser();
}
isLoggedIn(): boolean {
return this.userService.isLoggedIn();
}
logout(): void {
this.userService.logout();
}
}
Как видно из реализации, FakeHardUserService является фасадом над FakeUserService.
Сам тест будет выглядеть так:
welcome-mock.component.spec.ts
import { TestBed, async } from '@angular/core/testing';
import { userStub } from '@medium-stories/users/testing';
import { FakeHardUserService } from '../../services/fake-hard-user.service';
import { WelcomeMockComponent } from './welcome-mock.component';
describe('WelcomeMockComponent', () => {
let component: WelcomeMockComponent;
let userService: FakeHardUserService;
beforeEach(async(() => {
TestBed.configureTestingModule({
providers: [
WelcomeMockComponent,
{
provide: FakeHardUserService,
useValue: {}
}
]
}).compileComponents();
component = TestBed.inject(WelcomeMockComponent);
userService = TestBed.inject(FakeHardUserService);
}));
it('should not have welcome message after construction', () => {
expect(component.welcome).toBeUndefined();
});
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);
});
it('should ask user to log in if not logged in after ngOnInit', () => {
userService.getUser = jest.fn(() => null);
userService.isLoggedIn = jest.fn(() => false);
component.ngOnInit();
expect(component.welcome).not.toContain(userStub.username);
expect(component.welcome).toContain('log in');
});
});
В данном случае, для FakeHardUserService используется пустой объект.
Отметим, вместо пустого объекта можно использовать реальный класс — FakeHardUserServiceMock:
fake-hard-user.service.spec.ts
import { User } from '@medium-stories/entities';
import { userStub } from '@medium-stories/users/testing';
import { FakeHardUserService } from './fake-hard-user.service';
class FakeHardUserServiceMock extends FakeHardUserService {
constructor() {
super(null);
}
getUser(): User {
return userStub;
}
isLoggedIn(): boolean {
return true;
}
logout(): void {}
}
describe('FakeHardUserService', () => {
let service: FakeHardUserService;
beforeEach(() => {
service = new FakeHardUserServiceMock();
});
it('getUser() should return user', () => {
expect(service.getUser()).toEqual(userStub);
});
});
providers: [
{
provide: FakeHardUserService,
useClass: FakeHardUserServiceMock
}
]
Примеры тестов для 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’а, необходимо использовать фикстуры.
Создадим простой компонент:
banner.component.html
<p>banner works!</p>
banner.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
@Component({
selector: 'medium-stories-banner',
templateUrl: './banner.component.html',
styleUrls: ['./banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerComponent implements OnInit {
constructor() {}
ngOnInit(): void {}
}
Сгенерированный файл теста:
banner.component.spec.ts
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { BannerComponent } from './banner.component';
describe('BannerComponent', () => {
let component: BannerComponent;
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BannerComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should contain "banner works!"', () => {
const bannerElement = fixture.nativeElement;
expect(bannerElement.textContent).toContain('banner works!');
});
it('should have with "banner works!"', () => {
const bannerElement = fixture.nativeElement;
const p = bannerElement.querySelector('p');
expect(p.textContent).toEqual('banner works!');
});
});
Для создания fixture компонента используется метод createComponent:
fixture = TestBed.createComponent(BannerComponent);
Объект класса берется из fixture:
component = fixture.componentInstance;
Для данного компонента есть три теста.
Первый тест проверяет корректное создание компонента:
it('should create', () => {
expect(component).toBeTruthy();
});
Данный тест формально проверяет доступность всех зависимостей, директив, пайпов и компонент, которые были использованы. Часто это необходимо при рефакторинге, чтобы убедиться что все корректно работает.
Второй тест, проверяет правильность отображения:
it('should contain "banner works!"', () => {
const bannerElement = fixture.nativeElement;
expect(bannerElement.textContent).toContain('banner works!'); });
Так как это сгенерированный компонент, мы должны увидеть текст, который был добавлен в шаблон — banner works!.
Третий тест, показывает как выбирать элементы из шаблона:
it('should have 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 компонента, в котором будем использовать переменные для отображения в шаблоне компонента:
banner-title.component.html
<h1>{{ title }}</h1>
banner-title.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'medium-stories-banner-title',
templateUrl: './banner-title.component.html',
styleUrls: ['./banner-title.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerTitleComponent {
title = 'My title';
}
Создадим тест:
banner-title.component.spec.ts
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { BannerTitleComponent } from './banner-title.component';
describe('BannerTitleComponent', () => {
let component: BannerTitleComponent;
let fixture: ComponentFixture;
let h1: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BannerTitleComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerTitleComponent);
component = fixture.componentInstance;
h1 = fixture.nativeElement.querySelector('h1');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display original title', () => {
expect(h1.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = 'Test Title';
fixture.detectChanges();
expect(h1.textContent).toContain('Test Title');
});
});
Так как createComponent, не связывает данные шаблона и компонента, необходимо вызвать метод detectChanges() в fixture каждый раз при изменении шаблона:
fixture.detectChanges();
Можно настроить автоматическое определение изменений:
ComponentFixtureAutoDetect.spec.ts
TestBed.configureTestingModule({
declarations: [ BannerComponent ],
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
});
Если для теста:
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. Тестирование реактивной/асинхронной логики.
В данном примере мы будем использовать второй подход.
Усложним пример, и допустим в компоненте есть прелоадер. И необходимо дождаться возврата значений, и только тогда отобразить данные:
user-box.component.html
<ng-container *ngTemplateOutlet="userBoxTpl; context: { loading: preload$ | async }"></ng-container>
<ng-template #userBoxTpl let-loading="loading">
<ng-container *ngIf="loading; else preloaderTpl">
<ng-container *ngTemplateOutlet="userBoxCardTpl"></ng-container>
</ng-container>
</ng-template>
<ng-template #userBoxCardTpl let-loading="loading">
<h3>{{ user.username }}</h3>
</ng-template>
<ng-template #preloaderTpl>
<h3>Loading...</h3>
</ng-template>
user-box.component.ts
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { Event, User } from '@medium-stories/entities';
import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';
@Component({
selector: 'medium-stories-user-box',
templateUrl: './user-box.component.html',
styleUrls: ['./user-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserBoxComponent {
@Input() user: User;
preload$ = combineLatest([this.eventFacade.eventLast$, this.userFacade.user$]).pipe(
map<[Event, User], boolean>(data => {
return data.every(value => !!value);
})
);
constructor(private eventFacade: EventFacade, private userFacade: UserFacade) {}
}
В данном случае, мы проверяем корректность работы preload$. Для того чтобы просто проверить корректность создания компонента достаточно просто обернуть сервисы и вернуть Observable
:
user-box.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';
import { userStub } from '@medium-stories/users/testing';
import { UserBoxComponent } from './user-box.component';
describe('UserBoxComponent', () => {
let component: UserBoxComponent;
let eventFacade: EventFacade;
let userFacade: UserFacade;
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [UserBoxComponent],
providers: [
{
provide: EventFacade,
useValue: {
eventLast$: of()
}
},
{
provide: UserFacade,
useValue: {
user$: of()
}
}
]
}).compileComponents();
eventFacade = TestBed.inject(EventFacade);
userFacade = TestBed.inject(UserFacade);
}));
beforeEach(() => {
fixture = TestBed.createComponent(UserBoxComponent);
component = fixture.componentInstance;
component.user = userStub;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Вопрос: Нужно ли тестировать различные состояния preload$?
На самом деле этого делать не нужно, так как результат работы preload$ зависит от корректной работы сервисов EventFacade и UserFacade.
preload$ = combineLatest([
this.eventFacade.eventLast$,
this.userFacade.user$
]).pipe(
map<[Event, User], boolean>(data => data.every(value => !!value))
);
Можно написать тест, который будет возвращать некорректный результат для сервисов и прелоадер вернет false, однако полезность такого теста минимальна.
Подобный тест будет выглядеть так:
user-box-reactive.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { eventStub } from '@medium-stories/events/testing';
import { cold, getTestScheduler } from '@nrwl/angular/testing';
import { EventFacade } from '@medium-stories/events';
import { UserFacade } from '@medium-stories/users';
import { userStub } from '@medium-stories/users/testing';
import { UserBoxComponent } from './user-box.component';
describe('UserBoxComponent', () => {
let component: UserBoxComponent;
let eventFacade: EventFacade;
let userFacade: UserFacade;
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [UserBoxComponent],
providers: [
{
provide: EventFacade,
useValue: {
eventLast$: cold('-a|', { a: eventStub })
}
},
{
provide: UserFacade,
useValue: {
user$: cold('-a|', { a: userStub })
}
}
]
}).compileComponents();
eventFacade = TestBed.inject(EventFacade);
userFacade = TestBed.inject(UserFacade);
}));
beforeEach(() => {
fixture = TestBed.createComponent(UserBoxComponent);
component = fixture.componentInstance;
component.user = userStub;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('preload$ should return loading or username', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('h3').textContent).toContain('Loading...');
// update observables
getTestScheduler().flush();
fixture.detectChanges();
expect(compiled.querySelector('h3').textContent).toContain(userStub.username);
});
});
Формально, в сервисы мы передали события, которые будут вызваны с течением времени:
{
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):
banner-edit.component.html
<div *ngIf="banner">
<h2>{{ banner.name | uppercase }} Details</h2>
<div><span>id: </span>{{ banner.id }}</div>
<div>
<label> name: <input [(ngModel)]="banner.name" placeholder="name" /> </label>
</div>
</div>
banner-edit.component.ts
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
export interface Banner {
id: number;
name: string;
}
@Component({
selector: 'medium-stories-banner-edit',
templateUrl: './banner-edit.component.html',
styleUrls: ['./banner-edit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerEditComponent implements OnInit {
banner: Banner;
constructor() {}
ngOnInit(): void {
this.banner = { id: 1, name: 'Simple Banner' };
}
}
Создадим файл теста:
banner-edit.component.spec.ts
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { BannerEditComponent } from './banner-edit.component';
describe('BannerEditComponent', () => {
let component: BannerEditComponent;
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [BannerEditComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should convert hero name to Title Case', () => {
const hostElement = fixture.nativeElement;
const nameInput = hostElement.querySelector('input');
const nameDisplay = hostElement.querySelector('h2');
const newInputVal = 'quick BROWN fOx';
nameInput.value = newInputVal;
// dispatch a DOM event so that Angular learns of input value change.
// use new Event utility function (not provided by Angular) for better browser compatibility
nameInput.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(nameDisplay.textContent).toBe(newInputVal.toUpperCase());
});
});
Как видно, сначала были получены 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
Создадим компонент, при клике на который меняется текст:
banner-toggle.component.html
<div (click)="onToggle()">{{ message }}</div>
banner-toggle.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'medium-stories-banner-toggle',
templateUrl: './banner-toggle.component.html',
styleUrls: ['./banner-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerToggleComponent {
status = true;
get message(): string {
return this.status ? 'Active' : 'Disable';
}
onToggle(): void {
this.status = !this.status;
}
}
Файл теста:
banner-toggle.component.spec.ts
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BannerToggleComponent } from './banner-toggle.component';
describe('BannerToggleComponent', () => {
let component: BannerToggleComponent;
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BannerToggleComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerToggleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should change messsage', () => {
const divElement = fixture.debugElement.query(By.css('div'));
divElement.triggerEventHandler('click', null);
expect(component.message).toBe('Disable');
});
});
Как видно из примера, в компоненте мы находим нужный элемент, при клике на который должен вызваться метод:
const divElement = fixture.debugElement.query(By.css('div'));
И с помощью метода triggerEventHandler, мы вызываем событие DOM:
divElement.triggerEventHandler('click', null);
Тестирование компонентов с внешними файлами
Возьмём компонент:
@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().
Тестирование компонента с дочерними компонентами
Создадим компонент, который будет использовать другой компонент:
banner-list.component.html
<medium-stories-banner-details
[banner]="banner"
*ngFor="let banner of banners"
(selected)="onSelectedBanner($event)"
></medium-stories-banner-details>
banner-list.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Banner } from '../banner-edit/banner-edit.component';
@Component({
selector: 'medium-stories-banner-list',
templateUrl: './banner-list.component.html',
styleUrls: ['./banner-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerListComponent {
banners: Banner[] = [
{
id: 1,
name: 'First banner'
},
{
id: 2,
name: 'Second banner'
}
];
selectedBanner: Banner;
onSelectedBanner(banner: Banner): void {
this.selectedBanner = banner;
}
}
И сам используемый компонент:
banner-details.component.html
<div class="banner-details" (click)="onSelected()">
<div class="banner-details-id">{{ banner.id }}</div>
<div class="banner-details-name">{{ banner.name }}</div>
</div>
banner-details.component.ts
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Banner } from '../banner-edit/banner-edit.component';
@Component({
selector: 'medium-stories-banner-details',
templateUrl: './banner-details.component.html',
styleUrls: ['./banner-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BannerDetailsComponent {
@Input() banner: Banner;
@Output() selected = new EventEmitter();
onSelected(): void {
this.selected.emit(this.banner);
}
}
Файл теста:
banner-list.component.spec.ts
import { DebugElement } from '@angular/core';
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BannerDetailsComponent } from '../banner-details/banner-details.component';
import { BannerListComponent } from './banner-list.component';
describe('BannerListComponent', () => {
let component: BannerListComponent;
let bannersDetails: DebugElement[];
let fixture: ComponentFixture;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [BannerListComponent, BannerDetailsComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BannerListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
bannersDetails = fixture.debugElement.queryAll(By.css('.banner-details'));
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should raise selected event when clicked', () => {
bannersDetails[0].triggerEventHandler('click', null);
expect(component.selectedBanner.name).toBe(component.banners[0].name);
});
});
Сначала мы подключаем компоненты:
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.
user.component.html
<p>user works!</p>
<p>
<button type="button" (click)="onClick()">Go to users</button>
</p>
user.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'medium-stories-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
constructor(private router: Router) {}
onClick(): void {
this.router.navigate(['/users']);
}
}
В документации, приведены какие-то сложные пути за отслеживанием изменений путей. Вместо этого будем использовать RouterTestingModule.
user.component.spec.ts
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { UserComponent } from './user.component';
describe('UserComponent', () => {
let component: UserComponent;
let fixture: ComponentFixture;
let router: Router;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
declarations: [UserComponent]
}).compileComponents();
router = TestBed.inject(Router);
}));
beforeEach(() => {
fixture = TestBed.createComponent(UserComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should create', () => {
fixture.ngZone.run(() => {
component.onClick();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(router.url).toBe('/users');
});
});
});
});
Так как в тесте важно проверить корректность путей, а не сам механизм перенаправления, то достаточно следующего:
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. Однако, это будет достаточно редко, когда потребность в тестировании, например вложенных компонентов будет оправдана или же тестирование и проверка корректности нажатия всех кнопок и сопоставления всех методов.
Все это приводит к тому, что должен быть баланс в написании тестов. Если тестов будет много это с одной стороны делает систему надежней, с другой увеличивает время поддержки и рефакторинга.
В идеале компоненты должны отображать данные и стараться не изменять их (для этого есть сервисы, пайпы и директивы). И если так будет, то количество тестов будет минимально и оптимально одновременно.