Сценарии тестирования компонентов
В этом руководстве рассматриваются распространённые сценарии тестирования компонентов.
Привязка данных компонента
В примере приложения компонент Banner выводит статичный текст заголовка в HTML-шаблоне.
После нескольких изменений компонент Banner выводит динамический заголовок, привязываясь к свойству title компонента, как показано ниже.
import {Component, signal} from '@angular/core';
@Component({
selector: 'app-banner',
template: '<h1>{{ title() }}</h1>',
styles: ['h1 { color: green; font-size: 350%}'],
})
export class Banner {
title = signal('Test Tour of Heroes');
}
Хотя это и минимальный пример, вы решаете добавить тест, чтобы убедиться, что компонент действительно отображает нужное содержимое в нужном месте.
Поиск элемента <h1>
Вы напишете последовательность тестов, проверяющих значение элемента <h1>, в котором используется интерполяция свойства заголовка.
Вы измените beforeEach, чтобы найти этот элемент с помощью стандартного HTML-метода querySelector и присвоить его переменной h1.
let component: Banner;
let fixture: ComponentFixture<Banner>;
let h1: HTMLElement;
beforeEach(() => {
fixture = TestBed.createComponent(Banner);
component = fixture.componentInstance; // Banner test instance
h1 = fixture.nativeElement.querySelector('h1');
}); createComponent() не выполняет привязку данных
В первом тесте вы хотите убедиться, что на экране отображается значение title по умолчанию. Интуитивно хочется сразу проверить <h1>, например так:
it('should display original title', () => {
expect(h1.textContent).toContain(component.title());
});
Этот тест завершится с ошибкой и выведет сообщение:
expected '' to contain 'Test Tour of Heroes'.
Привязка данных выполняется, когда Angular запускает обнаружение изменений.
В рабочем приложении обнаружение изменений запускается автоматически, например, когда Angular создаёт компонент или пользователь нажимает клавишу.
TestBed.createComponent не запускает обнаружение изменений синхронно; это подтверждает исправленный тест:
it('no title in the DOM after createComponent()', () => {
expect(h1.textContent).toEqual('');
}); whenStable()
Можно указать TestBed, что нужно дождаться выполнения обнаружения изменений, с помощью await fixture.whenStable(). Только после этого <h1> получает ожидаемый заголовок.
it('should display original title', async () => {
await fixture.whenStable();
expect(h1.textContent).toContain(component.title());
});
Задержка обнаружения изменений предусмотрена намеренно и полезна. Она даёт тестировщику возможность проверить и изменить состояние компонента до того, как Angular начнёт привязку данных и вызовет хуки жизненного цикла.
Вот ещё один тест, который изменяет свойство title компонента до вызова fixture.whenStable().
it('should display a different test title', async () => {
component.title.set('Test Title');
await fixture.whenStable();
expect(h1.textContent).toContain('Test Title');
}); Привязка сигналов к входным параметрам
Чтобы отслеживать изменения входных параметров и слушать выходные события, можно динамически привязывать сигналы к входным параметрам, а функции — к выходным.
import {inputBinding, outputBinding} from '@angular/core';
const fixture = TestBed.createComponent(ValueDisplay, {
bindings: [
inputBinding('value', value),
outputBinding('valueChange', () => (/* ... */) ),
],
}); Изменение значения поля ввода с помощью dispatchEvent()
Чтобы имитировать пользовательский ввод, найдите элемент поля ввода и задайте его свойство value.
Но здесь есть один важный промежуточный шаг.
Angular не знает, что вы задали свойство value элемента поля ввода. Он не прочитает это свойство, пока вы не вызовете событие input элемента с помощью dispatchEvent().
В следующем примере компонента, использующего TitleCasePipe, показана правильная последовательность.
it('should convert hero name to Title Case', async () => {
const hostElement = fixture.nativeElement;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
}); Компонент с зависимостью
Компоненты часто зависят от сервисов.
Компонент Welcome выводит приветствие для вошедшего в систему пользователя. Он определяет пользователя по свойству внедрённого UserAuthentication:
import {Component, inject, OnInit, signal} from '@angular/core';
import {UserAuthentication} from '../model/user.authentication';
@Component({
selector: 'app-welcome',
template: '<h3 class="welcome"><i>{{ welcome() }}</i></h3>',
})
export class Welcome {
private userAuth = inject(UserAuthentication);
welcome = signal(
this.userAuth.isLoggedIn() ? `Welcome, ${this.userAuth.user().name}` : 'Please log in.',
);
}
В компоненте Welcome есть логика принятия решений, взаимодействующая с сервисом. Именно эта логика делает компонент достойным тестирования.
Предоставление тестовых двойников сервисов
Для тестируемого компонента необязательно предоставлять настоящие сервисы.
Внедрить настоящий UserAuthentication может быть сложно. Настоящий сервис может запросить у пользователя учётные данные для входа и попытаться связаться с сервером аутентификации. Такое поведение бывает трудно перехватить. Помните, что тестовые двойники заставляют тест вести себя не так, как рабочее приложение, поэтому используйте их умеренно.
Получение внедрённых сервисов
Тестам нужен доступ к UserAuthentication, внедрённому в компонент Welcome.
В Angular используется иерархическая система внедрения зависимостей. Инжекторы могут находиться на нескольких уровнях — от корневого инжектора, созданного TestBed, до инжекторов в дереве компонентов.
Самый надёжный способ получить внедрённый сервис, который работает всегда, — получить его из инжектора тестируемого компонента. Инжектор компонента доступен через свойство DebugElement у фикстуры.
// UserAuthentication actually injected into the component userAuth = fixture.debugElement.injector.get(UserAuthentication);
ПОЛЕЗНО: Обычно в этом нет необходимости. Часто сервисы предоставляются в корневом модуле или в переопределениях TestBed, и получить их проще с помощью TestBed.inject() (см. ниже).
TestBed.inject()
Этот способ проще запомнить, и он короче, чем получение сервиса с помощью DebugElement фикстуры.
В этом наборе тестов единственный поставщик UserAuthentication — корневой тестовый модуль, поэтому безопасно вызвать TestBed.inject() следующим образом:
userAuth = TestBed.inject(UserAuthentication);
ПОЛЕЗНО: Если TestBed.inject() не подходит, см. раздел Переопределение поставщиков компонента, где объясняется, когда и почему вместо этого нужно получать сервис из инжектора компонента.
Итоговая настройка и тесты
Вот полный beforeEach() с использованием TestBed.inject():
let fixture: ComponentFixture<Welcome>;
let comp: Welcome;
let userAuth: UserAuthentication; // the TestBed injected service
let el: HTMLElement; // the DOM element with the welcome message
beforeEach(() => {
fixture = TestBed.createComponent(Welcome);
comp = fixture.componentInstance;
// UserAuthentication from the root injector
userAuth = TestBed.inject(UserAuthentication);
// get the "welcome" element by CSS selector (e.g., by class name)
el = fixture.nativeElement.querySelector('.welcome');
});
А вот несколько тестов:
it('should welcome the user', async () => {
await fixture.whenStable();
const content = el.textContent;
expect(content, '"Welcome ..."').toContain('Welcome');
expect(content, 'expected name').toContain('Test User');
});
it('should welcome "Bubba"', async () => {
userAuth.user.set({name: 'Bubba'}); // welcome message hasn't been shown yet
await fixture.whenStable();
expect(el.textContent).toContain('Bubba');
});
it('should request login if not logged in', async () => {
userAuth.isLoggedIn.set(false); // welcome message hasn't been shown yet
await fixture.whenStable();
const content = el.textContent;
expect(content, 'not welcomed').not.toContain('Welcome');
expect(content, '"log in"').toMatch(/log in/i);
});
Первый — это проверка корректности работы: он подтверждает, что UserAuthentication был вызван и работает.
ПОЛЕЗНО: Второй аргумент expect (например, 'expected name') — необязательная метка ошибки. Если ожидание не выполняется, Vitest добавляет эту метку к сообщению об ошибке. В тесте с несколькими ожиданиями она может помочь понять, что пошло не так и какое именно ожидание не выполнилось.
Остальные тесты проверяют логику компонента при разных значениях, возвращаемых сервисом. Второй тест проверяет результат изменения имени пользователя. Третий тест проверяет, что компонент выводит правильное сообщение, если пользователь не вошёл в систему.
Компонент с асинхронным сервисом
В этом примере шаблон компонента About содержит компонент Twain. Компонент Twain показывает цитаты Марка Твена.
<p class="twain">
<i>{{ quote | async }}</i>
</p>
<button type="button" (click)="getQuote()">Next quote</button>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
ПОЛЕЗНО: Значение свойства quote компонента передаётся через AsyncPipe. Это значит, что свойство возвращает либо Promise, либо Observable.
В этом примере метод TwainQuotes.getQuote() сообщает, что свойство quote возвращает Observable.
getQuote() {
this.errorMessage.set('');
this.quote = this.twainQuotes.getQuote().pipe(
startWith('...'),
catchError((err: any) => {
this.errorMessage.set(err.message || err.toString());
return of('...'); // reset message to placeholder
}),
);
}
Компонент Twain получает цитаты из внедрённого TwainQuotes. Компонент начинает работу с возвращённым Observable, содержащим значение-заполнитель ('...'), пока сервис не вернёт первую цитату.
Компонент catchError перехватывает ошибки сервиса, подготавливает сообщение об ошибке и возвращает значение-заполнитель по каналу успешного результата.
Все эти возможности нужно протестировать.
Тестирование с имитацией HTTP-запросов с помощью HttpTestingController.
При тестировании компонента важен только публичный API сервиса. Как правило, сами тесты не должны обращаться к удалённым серверам. Вместо этого такие обращения нужно имитировать.
Если асинхронный сервис использует HttpClient для загрузки удалённых данных, рекомендуется возвращать имитированные ответы на уровне HTTP с помощью HttpTestingController.
Подробнее об имитации HttpBackend см. в специальном руководстве.
Тестирование с помощью заглушки сервиса.
Если невозможно имитировать асинхронный запрос на уровне HTTP, можно воспользоваться шпионами.
В этом app/twain/twain-quotes.spec.ts показан один из способов настройки:
class TwainQuotesStub implements TwainQuotes {
private testQuote = 'Test Quote';
getQuote() {
return of(this.testQuote);
}
// ... Implement everything to conform to the API
}
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
});
fixture = TestBed.createComponent(Twain);
component = fixture.componentInstance;
await fixture.whenStable();
quoteEl = fixture.nativeElement.querySelector('.twain');
});
Обратите внимание на то, как заглушка заменяет исходную реализацию.
TestBed.configureTestingModule({
providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
});
Заглушка устроена так, что любой компонент или сервис, внедряющий её, получает реализацию-заглушку. Это значит, что каждый вызов getQuote возвращает observable с тестовой цитатой.
В отличие от настоящего метода getQuote(), этот шпион обходит сервер и возвращает синхронный observable, значение которого доступно немедленно.
Асинхронный тест с поддельными таймерами Vitest
Чтобы имитировать асинхронные функции, например setTimeout или Promise, можно использовать поддельные таймеры Vitest и контролировать момент их срабатывания.
it('should display error when TwainQuotes service fails', async () => {
class TwainQuotesStub implements TwainQuotes {
getQuote() {
return defer(() => {
return new Promise<string>((_, reject) => {
setTimeout(() => reject('TwainService test failure'));
});
});
}
// ... Implement everything to conform to the API
}
TestBed.configureTestingModule({
providers: [{provide: TwainQuotes, useClass: TwainQuotesStub}],
});
vi.useFakeTimers(); // setting up the fake timers
const fixture = TestBed.createComponent(TwainComponent);
// rendering isn't async, we need to flush
await vi.runAllTimersAsync();
await expect(fixture.nativeElement.querySelector('.error')!.textContent).toMatch(/test failure/);
expect(fixture.nativeElement.querySelector('.twain')!.textContent).toBe('...');
vi.useRealTimers(); // resets to regular async execution
}); Другие асинхронные тесты
Если заглушка сервиса возвращает асинхронные observables, большинство ваших тестов тоже должны быть асинхронными.
Вот тест, демонстрирующий поток данных, ожидаемый в реальном приложении.
it('should show quote after getQuote', async () => {
class MockTwainQuotes implements TwainQuotes {
private subject = new Subject<string>();
getQuote() {
return this.subject.asObservable();
}
emit(val: string) {
this.subject.next(val);
}
}
it('should show quote after getQuote (success)', async () => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [{provide: TwainQuotes, useClass: MockTwainQuotes}],
});
const fixture = TestBed.createComponent(TwainComponent);
const twainQuotes = TestBed.inject(TwainQuotes) as MockTwainQuotes;
await vi.runAllTimersAsync(); // render before the quote is received
const quoteEl = fixture.nativeElement.querySelector('.twain');
expect(quoteEl.textContent).toBe('...');
twainQuotes.emit('Twain Quote'); // emits the quote
await vi.runAllTimersAsync(); // render with the quote received
expect(quoteEl.textContent).toBe('Twain Quote');
expect(fixture.nativeElement.querySelector('.error')).toBeNull();
vi.useRealTimers();
});
});
Обратите внимание: при первой отрисовке элемент цитаты показывает значение-заполнитель ('...'). Первая цитата ещё не получена.
Затем можно проверить, что элемент цитаты отображает ожидаемый текст.
Асинхронные тесты с zone.js и fakeAsync
Вспомогательная функция fakeAsync — это ещё один макет часов, использующий перехват асинхронных API с помощью zone.js. Раньше её часто использовали для тестирования приложений на основе zone.js. Использование fakeAsync больше не рекомендуется.
СОВЕТ: Предпочитайте встроенные стратегии асинхронного тестирования или другие поддельные таймеры (также называемые макетами часов), например таймеры Vitest или Jasmine.
ВАЖНО: fakeAsync нельзя использовать с тестовым средством запуска Vitest, поскольку для него не применяется патч zone.js.
Компонент с входными и выходными параметрами
Компонент с входными и выходными параметрами обычно находится в шаблоне представления хост-компонента. Хост использует привязку свойства, чтобы задать входное свойство, и привязку события, чтобы слушать события, создаваемые выходным свойством.
Цель тестирования — проверить, что такие привязки работают как ожидается. Тесты должны задавать значения входных параметров и слушать выходные события.
Компонент DashboardHero — простой пример компонента с такой ролью. Он отображает отдельного героя, переданного компонентом Dashboard. При нажатии на героя компонент Dashboard получает уведомление о том, что пользователь выбрал этого героя.
Компонент DashboardHero встроен в шаблон компонента Dashboard следующим образом:
@for (hero of heroes; track hero) {
<dashboard-hero class="col-1-4" [hero]="hero" (selected)="gotoDetail($event)" />
}
Компонент DashboardHero находится внутри блока @for, который присваивает входному свойству hero каждого компонента текущее значение цикла и слушает событие selected компонента.
Вот полное определение компонента:
@Component({
selector: 'dashboard-hero',
imports: [UpperCasePipe],
template: `
<button type="button" (click)="click()" class="hero">
{{ hero().name | uppercase }}
</button>
`,
})
export class DashboardHero {
readonly hero = input.required<Hero>();
readonly selected = output<Hero>();
click() {
this.selected.emit(this.hero());
}
}
Хотя тестирование такого простого компонента само по себе не представляет большой ценности, полезно знать, как это делается. Используйте один из следующих подходов:
- Проверьте его в составе компонента
Dashboard - Проверьте его как самостоятельный компонент
- Проверьте его в составе подмены компонента
Dashboard
Сейчас нужно протестировать компонент DashboardHero, а не компонент Dashboard, поэтому попробуем второй и третий варианты.
Тестирование компонента DashboardHero отдельно
Вот основная часть настройки файла спецификации.
let fixture: ComponentFixture<DashboardHero>;
let comp: DashboardHero;
let heroDe: DebugElement;
let heroEl: HTMLElement;
let expectedHero: Hero;
beforeEach(async () => {
fixture = TestBed.createComponent(DashboardHero);
comp = fixture.componentInstance;
// find the hero's DebugElement and element
heroDe = fixture.debugElement.query(By.css('.hero'));
heroEl = heroDe.nativeElement;
// mock the hero supplied by the parent component
expectedHero = {id: 42, name: 'Test Name'};
// simulate the parent setting the input property with that hero
fixture.componentRef.setInput('hero', expectedHero);
// wait for initial data binding
await fixture.whenStable();
});
Обратите внимание, как код настройки присваивает тестового героя (expectedHero) свойству hero компонента, имитируя присваивание, которое компонент Dashboard выполняет с помощью привязки свойства в повторителе.
Следующий тест проверяет, что имя героя передаётся в шаблон с помощью привязки.
it('should display hero name in uppercase', () => {
const expectedPipedName = expectedHero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
Поскольку шаблон передаёт имя героя через Angular UpperCasePipe, тест должен сравнить значение элемента с именем, записанным заглавными буквами.
Нажатие
При нажатии на героя должно генерироваться событие selected, которое может обработать хост-компонент (предположительно Dashboard):
it('should raise selected event when clicked (triggerEventHandler)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroDe.triggerEventHandler('click');
expect(selectedHero).toBe(expectedHero);
});
Свойство selected компонента возвращает EventEmitter, которое для потребителей выглядит как синхронный Observable RxJS. Тест подписывается на него явно, как это хост-компонент делает неявно.
Если компонент работает как ожидается, нажатие на элемент героя должно привести к тому, что свойство selected компонента отправит объект hero.
Тест обнаруживает это событие через подписку на selected.
triggerEventHandler
heroDe из предыдущего теста — это DebugElement, представляющий <div> героя.
У него есть свойства и методы Angular, абстрагирующие взаимодействие с исходным элементом. В этом тесте вызывается DebugElement.triggerEventHandler с именем события "click". Обработчик привязки события "click" вызывает DashboardHero.click().
Метод Angular DebugElement.triggerEventHandler может генерировать любое событие, привязанное к данным, используя его имя. Второй параметр — объект события, передаваемый обработчику.
Тест вызвал событие "click".
heroDe.triggerEventHandler('click');
В этом случае тест правильно предполагает, что обработчику события во время выполнения — методу click() компонента — не важен объект события.
ПОЛЕЗНО: Другие обработчики не столь снисходительны. Например, директива RouterLink ожидает объект со свойством button, которое указывает, какая кнопка мыши (если была нажата) использовалась для щелчка. Директива RouterLink выдаёт ошибку, если объект события отсутствует.
Нажатие на элемент
В следующем варианте теста вызывается собственный метод click() исходного элемента, что вполне подходит для этого компонента.
it('should raise selected event when clicked (element.click)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
heroEl.click();
expect(selectedHero).toBe(expectedHero);
}); Вспомогательная функция click()
Нажатие на кнопку, ссылку или произвольный HTML-элемент — распространённая задача при тестировании.
Сделайте этот процесс последовательным и простым, инкапсулировав процедуру инициирования нажатия во вспомогательную функцию, например следующую функцию click():
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
left: {button: 0},
right: {button: 2},
};
/** Simulate element click. Defaults to mouse left-button click event. */
export function click(
el: DebugElement | HTMLElement,
eventObj: any = ButtonClickEvents.left,
): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}
Первый параметр — это элемент, на который нужно нажать. При желании передайте пользовательский объект события вторым параметром. По умолчанию используется частичный объект события нажатия левой кнопки мыши, который принимают многие обработчики, в том числе директива RouterLink.
ВАЖНО: Вспомогательная функция click() не входит в число утилит тестирования Angular. Это функция, определённая в примере кода из этого руководства. Она используется во всех примерах тестов. Если она вам нравится, добавьте её в собственный набор вспомогательных функций.
Вот предыдущий тест, переписанный с использованием вспомогательной функции click.
it('should raise selected event when clicked (click helper with DebugElement)', () => {
let selectedHero: Hero | undefined;
comp.selected.subscribe((hero: Hero) => (selectedHero = hero));
click(heroDe); // click helper with DebugElement
expect(selectedHero).toBe(expectedHero);
}); Компонент внутри тестового хоста
В предыдущих тестах хост-компонент Dashboard имитировался самими тестами. Но правильно ли работает компонент DashboardHero, когда его привязки данных настроены через хост-компонент?
@Component({
imports: [DashboardHero],
template: ` <dashboard-hero [hero]="hero" (selected)="onSelected($event)" />`,
})
class TestHost {
hero: Hero = {id: 42, name: 'Test Name'};
selectedHero: Hero | undefined;
onSelected(hero: Hero) {
this.selectedHero = hero;
}
}
Тестовый хост задаёт входное свойство hero компонента, передавая тестового героя. Он связывает событие selected компонента с обработчиком onSelected, который сохраняет отправленного героя в свойстве selectedHero.
Позже тесты смогут проверить selectedHero и убедиться, что событие DashboardHero.selected передало ожидаемого героя.
Настройка тестов test-host похожа на настройку автономных тестов:
beforeEach(async () => {
// create TestHost instead of DashboardHero
fixture = TestBed.createComponent(TestHost);
testHost = fixture.componentInstance;
heroEl = fixture.nativeElement.querySelector('.hero');
await fixture.whenStable();
});
В конфигурации тестового модуля есть два важных отличия:
- Вместо компонента
DashboardHeroсоздаётся компонентTestHost - Компонент
TestHostзадаётDashboardHero.heroс помощью привязки
Метод createComponent возвращает fixture, содержащую экземпляр TestHost, а не экземпляр DashboardHero.
Создание TestHost также приводит к созданию DashboardHero, поскольку последний находится в шаблоне первого. Поиск элемента героя (heroEl) по-прежнему находит его в тестовом DOM, хотя теперь он расположен глубже в дереве элементов.
Сами тесты почти не отличаются от автономного варианта:
it('should display hero name', () => {
const expectedPipedName = testHost.hero.name.toUpperCase();
expect(heroEl.textContent).toContain(expectedPipedName);
});
it('should raise selected event when clicked', () => {
click(heroEl);
// selected hero should be the same data bound hero
expect(testHost.selectedHero).toBe(testHost.hero);
});
Отличается только тест события выбора. Он подтверждает, что выбранный герой DashboardHero действительно передаётся через привязку события в хост-компонент.
Компонент маршрутизации
Компонент маршрутизации — это компонент, который указывает Router перейти к другому компоненту. Компонент Dashboard является компонентом маршрутизации, поскольку пользователь может перейти к компоненту HeroDetail, нажав одну из кнопок героев на панели мониторинга.
Angular предоставляет вспомогательные средства тестирования, позволяющие сократить шаблонный код и эффективнее тестировать код, зависящий от HttpClient. Функцию provideRouter также можно использовать непосредственно в тестовом модуле.
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: '**', component: Dashboard}]),
provideHttpClientTesting(),
HeroService,
],
});
harness = await RouterTestingHarness.create();
comp = await harness.navigateByUrl('/', Dashboard);
TestBed.inject(HttpTestingController).expectOne('api/heroes').flush(getTestHeroes());
});
Следующий тест нажимает на отображаемого героя и проверяет, что выполняется переход по ожидаемому URL.
it('should tell navigate when hero clicked', async () => {
// get first <dashboard-hero> DebugElement
const heroDe = harness.routeDebugElement!.query(By.css('dashboard-hero'));
heroDe.triggerEventHandler('selected', comp.heroes[0]);
// expecting to navigate to id of the component's first hero
const id = comp.heroes[0].id;
expect(TestBed.inject(Router).url, 'should nav to HeroDetail for first hero').toEqual(
`/heroes/${id}`,
);
}); Компоненты, отображаемые по маршруту
Компонент, отображаемый по маршруту, — это цель перехода Router. Его может быть сложнее тестировать, особенно если маршрут к компоненту содержит параметры. HeroDetail — это компонент, отображаемый по маршруту, который служит целью такого маршрута.
Когда пользователь нажимает на героя на панели Dashboard, компонент Dashboard указывает Router перейти к heroes/:id. :id — это параметр маршрута, значением которого является id героя, предназначенного для редактирования.
Router сопоставляет этот URL с маршрутом к HeroDetail. Он создаёт объект ActivatedRoute с информацией о маршруте и внедряет его в новый экземпляр HeroDetail.
Вот сервисы, внедрённые в HeroDetail:
private heroDetailService = inject(HeroDetailService); private route = inject(ActivatedRoute); private router = inject(Router);
Компонент HeroDetail нужен параметр id, чтобы с помощью HeroDetailService получить соответствующего героя. Компонент должен получить id из свойства ActivatedRoute.paramMap, которое представляет собой Observable.
Нельзя просто обратиться к свойству id объекта ActivatedRoute.paramMap. Компонент должен подписаться на observable ActivatedRoute.paramMap и быть готовым к изменению id в течение жизненного цикла.
constructor() {
// get hero when `id` param changes
this.route.paramMap
.pipe(takeUntilDestroyed())
.subscribe((pmap) => this.getHero(pmap.get('id')));
}
Тесты могут проверить, как HeroDetail реагирует на различные значения параметра id, переходя по разным маршрутам.
Тестирование вложенных компонентов
В шаблонах компонентов часто есть вложенные компоненты, шаблоны которых могут содержать другие компоненты.
Дерево компонентов может быть очень глубоким, и иногда вложенные компоненты никак не влияют на тестирование компонента в его корне.
Например, компонент App отображает панель навигации со ссылками и директивами RouterLink.
<app-banner /> <app-welcome /> <nav> <a routerLink="/dashboard">Dashboard</a> <a routerLink="/heroes">Heroes</a> <a routerLink="/about">About</a> </nav> <router-outlet />
Чтобы проверить ссылки, но не навигацию, не нужно, чтобы Router выполнял переход, и не нужен <router-outlet>, обозначающий место вставки компонентов, отображаемых по маршруту компонентом Router.
Компоненты Banner и Welcome (обозначенные <app-banner> и <app-welcome>) тоже не имеют значения.
Однако любой тест, создающий компонент App в DOM, также создаёт экземпляры этих трёх компонентов. Если это допустить, придётся настроить TestBed для их создания.
Если не объявить эти компоненты, компилятор Angular не распознает теги <app-banner>, <app-welcome> и <router-outlet> в шаблоне App и выдаст ошибку.
Если объявить настоящие компоненты, придётся также объявить их вложенные компоненты и предоставить все сервисы, внедрённые в любой компонент дерева.
В этом разделе описаны два способа упростить настройку. Используйте их по отдельности или вместе, чтобы сосредоточиться на тестировании основного компонента.
Создание заглушек ненужных компонентов
При использовании первого способа создаются и объявляются заглушки компонентов и директивы, которые практически не участвуют в тестах или не участвуют вовсе.
@Component({selector: 'app-banner', template: ''})
class BannerStub {}
@Component({selector: 'router-outlet', template: ''})
class RouterOutletStub {}
@Component({selector: 'app-welcome', template: ''})
class WelcomeStub {}
Селекторы заглушек совпадают с селекторами соответствующих настоящих компонентов. Но их шаблоны и классы пусты.
Затем объявите их, переопределив imports компонента с помощью TestBed.overrideComponent.
let comp: App;
let fixture: ComponentFixture<App>;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter([]), UserAuthentication],
}).overrideComponent(App, {
set: {
imports: [RouterLink, BannerStub, RouterOutletStub, WelcomeStub],
},
});
fixture = TestBed.createComponent(App);
comp = fixture.componentInstance;
});
ПОЛЕЗНО: Ключ set в этом примере заменяет все существующие импорты компонента. Обязательно импортируйте все зависимости, а не только заглушки. В качестве альтернативы можно использовать ключи remove/add, чтобы выборочно удалять и добавлять импорты.
NO_ERRORS_SCHEMA
Второй способ — добавить NO_ERRORS_SCHEMA в переопределённые метаданные компонента.
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter([]), UserAuthentication],
}).overrideComponent(App, {
set: {
imports: [], // resets all imports
schemas: [NO_ERRORS_SCHEMA],
},
});
});
NO_ERRORS_SCHEMA указывает компилятору Angular игнорировать нераспознанные элементы и атрибуты.
Компилятор распознаёт элемент <app-root> и атрибут routerLink, поскольку в конфигурации TestBed объявлены соответствующие компонент App и директива RouterLink.
Но при обнаружении <app-banner>, <app-welcome> или <router-outlet> компилятор не выдаст ошибку. Он просто отобразит их как пустые теги, которые браузер проигнорирует.
Заглушки компонентов больше не нужны.
Совместное использование обоих способов
Эти способы применяются для поверхностного тестирования компонентов: так они называются, потому что уменьшают визуальную поверхность компонента до элементов его шаблона, важных для тестов.
Способ с NO_ERRORS_SCHEMA проще, но не злоупотребляйте им.
NO_ERRORS_SCHEMA также не позволяет компилятору сообщать об отсутствующих компонентах и атрибутах, которые вы случайно пропустили или написали с ошибкой. Вы можете потратить часы на поиск мнимых ошибок, которые компилятор обнаружил бы мгновенно.
У подхода с компонентами-заглушками есть ещё одно преимущество. Хотя в этом примере заглушки были пустыми, при необходимости взаимодействия с ними в тестах можно добавить им упрощённые шаблоны и классы.
На практике в одной настройке обычно сочетают оба способа, как показано в этом примере.
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter([]), UserAuthentication],
}).overrideComponent(App, {
remove: {imports: [RouterOutlet, Welcome]},
set: {schemas: [NO_ERRORS_SCHEMA]},
});
});
Компилятор Angular создаёт BannerStub для элемента <app-banner> и применяет RouterLink к ссылкам с атрибутом routerLink, но игнорирует теги <app-welcome> и <router-outlet>.
By.directive и внедрённые директивы
Небольшая дополнительная настройка запускает начальную привязку данных и получает ссылки на навигационные ссылки:
beforeEach(async () => {
await fixture.whenStable();
// find DebugElements with an attached RouterLinkStubDirective
linkDes = fixture.debugElement.queryAll(By.directive(RouterLink));
// get attached link directive instances
// using each DebugElement's injector
routerLinks = linkDes.map((de) => de.injector.get(RouterLink));
});
Особого внимания заслуживают три момента:
- Найдите элементы ссылок с прикреплённой директивой с помощью
By.directive - Запрос возвращает обёртки
DebugElementдля соответствующих элементов - Каждый
DebugElementпредоставляет инжектор зависимостей с экземпляром директивы, прикреплённой к этому элементу
Ссылки компонента App, которые нужно проверить:
<nav> <a routerLink="/dashboard">Dashboard</a> <a routerLink="/heroes">Heroes</a> <a routerLink="/about">About</a> </nav>
Вот несколько тестов, проверяющих, что эти ссылки связаны с директивами routerLink как ожидалось:
it('can get RouterLinks from template', () => {
expect(routerLinks.length, 'should have 3 routerLinks').toBe(3);
expect(routerLinks[0].href).toBe('/dashboard');
expect(routerLinks[1].href).toBe('/heroes');
expect(routerLinks[2].href).toBe('/about');
});
it('can click Heroes link in template', async () => {
const heroesLinkDe = linkDes[1]; // heroes link DebugElement
TestBed.inject(Router).resetConfig([{path: '**', children: []}]);
heroesLinkDe.triggerEventHandler('click', {button: 0});
await fixture.whenStable();
expect(TestBed.inject(Router).url).toBe('/heroes');
}); Использование объекта страницы
Компонент HeroDetail — это простое представление с заголовком, двумя полями героя и двумя кнопками.
Но даже в этой простой форме шаблон довольно сложен.
@if (hero) {
<div>
<h2>
<span>{{ hero.name | titlecase }}</span> Details
</h2>
<div><span>id: </span>{{ hero.id }}</div>
<div>
<label for="name">name: </label>
<input id="name" [(ngModel)]="hero.name" placeholder="name" />
</div>
<button type="button" (click)="save()">Save</button>
<button type="button" (click)="cancel()">Cancel</button>
</div>
}
Для тестов компонента нужно …
- Дождаться появления героя, прежде чем элементы появятся в DOM
- Получить ссылку на текст заголовка
- Получить ссылку на поле ввода имени, чтобы проверить его значение и задать новое
- Получить ссылки на две кнопки, чтобы нажимать на них
Даже такая небольшая форма может привести к путанице из-за сложной условной настройки и выбора элементов CSS.
Упростите работу с помощью класса Page, который управляет доступом к свойствам компонента и инкапсулирует логику их установки.
Вот такой класс Page для hero-detail.component.spec.ts
class Page {
// getter properties wait to query the DOM until called.
get buttons() {
return this.queryAll<HTMLButtonElement>('button');
}
get saveBtn() {
return this.buttons[0];
}
get cancelBtn() {
return this.buttons[1];
}
get nameDisplay() {
return this.query<HTMLElement>('span');
}
get nameInput() {
return this.query<HTMLInputElement>('input');
}
//// query helpers ////
private query<T>(selector: string): T {
return harness.routeNativeElement!.querySelector(selector)! as T;
}
private queryAll<T>(selector: string): T[] {
return harness.routeNativeElement!.querySelectorAll(selector) as any as T[];
}
}
Теперь важные средства для управления компонентом и его проверки аккуратно организованы и доступны через экземпляр Page.
Метод createComponent создаёт объект page и заполняет его недостающие данные после появления hero.
async function createComponent(id: number) {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${id}`, HeroDetail);
page = new Page();
const request = TestBed.inject(HttpTestingController).expectOne(`api/heroes/?id=${id}`);
const hero = getTestHeroes().find((h) => h.id === Number(id));
request.flush(hero ? [hero] : []);
await harness.fixture.whenStable();
}
Вот ещё несколько тестов компонента HeroDetail, иллюстрирующих эту идею.
it("should display that hero's name", () => {
expect(page.nameDisplay.textContent).toBe(expectedHero.name);
});
it('should navigate when click cancel', () => {
click(page.cancelBtn);
expect(TestBed.inject(Router).url).toEqual(`/heroes/${expectedHero.id}`);
});
it('should save when click save but not navigate immediately', () => {
click(page.saveBtn);
expect(TestBed.inject(HttpTestingController).expectOne({method: 'PUT', url: 'api/heroes'}));
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should navigate when click save and save resolves', async () => {
click(page.saveBtn);
await harness.fixture.whenStable();
expect(TestBed.inject(Router).url).toEqual('/heroes/41');
});
it('should convert hero name to Title Case', async () => {
// get the name's input and display elements from the DOM
const hostElement: HTMLElement = harness.routeNativeElement!;
const nameInput: HTMLInputElement = hostElement.querySelector('input')!;
const nameDisplay: HTMLElement = hostElement.querySelector('span')!;
// simulate user entering a new name into the input box
nameInput.value = 'quick BROWN fOx';
// Dispatch a DOM event so that Angular learns of input value change.
nameInput.dispatchEvent(new Event('input'));
// Wait for Angular to update the display binding through the title pipe
await harness.fixture.whenStable();
expect(nameDisplay.textContent).toBe('Quick Brown Fox');
}); Переопределение поставщиков компонента
Компонент HeroDetail предоставляет собственный HeroDetailService.
@Component({
/* ... */
providers: [HeroDetailService],
})
export class HeroDetail {
private heroDetailService = inject(HeroDetailService);
private route = inject(ActivatedRoute);
private router = inject(Router);
}
Невозможно подменить HeroDetailService компонента в providers из TestBed.configureTestingModule. Это поставщики тестового модуля, а не компонента. Они настраивают инжектор зависимостей на уровне фикстуры.
Angular создаёт компонент с его собственным инжектором, который является дочерним по отношению к инжектору фикстуры. Он регистрирует поставщиков компонента (в данном случае HeroDetailService) в дочернем инжекторе.
Тест не может получить доступ к службам дочернего инжектора через инжектор фикстуры. И TestBed.configureTestingModule тоже не может их настроить.
Всё это время Angular создавал новые экземпляры настоящего HeroDetailService!
ПОЛЕЗНО: Эти тесты могут завершиться с ошибкой или по тайм-ауту, если HeroDetailService самостоятельно выполняет XHR-запросы к удалённому серверу. Возможно, удалённого сервера, к которому можно обратиться, нет.
К счастью, HeroDetailService делегирует ответственность за доступ к удалённым данным внедрённому HeroService.
@Service()
export class HeroDetailService {
private heroService = inject(HeroService);
}
Предыдущая конфигурация теста заменяет настоящий HeroService на TestHeroService, который перехватывает запросы к серверу и имитирует ответы на них.
А что, если вам повезло меньше? Что, если имитировать HeroService сложно? Что, если HeroDetailService самостоятельно отправляет запросы к серверу?
Метод TestBed.overrideComponent может заменить providers компонента простыми в управлении тестовыми двойниками, как показано в следующем варианте настройки:
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
provideRouter([
{path: 'heroes', component: HeroList},
{path: 'heroes/:id', component: HeroDetail},
]),
// HeroDetailService at this level is IRRELEVANT!
{provide: HeroDetailService, useValue: {}},
],
}).overrideComponent(HeroDetail, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
});
});
Обратите внимание: TestBed.configureTestingModule больше не предоставляет поддельный HeroService, поскольку в нём нет необходимости.
Метод overrideComponent
Обратите внимание на метод overrideComponent.
.overrideComponent(HeroDetail, {
set: {providers: [{provide: HeroDetailService, useClass: HeroDetailServiceSpy}]},
});
Он принимает два аргумента: тип компонента, который нужно переопределить (HeroDetail), и объект метаданных переопределения. Объект метаданных переопределения — это обобщённый тип, определённый следующим образом:
type MetadataOverride<T> = {
add?: Partial<T>;
remove?: Partial<T>;
set?: Partial<T>;
};
Объект переопределения метаданных может добавлять элементы в свойства метаданных и удалять их или полностью сбрасывать эти свойства. В этом примере сбрасываются метаданные providers компонента.
Параметр типа T задаёт тип метаданных, которые передаются декоратору @Component:
selector?: string; template?: string; templateUrl?: string; providers?: any[]; …
Предоставление заглушки-шпиона (HeroDetailServiceSpy)
В этом примере массив providers компонента полностью заменяется новым массивом, содержащим HeroDetailServiceSpy.
HeroDetailServiceSpy — это заглушка, имитирующая настоящий HeroDetailService и его необходимые возможности. Она не внедряет HeroService нижнего уровня и не делегирует ему вызовы, поэтому предоставлять для него тестовый двойник не нужно.
Связанные с ним тесты компонента HeroDetail будут проверять, вызывались ли методы HeroDetailService, отслеживая вызовы методов службы. Поэтому в заглушке эти методы реализованы как шпионы:
import {vi} from 'vitest';
class HeroDetailServiceSpy {
testHero: Hero = {...testHero};
/* emit cloned test hero */
getHero = vi.fn(() => asyncData({...this.testHero}));
/* emit clone of test hero, with changes merged in */
saveHero = vi.fn((hero: Hero) => asyncData(Object.assign(this.testHero, hero)));
} Тесты с переопределением
Теперь тесты могут напрямую управлять героем компонента, изменяя testHero заглушки-шпиона, и проверять, вызывались ли методы службы.
let hdsSpy: HeroDetailServiceSpy;
beforeEach(async () => {
harness = await RouterTestingHarness.create();
component = await harness.navigateByUrl(`/heroes/${testHero.id}`, HeroDetail);
page = new Page();
// get the component's injected HeroDetailServiceSpy
hdsSpy = harness.routeDebugElement!.injector.get(HeroDetailService) as any;
harness.detectChanges();
});
it('should have called `getHero`', () => {
expect(hdsSpy.getHero, 'getHero called once').toHaveBeenCalledTimes(1);
});
it("should display stub hero's name", () => {
expect(page.nameDisplay.textContent).toBe(hdsSpy.testHero.name);
});
it('should save stub hero change', async () => {
const origName = hdsSpy.testHero.name;
const newName = 'New Name';
page.nameInput.value = newName;
page.nameInput.dispatchEvent(new Event('input')); // tell Angular
expect(component.hero.name, 'component hero has new name').toBe(newName);
expect(hdsSpy.testHero.name, 'service hero unchanged before save').toBe(origName);
click(page.saveBtn);
expect(hdsSpy.saveHero, 'saveHero called once').toHaveBeenCalledTimes(1);
await harness.fixture.whenStable();
expect(hdsSpy.testHero.name, 'service hero has new name after save').toBe(newName);
expect(TestBed.inject(Router).url).toEqual('/heroes');
}); Дополнительные переопределения
Метод TestBed.overrideComponent можно вызывать несколько раз для одного и того же или разных компонентов. TestBed предоставляет похожие методы overrideDirective, overrideModule и overridePipe, позволяющие исследовать и заменять части других классов.
Изучите доступные варианты и их сочетания самостоятельно.
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/testing/components-scenarios