
Когда приложение разрастается или нам очень важно, чтобы оно работало верно при любом рефакторинге, мы начинаем задумываться о unit или e2e тестировании.
За несколько лет работы с Angular — приложениями в корпоративном сегменте, поймав множество проблем при рефакторинге и создании нового функционала, тесты кажутся не бесполезной тратой времени, а ключом к стабильности, находящимся в руках программистов.
Далее попробуем разобраться с тестированием базового приложения на Angular и затронем немного теории.
Начнем с видов тестирования, которые могут производить разработчики приложений (в нашем случае frontend).

(Мартин Фаулер и Google Testing Blog)
У Angular такие методы есть “из коробки”.
Рассмотрим подробнее пирамиду тестирования на примере Angular- приложений.
Unit тестирование – изолированное тестирование методов класса. Проверяем работу метода, передавая на вход различные комбинации параметров, и сравниваем полученный результат с ожидаемым.
Например, у нас есть компонент, в котором происходит конвертация hex строки в rgb, вывод этой информации пользователю, а также отправка на rest-сервер.
Если начнем смотреть в сторону unit-тестов, то мы можем на каждый метод в классе компонента написать множество тестов.
Если у нас есть метод rgbToHex(hex: string) => string, то для проверки метода с такой сигнатурой нам потребуется сделать следующее: expect(rgbToHex(‘777’)).toBe(‘rgb(119, 119, 119)’).
Вроде здорово, но у нас возникает проблема из-за большого количества функций, которые надо покрыть, и нам становится лень писать тесты. Кроме того, даже написав unit-тесты на каждый метод в компоненте, мы не гарантируем правильность их совместной работы.
Например, сделав и протестировав компонент вывода таблиц, мы можем вызвать его в компоненте “калькулятор”, но случайно указать неверный биндинг. Тогда данные не попадут в таблицы, и полностью рабочие компоненты не будут работать корректно.

Интеграционное тестирование — тестирование связки нескольких компонентов. С этого этапа мы начинаем тестировать не просто методы класса, но и их привязку к html, т.е. кликаем на элементы внутри компонента. В нотации Angular часто встречается Shallow testing, что по сути и является интеграционным тестированием.
На Shallow testing мы подробнее остановимся ниже.
E2E (end-to-end) тестирование — способ тестирования приложения полностью, чтобы решить проблемы unit-тестов.
При этом подходе мы пишем тестовые сценарии для полностью отрендеренного приложения, т.е. все компоненты и сервисы собраны воедино, и мы воспроизводим действия пользователя.
Это очень здорово, но у нас может появиться проблема динамического изменения стабов (обычно это статический json сервер на node.js), а для воспроизведения различных ситуаций нам могут потребоваться разные данные от эмулируемого сервера.
Например: у нас есть страница со списком пользователей, одним из компонентов этой панели является пагинация. Она должна вести себя по-разному при различном количестве пользователей. Но если данные на эмулируемом сервере заданы через json, то тогда количество пользователей будет всегда одним, и нам не удастся проверить все кейсы с различным количеством страниц.
В итоге можно прийти к выводу, что, если мы хотим гибко менять стабы и тестировать не по методам, а по логическим единицам интерфейса, нужно использовать что-то среднее между e2e и unit-тестами. Вот тут-то на помощь нам и приходит shallow testing (интеграционное тестирование).
Глоссарий:
- Моки – это объекты для имитации ответов при различных use-case.
- Стабы – это максимально глупые заглушки без логики (можно почитать Мартина).
- Karma — тест-ранер, встроенный в angular (часто вместо него советуют jest, но сегодня не об этом).
- Jasmine — фреймворк для описания тестов в виде спеков (см.ниже).
- spec — расширение файла с тестами (описание спецификации в стиле BDD).
- it — название методов для тестов в Jasmine.
- xit — название методов, которые не будут запускаться.
- fit — если в спеках есть методы с таким именем, то будут запущены только они.
Shallow testing для Аngular, как и для других фреймворков, – это подход unit-тестов, когда рендерится компонент размера достаточного, чтобы он мог существовать как отдельная единица ui со своей функциональностью.
Например, у нас есть компонент для конвертации hex -> rgb. Мы можем отрендерить только этот компонент, генерировать стабы для разных ситуаций, выполнить возможные use-case для этого компонента с точки зрения конечных пользователей и проверить работу компонента.
Давайте попробуем разобраться на примере (репозиторий).
Подготовим класс для доступа к элементам компонента в соответствии с PageObject и добавим хелпер в корень проекта.
Хелпер — будет помогать искать элементы в компонентах, которые были выбраны для рендеринга. Так можно облегчить жизнь, если мы используем Angular Material: тогда элементы типа select будут создавать список с option в отдельном блоке, и поиск этих элементов может привести к бойлерплейтам, а обертка в виде хелпера может помочь.
export class PageObjectBase { constructor(private root: HTMLDivElement) { } // Упрощаем доступ к input элементам компонентов _inputValue(cssSelector: string, value: string) { if (value) { this.root.querySelector<HTMLInputElement>(cssSelector).value = value; this.root.querySelector<HTMLInputElement>(cssSelector).dispatchEvent(new Event('input')); } else { return this.root.querySelector<HTMLInputElement>(cssSelector).value } } // Теперь достаточно вызвать метод и передать селектор кнопки для клика _buttonClick(cssSelector: string) { this.root.querySelector<HTMLButtonElement>(cssSelector).dispatchEvent(new Event('click')); } }
PageObject — популярный шаблон автоматизированного тестирования. Используется для упрощения поддержки написанных тестов. Если мы изменим UI, то тесты нам не придется переписывать, просто изменим селекторы элементов.
export class ConverterFromHexPageObject extends PageObjectBase { constructor(root: HTMLDivElement) { super(root) } hex(text?: string) { return this._inputValue('.input-hex', text); } rgb(text?: string) { return this._inputValue('.input-rgb', text); } clear() { this._buttonClick('.btn-clear') } calc() { this._buttonClick('.btn-calc') } }
и сами тесты:
// Предположим, что сервис в компоненте должен работать с api по этому адресу const urlToSave = 'http://localhost:4200/save-hex'; // Определим тест-сьют для тестирования компонента describe('ConverterFromHexComponent', () => { let component: ConverterFromHexComponent; let fixture: ComponentFixture<ConverterFromHexComponent>; let page: ConverterFromHexPageObject; let httpTestingController: HttpTestingController; // Подготовительные действия перед каждым запуском теста, формируем тестовый модуль beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ConverterModule, HttpClientTestingModule] }) .compileComponents(); httpTestingController = TestBed.get(HttpTestingController); })); // Настраиваем компонент, задаем начальные параметры beforeEach(() => { fixture = TestBed.createComponent(ConverterFromHexComponent); component = fixture.componentInstance; page = new ConverterFromHexPageObject(fixture.nativeElement); fixture.detectChanges(); }); // Хорошей практикой считается проверять, что не осталось зависших http запросов afterEach(() => { httpTestingController.verify(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should clear', async () => { page.hex('112233'); // в input ввели число expect(page.hex()).toBe('112233'); // проверили, что число на месте await page.clear(); // очищаем поле с помощью кнопки компонента fixture.detectChanges(); // запускаем цикл проверки изменений expect(page.hex()).toBe(''); // проверяем, что кнопка clear сработала }); it('should convert', async () => { page.hex('123123'); expect(page.rgb()).toBe(''); page.calc(); const req = httpTestingController.expectOne(urlToSave); expect(req.request.method).toEqual('POST'); expect(req.request.body.hex).toEqual('123123'); req.flush({}); await fixture.detectChanges(); expect(page.rgb()).toBe('rgb(18, 49, 35)'); }); it('should convert three-digit hex', async () => { page.hex('567'); expect(page.rgb()).toBe(''); page.calc(); const req = httpTestingController.expectOne(urlToSave); expect(req.request.method).toEqual('POST'); req.flush({}); await fixture.detectChanges(); expect(page.rgb()).toBe('rgb(85, 102, 119)'); }); it('rgb should be empty when entered incorrectly hex', async () => { page.hex('qw123we'); page.calc(); const req = httpTestingController.expectNone(urlToSave); await fixture.detectChanges(); expect(page.rgb()).toBe(''); }); });
Вроде все просто, но вот несколько интересных замечаний для Angular Shallow testing:
- Всегда проверяйте, что не осталось незарезорвленных http запросов, это поможет точно понять, есть ли ненужные для нашей функциональности запросы.
- Для больших компонентов можно воспользоваться PageObject паттерном, чтобы не копипастить селекторы к элементам.
- Сделать хелпер для считывания json стабов и использовать их как заготовки стабов, которые во время теста можно изменять.
- Настроить минимальный уровень покрытия тестами после покрытия основного функционала, чтобы держать планку с добавлением новой функциональности.
- Использовать параллельное выполнение тестов.
- Писать позитивные и негативные тесты.
- Если есть работа с хранилищами, то надо чистить session, local, cookie перед каждым тестом (где это необходимо).
- Избавиться от эффекта пестицида можно благодаря fake.js.
- Избегайте плохих запахов.
База знаний
Репозиторий с примерами
