Сайт визитка на Angular. Unit тестирование.
В данной статье немного поговорим о тестировании. Разберем основные нюансы связанные с тестированием в Angular, а также поговорим о частях приложения, которые обязательно нужно тестировать.
Я не очень сильно люблю писать про тестирование, так как не обладаю достаточными компетенциями в данной области. Приведу примеры того, что мне кажется актуальным в данный момент, и на что стоит обращать внимание при тестировании.
В Angular есть тестовые фреймворки: karma
и protactor
. В Nx решили использовать в качестве тестовых фреймворков: jest
и cypress
.
В проект были также добавлены 2 библиотеки:
- ng-mocks — библиотека для мокирования Angular сущностей (модулей, компонентов, пайпов, директив и сервисов);
- ts-mockito — библиотека для мокирования typescript сущностей.
Вопрос: Что можно тестировать в Angular?
Ответ: Каждый элемент системы в Angular может быть протестирован.
Есть множество подходов к процессу тестирования. У QA есть свой путь и видение того, как должны быть организованы тесты.
Типы тестов
Очень важно в процессе написания тестов оставаться человеком, и понимать какие части системы могут быть нарушены, а какие будут работать стабильны.
Иначе все будет как в старом анекдоте про тестировщика:
Тестировщик заходит в бар и заказывает:
кружку пива,
2 кружки пива,
0 кружек пива,
999999999 кружек пива,
ящерицу в стакане,
–1 кружку пива,
qwertyuip кружек пива.
Первый реальный клиент заходит в бар и спрашивает, где туалет. Бар вспыхивает пламенем, все погибают.
Какие тесты стоит писать?
Обычно это тесты на создание компонента/модуля/директивы/пайпа/сервиса.
Далее идут тесты на проверку входных данных, которые были переданы.
Затем группы тестов на корректный результат методов, после тестирование некорректного поведения.
Тестирование связки HTML + Javascript
Если unit тесты для большинства случаев понятны, то для Angular важно тестирование связки HTML + JavaScript
.
Под этим понимается тестирование компонентов, где при изменении значений в компоненте, шаблон компонента реагирует соответствующим образом.
Например, если в компоненте изменилось значение для свойства, то в шаблоне должно отобразиться новое значение.
Очень часто, разработчики пишут тесты на проверку именно работы методов в компоненте, но не тестируют их работоспособность в связке с шаблонами. Это часто приводит к ложному ощущению полного покрытия тестами, что не является таковым.
Примеры тестирования данной связки будут разобраны ниже.
Использование Stub’ов
Обычно, я создаю тестовые объекты (stub), которые в дальнейшем использую везде. Однако, по одному из правил тестирования — в тестах не должно быть кода.
Например, у вас есть сущность клиента (customer
) из 3 полей: name
, phone
и email
.
Если вы пишите тесты без stub’ов, то во всех местах, где есть клиент, возвращаете некий объект.
Затем вы добавляете в клиента еще одно поле — photo
.
Для того чтобы все тесты были корректными, нужно пройтись по всем файлам и руками дописать туда новое поле photo
.
После вы решаете удалить поле — email
.
Снова идете по всем файлам и удаляете поле email
.
В итоге, вместо написания кода, вы исправляете файлы тестов.
Насколько нужна практика такого тестирования я не знаю, поэтому я всегда использую stub’ы, чтобы не заниматься этой бесполезной рутиной.
Есть кейсы, где так делать не нужно, но это ~ 1% от всех разрабатываемых тестов.
Использование PageObject
Для того чтобы упростить тестирование и понимание процесса можно использовать такой паттерн как PageObject
.
PageObject — объект, который позволяет инкапсулировать внутри себя логику изменения страницы и компонента и предоставить простой доступ свойствам и методам класса или приложения.
В приложении используется следующий класс для упрощения создания PageObject’ов для компонентов Angular.
getByAutomationId
— позволяет найти элемент на странице поautomation-id
;getAllByAutomationId
— позволяет найти коллекцию элементов на странице;text
— возвращает текст дляautomation-id
;triggerEventHandler
— метод, который позволяет вызвать событие на странице.
Примеры использования PageObject
будут рассмотрены в разделе тестирования компонентов.
Тестирование сервисов
Одной из главных задач при тестировании Angular приложения — протестировать работоспособность сервисов — ключевых элементов приложения.
Обычно для тестирования достаточно сформировать список зависимостей и передать их в конструктор сервиса.
В качестве примера покажем тестирование сервиса PlatformService
:
Сервис зависит от единственной константы, которая является InjectionToken’ом.
Запустим тесты командой:
nx test core-platrorm-service
Тест проверяет, что соответствующие методы возвращают корректный результат в зависимости от полученного токена.
Возьмем сервис посложнее, который содержит много зависимостей — MetaService
:
Сервис добавляет мета теги на страницу.
Протестируем данный функционал, создав файл meta.service.spec.ts
:
Сначала в тесте определяются все необходимые зависимости:
let getProp: <T = HTMLMetaElement>(prop: string) => T | null;let service: MetaService;
let routerMock: Router;
let environmentServiceMock: EnvironmentService;
let document: Document;
beforeEach(() => {
routerMock = mock(Router);
environmentServiceMock = mock(EnvironmentService);
});
Затем, используя TestBed
, конфигурируется тестовое окружение, в которое передаются все сервисы и токены:
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [],
providers: [
providerOf(Router, routerMock),
providerOf(EnvironmentService, environmentServiceMock),
{
provide: LOCALE_ID,
useValue: 'ru-RU',
},
{
provide: META_CONFIG,
useValue: META_CONFIG_DEFAULT,
},
{
provide: META_CONFIG_OG,
useValue: META_CONFIG_OG_DEFAULT,
},
],
}).compileComponents();
});
Отметим, что provideOf
это функция создания Angular провайдера для ts-mockito
:
После создания тестового окружения можно получить инстансы сервисов и их протестировать:
beforeEach(() => {
when(environmentServiceMock.environments).thenReturn(ENVIRONMENTS_DEFAULT);
when(routerMock.url).thenReturn('/');
service = TestBed.inject(MetaService);
document = TestBed.inject(DOCUMENT);
getProp = <T = HTMLMetaElement>(prop: string) => document.getElementById(prop) as T | null;
});
Функции when
и методthenReturn
формируют запрос ответ для мокируемых данных.
В данном случае при запросе environmentServiceMock.environments
вернется ENVIRONMENTS_DEFAULT
. А routerMock.url
вернет строку /
.
Напишем тест, который должен добавить для страницы требуемые мета теги:
it('should set meta', () => {
service.update();
expect(document.title).toBe(`${META_CONFIG_DEFAULT.title} | ${ENVIRONMENTS_DEFAULT.brand}`); expect(getProp<HTMLLinkElement>('canonical')?.href).toBe('http://localhost/'); expect(getProp('meta-description')?.content).toBe(META_CONFIG_DEFAULT.description); expect(getProp('meta-keywords')?.content).toBe(META_CONFIG_DEFAULT.keywords); expect(getProp('meta-og:title')?.content).toBe(`${META_CONFIG_OG_DEFAULT.title} | ${ENVIRONMENTS_DEFAULT.brand}`); expect(getProp('meta-og:description')?.content).toBe(META_CONFIG_OG_DEFAULT.description); expect(getProp('meta-og:type')?.content).toBe(META_CONFIG_OG_DEFAULT.type); expect(getProp('meta-og:locale')?.content).toBe('ru-RU'); expect(getProp('meta-og:site_name')?.content).toBe(ENVIRONMENTS_DEFAULT.brand); expect(getProp('meta-og:image')?.content).toBe(`${ENVIRONMENTS_DEFAULT.appHost}${META_CONFIG_OG_DEFAULT.image}`); expect(getProp('meta-og:image:type')?.content).toBe(META_CONFIG_OG_DEFAULT.imageType); expect(getProp('meta-og:image:width')?.content).toBe(META_CONFIG_OG_DEFAULT.imageWidth); expect(getProp('meta-og:image:height')?.content).toBe(META_CONFIG_OG_DEFAULT.imageHeight);
});
Данный тест перебирает все устанавливаемые свойства и проверяет их значение.
nx test core-meta-service
Если сервис содержит реактивную логику, то в тестировании используется jasmine-marbles.
Разберем сервис определения текущей платформы — LayoutService
.
Сервис использует BreakpointObserver
, который отслеживает изменения экрана и вызывает соответствующие события для отслеживаемых размеров.
this.breakpointObserver
.observe(LAYOUT_TYPES)
.pipe(
tap((result) => {
let type;
for (const query of Object.keys(result.breakpoints)) {
if (result.breakpoints[query]) {
type = LAYOUT_SHORT_TYPES_MAP[query];
break;
}
}
this.layoutSubject$.next(type ?? Breakpoints.Handset);
})
)
.subscribe();
Доступ к размеру осуществляется с помощью подписки на layoutType$
:
get layoutType$(): Observable<string> {
return this.layoutSubject$.asObservable();
}
Добавим файл с тестом:
Как и в предыдущем варианте, сначала объявлены зависимости, а потом они переданы в сервис:
let breakpointObserverMock: BreakpointObserver;
let service: LayoutService;
let observe$: ReplaySubject<BreakpointState>;
beforeEach(() => {
breakpointObserverMock = mock(BreakpointObserver);
observe$ = new ReplaySubject<BreakpointState>(1);
when(breakpointObserverMock.observe(deepEqual(LAYOUT_TYPES))).thenReturn(observe$);
});
beforeEach(() => {
service = new LayoutService(instance(breakpointObserverMock));
});
Рассмотрим тест:
it('should return correct handset types', () => {
observe$.next({
matches: true,
breakpoints: {
[Breakpoints.Handset]: true,
},
});
expect(service.layoutType$).toBeObservable(cold('a', { a: Breakpoints.Handset }));
expect(service.is(Breakpoints.Handset)).toBeTruthy();
});
observe$.next()
— испускает новое событие изменения размера.
Затем expect()
ожидает observable
с помощью вызова toBeObservable()
.
Таким образом тестируются все допустимые размеры данного сервиса.
nx test core-layout-service
Тестирование директив
Для тестирования директивы в Angular необходимо создать новый класс, к которому нужно добавить тестируемую директиву.
Рассмотрим тестирование ExtractTouchedDirective
:
Данная директива отслеживает изменения FormControl
и в случае если происходит событие touched
, компонент вызывает метод markAsTouched
.
Сначала создается тестовый класс:
@Component({
template: `<div banshopExtractTouched [control]="control"></div>`,
})
class WrapperComponent {
control = new FormControl(null, [Validators.required]);
@ViewChild(ExtractTouchedDirective) directive!: ExtractTouchedDirective;
}
Затем создается тестовое окружение:
let fixture: ComponentFixture<WrapperComponent>;
beforeEach(
waitForAsync(() => {
void TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [ExtractTouchedDirective, WrapperComponent],
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(WrapperComponent);
});
Затем проверяем корректность создания компонента:
it('should create an instance directive', () => {
fixture.detectChanges();
expect(fixture.componentInstance.directive).toBeTruthy();
});
Если компонент создается успешно, тогда можно проверить логику работы директивы:
it('should call markForCheck after control touched', () => {
fixture.detectChanges();
const spy = jest.spyOn((fixture.componentInstance.directive as any).changeDetectorRef, 'markForCheck');
fixture.componentInstance.control.markAsTouched();
expect(spy).toHaveBeenCalled();
});
Используя jest.spyOn
подписываемся за наблюдением вызова метода markForCheck
.
nx test core-forms-extract -t "ExtractTouchedDirective"
Тестирование pipe
Так как пайпы представляют собой методы трансформирования данных, то тестирование пайпов обычно совпадает с тестированием сервисов.
Рассмотрим тестирование BanshopExtractFormGroupPipe
:
Создадим файл теста extract-form-group.pipe.spec.ts
:
Сначала создается экземпляр пайпа:
let pipe: BanshopExtractFormGroupPipe;
beforeEach(() => {
pipe = new BanshopExtractFormGroupPipe();
});
Затем проверяется работоспособность:
it('should return field by name', () => {
const form = new FormGroup({
field: new FormGroup({
subfield: new FormControl(),
}),
});
expect(Object.keys(pipe.transform(form, 'field').controls).length).toBe(1);
});
В данном случае предаем форму, и пытаемся из нее получить требуемое поле — field
..
nx test core-forms-extract -t "ExtractFormGroupPipe"
Тестирование компонентов
Рассмотрим тестирование компонента главной страницы.
В данном случае имеем блок, который выводит список товаров.
Создадим PageObject
— home-page.component.po.ts
:
Тогда тест будет выглядеть следующим образом:
Сначала как и всегда определяется тестовое окружение:
let pageObject: HomePageComponentPo;
let fixture: ComponentFixture<HomePageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule, MockModule(ProductListModule)],
declarations: [HomePageComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HomePageComponent);
pageObject = new HomePageComponentPo(fixture);
});
Затем проверяется корректность создания компонента:
it('should create', () => {
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
});
Затем проверяем, а отрисовался ли элемент на странице:
it('should show', () => {
fixture.detectChanges();
expect(pageObject.list).toBeTruthy();
});
Запустим тесты:
nx test home-page
Разберем более сложный компонент —Carousel .
Карусель имеет набор слайдов и элементы управления в виде стрелок и кнопок.
Интересны следующие кейсы:
- При клике влево/вправо, должен отобразиться соответствующий слайд.
- При клике на кнопки слайдов, то должен отобразиться выбранный слайд.
Добавим pageObject carousel.component.po.ts
:
В данном случае PageObject помимо элементов:
get carouselComponent(): CarouselComponent | null {
return this.carousel?.componentInstance;
}
содержит методы вызова требуемых событий:
triggerCarouselSlideFirstClick(): void {
this.triggerEventHandler(this.carouselSlides[0], 'clicked');
}
Создадим класс теста:
Компонент достаточно прост, но из-за того, что он имеет входящие параметры создается класс обертка:
@Component({
template: `<banshop-carousel automation-id="carousel" [images]="images"></banshop-carousel>`,
})
export class WrapperComponent {
images = ['/1.jpg', '/2.jpg', '/3.jpg'];
}
Далее создается тестовое окружение:
let pageObject: CarouselComponentPo;
let fixtureWrapper: ComponentFixture<WrapperComponent>;
beforeEach(
waitForAsync(() => {
void TestBed.configureTestingModule({
imports: [MockModule(CarouselDotsModule), MockModule(CarouselNavsModule), MockModule(CarouselSlideModule)],
declarations: [CarouselComponent, WrapperComponent],
}).compileComponents();
})
);
beforeEach(() => {
fixtureWrapper = TestBed.createComponent(WrapperComponent);
pageObject = new CarouselComponentPo(fixtureWrapper);
});
MockModule
— позволяет замокать модуль, который будет корректно себя вести.
Сначала проверяется, что все компоненты отображены:
it('should show slides, dots and navs', () => {
fixtureWrapper.detectChanges();
expect(pageObject.carouselDots).toBeTruthy();
expect(pageObject.carouselNavs).toBeTruthy();
expect(pageObject.carouselSlides.length).toBe(3);
});
Затем проверяется, что при клике происходят требуемые изменения:
it('should call selected after click on first slide', () => {
const selected = jest.spyOn(pageObject.carousel?.componentInstance.clicked, 'emit');
fixtureWrapper.detectChanges();
pageObject.triggerCarouselSlideFirstClick();
fixtureWrapper.detectChanges();
expect(selected).toBeCalled();
});
it('should change active slide after click next', () => {
fixtureWrapper.detectChanges();
pageObject.triggerCarouselNavsNextClick();
fixtureWrapper.detectChanges();
expect(pageObject.carouselActiveSlideIndex).toBe(1);
});
it('should change active slide after click prev', () => {
fixtureWrapper.detectChanges();
pageObject.triggerCarouselNavsPrevClick();
fixtureWrapper.detectChanges();
expect(pageObject.carouselActiveSlideIndex).toBe(2);
});
it('should select slide after click on dot', () => {
const selected = jest.spyOn(pageObject.carousel?.componentInstance, 'onSelected');
fixtureWrapper.detectChanges();
pageObject.triggerCarouselDotsClick(2);
fixtureWrapper.detectChanges();
expect(selected).toBeCalledWith(2);
});
Запустим тесты:
nx test ui-carousel
Тестирование Ngrx селекторов
При использовании Ngrx необходимо тестировать структурные элементы, в частности селекторы.
Рассмотрим тесты для cart.selectors.ts
:
Напишем тесты:
Смысл тестов на селекторы — выбрать значение из state
и убедится, что оно совпадает с ожидаемым.
Feature state
создается с помощью маленькой функции:
const getStore = (state?: Partial<CartState>, products: CartProduct[] = []): CartPartialState => ({
[CART_FEATURE_KEY]: cartAdapter.setAll(products, { ...cartInitialState, ...state }),
});
После этого можно проверять значения в state
:
it('getCartError() should return the current "error" state', () => {
state = getStore({ loaded: true }, CART_PRODUCTS_STUB);
const result = CartSelectors.selectCartProductsEntities(state);
expect(Object.keys(result).length).toEqual(CART_PRODUCTS_STUB.length);
});
it('selectCartProducts() should return cardProducts', () => {
state = getStore({ loaded: true }, CART_PRODUCTS_STUB);
const results = CartSelectors.selectCartProducts(state);
expect(results.length).toBe(CART_PRODUCTS_STUB.length);
});
it('selectCartLoaded() should return loaded', () => {
state = getStore({ loaded: true });
const result = CartSelectors.selectLoaded(state);
expect(result).toBeTruthy();
});
Запустим тесты:
nx test cart-state -t "Cart Selectors"
Тестирование редьюсера
Далее в списке тестирования идет редьюсер.
Редьюсер должен при вызове action’а изменять state
.
Добавим класс тестов — cart.reducer.spec.ts
:
Сначала создается тестовое окружение:
const getState = (state?: Partial<CartState>, products: CartProduct[] = []): CartState =>
cartAdapter.setAll(products, { ...cartInitialState, ...state });
let state: CartState;
beforeEach(() => {
state = getState();
});
Затем проверяются все используемые в редьюсере экшены:
it('clear() should remove all', () => {
state = getState({}, CART_PRODUCTS_STUB);
const action = CartActions.clear();
const result = reducer(state, action);
expect(result.loaded).toBeTruthy();
expect(result.ids.length).toBe(0);
});...
Запустим тесты для редьюсера:
nx test cart-state -t "Cart Reducer"
Тестирование эффектов
Последней обязательной частью является тестирование эффектов.
Создадим файл тестов — cart.effects.spec.ts
.
В начале создается тестовое окружение:
let actions: Observable<Action>;
let effects: CartEffects;
let store: MockStore;
let localAsyncStorageMock: LocalAsyncStorage;
let getItem$: BehaviorSubject<CartProduct[] | null>;
let setStore: (state?: Partial<CartState>, products?: CartProduct[]) => void;
beforeEach(() => {
localAsyncStorageMock = mock(LocalAsyncStorage);
getItem$ = new BehaviorSubject<CartProduct[] | null>(null);
});
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
CartEffects,
provideMockActions(() => actions),
provideMockStore({
initialState: {
[CART_FEATURE_KEY]: cartInitialState,
},
}),
providerOf(LocalAsyncStorage, localAsyncStorageMock),
],
});
})
);
beforeEach(() => {
when(localAsyncStorageMock.getItem(CartKeys.Cart)).thenReturn(getItem$);
effects = TestBed.inject(CartEffects);
store = TestBed.inject(MockStore);
setStore = (state: Partial<CartState> = {}, products: CartProduct[] = []) =>
store.setState({
[CART_FEATURE_KEY]: cartAdapter.setAll(products, { ...cartInitialState, ...state }),
});
});
Сначала определяются все сервисы и свойства, которые должны быть замокированы.
Затем все мок сервисы добавляются в тестовую среду.
После создания окружения идет получение инстанса класса эффектов.
Затем для каждого созданного эффекта пишется тест:
describe('init$', () => {
it('should work', () => {
const action = CartActions.init();
const completion = CartActions.restore({ cartProducts: [] });
actions = hot('-a-|', { a: action });
const expected = hot('-a-|', { a: completion });
expect(effects.init$).toBeObservable(expected);
});
});
В данном тесте проверяется, что если вызывается экшен init
, то затем должен вызваться экшен restore
.
Запустим тесты:
nx test cart-state -t "CartEffects"
Запуск всех тестов в монорепозитории
Описанных выше примеров должно хватить для понимания и описания всех unit тестов в приложении.
Запустим все тесты:
nx run-many --target=test --all --parallel --maxParallel=8
Как видно из команды, было запущено 75 проектов тестов:
> NX Successfully ran target test for 75 project
Ссылки
Предыдущая статья — Настройка SSR.
Следующая статья — Заключение.
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — article.
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln