Сайт визитка на Angular. Unit тестирование.

Aleksandr Serenko
F.A.F.N.U.R
Published in
10 min readMar 27, 2022
Сайт визитка на 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"

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

Рассмотрим тестирование компонента главной страницы.

В данном случае имеем блок, который выводит список товаров.

Создадим PageObjecthome-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

--

--

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

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