Тесты — фронтенд. Часть I. Юнит.

Artem “oxmap” Savin
14 min readJun 3, 2019

--

Это первая статья в цикле тестирования фронтенд. Целью статьи является ознакомление с автоматическим тестированием и показ написания(в каком направлении двигаться) модульных тестов (спецификаций).

Также к статье есть презентация, которая в большей части является keynote. https://youtu.be/Ov_gLfJRzxM

Перед прочтением необходимо указать несколько примечаний.

1. Статья о тестировании во фронтенд разработке (бекенд, десктоп, мобилки отпадают)
2. Упор на окружение Angular/Typescript
3. Автоматическое тестирование
4. Тестирование спецификаций(модульное)

Необходимость в компании

Главный тезис, который преподносится в этой статье: автоматические ТЕСТЫ НУЖНЫ. Нужны не меньше чем сами фичи. Ниже представлены аргументы, раскрывающие данный тезис.

Клиент

Для клиента тесты аргументируются сохранением средств на расходы ввиду ошибок ПО.

Компания

Для компании разработчика тесты нужны для поддержки проекта. Это значит, что проект, который развивается несколько лет, постоянно обеспечивает минимальный уровень качества, и количество дефектов в нем не превышает некоторого максимума. Мы не должны существенно ухудшать качество наших фич. Это можно гарантировать с помощью тестов, ручных и автоматизированных. Также в проект всегда входят новые люди, тесты гарантируют, что специалист не поломает реализованный функционал.

Тестировщик

Для тестировщика тесты позволяют писать более сложные тест-кейсы, которые максимально приближены к пользовательскому опыту. Самые простые ошибки программист с большой вероятностью устранит сам, если он будет систематически дополнять свой код тестовым.

Разработчик

Для разработчика преимуществ конечно же необходимо привести больше, так как в итоге писать тесты программисту (никому из приведенных выше).

При написании теста разработчик начинает лучше понимать требования, благодаря коммуникации с нетехническими специалистами. Уточняя моменты при написании тестов, разработчик делает более стабильным свой код и избавляет его от самых простых ошибок. Тесты позволяют гарантировать стабильность приложения при рефакторинге. Если модуль закрыть тестами, они качественно проверят, что основные кейсы работают. Это подтвердит, что те изменения, которые появились в приложении, ничего не сломали. Тесты служат отличной документацией к коду. Гораздо легче понять чужой код, когда есть какой-то потребитель этого кода. И самым первым потребителем как раз выступают тесты. Достаточно открыть тестовый класс, и понять, что этот код делает, просто почитав названия тестовых методов и поняв, какие данные там передаются.

Тесты позволяют формировать качественный API. Это достигается за счёт того, что заранее продумывается какие данные будут передаваться между сущностями вместо того, чтобы потом постоянно добавлять новые контексты и параметры.

Ну и самый пробивной аргумент: меньше ошибок. Ошибки отлавливаются еще на этапе прогона тестов, которые запускаются в CI среде.

В итоге приведенные аргументы в пользу тестов доказывают их необходимость в проекте. Но внедрение тестов необходимо не везде. Из-за их сложности и детальной проработки нет смысла вводить тесты в проект, который длится 1–2 месяца (маленький по объемам). Так как требования в проекте не большие и сложность проекта быстрее тестируется вручную.

Теория

Что такое юнит-тесты

Юнит(модульный) тест применяется для тестирования одной логически выделенной и изолированной единицы системы. Чаще всего это метод класса или простая функция (или весь класс).

Пирамида тестирования. Модульные тесты реализовывать быстрее, чем GUI тесты (время на разработку меньше). Модульные тесты отрабатывают быстрее, чем GUI. Модульные тесты дешевле, чем GUI тесты (однако, стремясь к вершине пирамиды, мы получаем большую уверенность в том, что все работает как ожидалось).

Пирамида тестирования

Автоматические тесты vs ручные тесты

На сегодняшний день невозможно использовать один из представленных видом тестирования. В компаниях используется комбинированный подход, при котором автоматические тесты пишутся в ходе разработки (модульные тесты), а ручное тестирование происходит по тестовым сценариям, проверяя бизнес-логику сразу, и уже в последующем внедряются более сложные автоматизированные тесты(интеграционные, GUI, нагрузочные).

Ручное тестирование необходимо при следующих видах тестирования:

  • Исследовательское тестирование. Данный вид тестирования помогает за короткое время обнаружить наиболее критичные дефекты.
  • Тестирование юзабилити. При проведении данного вида тестирования тестировщику важно определить, насколько удобным будет продукт для конечного пользователя.
  • Интуитивное тестирование (ad-hoc testing). Данный вид тестирования выполняется без заранее разработанного сценария и определенных результатов. Выполняя проверки, тестировщик импровизирует и полагается на здравый смысл, свой опыт и знание продукта.

Рекомендуется по возможности автоматизировать следующие виды тестирования (приведены по приоритету):

  • Регрессионное тестирование. Так как тесты запускаются постоянно, то необходимо постоянно проверять, что новая функциональность не ломает совместимость с описанными ранее требованиями. На долгосрочных проектах автоматизация позволяет значительно сократить затраты на обеспечение качества продукта.
  • Нагрузочное тестирование. Автоматизация нагрузочного тестирования позволяет быстрее получать результаты, экономить на мощностях и стоимости инструментов.
  • Тестирование локализации. Если продукт будет выводиться на мировой рынок, его необходимо адаптировать к культурным аспектам разным странам. Локализация включает в себя перевод всех элементов интерфейса, служебных элементов, адаптацию режима отображения даты, времени, единиц измерения, валюты.

Термины

Для понимания концепций и чтения статьи необходимо понимать основные термины и шаблоны в модульных тестах.

Функции-проверки (Asserts) — Набор функций, позволяющих производить сравнения результатов выполнения двух и более функций. Может предоставлять возможность сравнения структур вглубь, используя механизмы интроспекции проверять у объектов наличие тех или иных свойств.

Заглушка (Stub, Dummy) — функция или метод класса, которая подменяет реализацию оригинальной функции и не выполняет никакого осмысленного действия, возвращает пустой результат или тестовые данные.

Макет (Mockup) — экземпляр объекта, который представляет собой конкретную фиктивную реализацию определенного интерфейса. Макет, как правило, предназначен для подмены оригинального объекта системы, исключительно для тестирования взаимодействия и изолированности тестируемого компонента. Методы объекта чаще всего из себя представляют Заглушки (Stubs, Dummies), описанные выше.

Шпион (Spy) — Объект-обертка, по типу прокси, которая слушает вызовы и сохраняет информацию об этих вызовах (аргументы, количество вызовов, контекст) оригинального объекта системы. Далее сохраненные шпионом данные используются в тестах.

Испытательная Платформа (TestBed) — это специально воссозданная тестовая среда, можно сказать, платформа для тестирования (может быть комплекс Макетов, Заглушек и Шпионов). Применяется для комплексного тестирования отдельных связок компонентов или всей системы. Также может использоваться, как площадка для экспериментов.

Фикстура (Fixture) — это механизм позволяющий привести объект или всю систему в определенное состояние и зафиксировать это состояние для тестов. Под фикстурой чаще всего понимают тестовые данные необходимые для корректного запуска тестов, а также механизмы загрузки/выгрузки этих данных в хранилище. Т.е. основное назначение фикстур — это привести данные системы к определенному состоянию (фиксированному), которое будет точно известно во время выполнения тестов.

Концепции

Перед тем, как раскрыть концепции стоит упомянуть об успешности системы тестирования в проекте.

Успешность системы:

  • Количество багов в новых релизах (в т.ч. и регрессии).
  • Покрытие кода.

Первый показывает, есть ли у наших действий результат, или мы впустую расходуем время. Второй — как много нам еще предстоит сделать.

Для поддержания успешности в проектах необходимо придерживаться необходимых концепций, лежащих в основе модульного тестирования:

  1. Тесты в пределах проекта должны быть расположены в соответствии с общей логикой и должны быть частью системы контроля версий.
  2. Написание тест кейсов должно быть осмысленным. Читая название кейса, разработчик должен понимать, что в нем происходит и что ожидается от кейса.
  3. Каждый тестирующий класс или метод должен тестировать только одну сущность.
  4. Модульные тесты — это спецификация дизайна того, как должно срабатывать определенное поведение, а не список наблюдений за всем кодом.
  5. Тесты не должны запускаться в определенном порядке. Также тесты не должны зависеть от активной базы данных или сетевом соединении, или стенде.
  6. Тесты не должны зависеть от окружения, в котором они выполняются. Не важна версия браузера, устройства, операционной системы.
  7. Все принципы, применяемые в разработке продакшн-кода, могут и должны применяться при написании тестов. (DRY, KISS)
  8. Тест должен легко поддерживаться.
  9. Тесты должны запускаться регулярно в автоматическом режиме.

Тестирование в Angular

Karma/Jasmine

Для запусков тестов в Angular используется Karma+Jasmine сразу из коробки. Но также есть возможность менять среду исполнения тестов на необходимую.

Karma — это раннер тестов. Под запуском тестов подразумевается старт веб-сервера, который выполняет исходный код в сравнении с тестовым кодом для каждого из подключенных браузеров. Результаты каждого теста для каждого браузера проверяются и отображаются в командной строке для разработчика, чтобы они могли видеть, какие браузеры и тесты пройдены или не пройдены.

Karma также просматривает все файлы, указанные в файле конфигурации, и всякий раз, когда какой-либо файл изменяется, он запускает тестовый запуск, отправляя на сервер тестирования сигнал, информирующий все захваченные браузеры о необходимости повторного запуска тестового кода. Затем каждый браузер загружает исходные файлы внутри iframe, выполняет тесты и сообщает результаты обратно на сервер.

Сервер собирает результаты всех захваченных браузеров и представляет их разработчику.

Jasmine — фреймворк для написания тестов. Данный фреймворк позиционируется как BDD фреймворк, то есть пытается описать пример метода тестирования в удобочитаемой для человека форме, чтобы любой пользователь, включая нетехнического, мог определить, что происходит. Jasmine тесты написаны с использованием функций JS/TS, что делает написание тестов хорошим расширением написания кода приложения.

Внедрение зависимостей

Тесты в Angular можно разделить на 2 типа:

  • изолированные
  • тесты с зависимостями.

При изолированном подходе мы тестируем сервис как самый обыкновенный класс с методами. Сначала создаем экземпляр класса, а затем проверяем, как он работает в различных ситуациях.

В большинстве случаев (90%) в классы приложений внедряется сервис с помощью внедрения зависимостей Angular, и для этого шаблона использования должен быть механизм тестирования. Утилиты тестирования Angular позволяют легко исследовать поведение внедренных сервисов.

Когда у службы есть зависимая служба, DI находит или создает эту зависимую службу. И если этот зависимый сервис имеет свои собственные зависимости, DI также находит или создает их.

Как потребитель сервиса, вы не беспокоитесь об этом. Вы не беспокоитесь о порядке аргументов конструктора или о том, как они создаются. Но при тестировании вы должны хотя бы подумать о первом уровне зависимостей сервисов, но вы можете позволить Angular DI создавать сервисы и работать с порядком аргументов конструктора, когда вы используете утилиту тестирования TestBed для предоставления и создания сервисов.

Утилита TestBed создает динамически созданный модуль тестирования Angular, который эмулирует Angular @NgModule. Чтобы протестировать сервис, вы устанавливаете свойство метаданных провайдеров с массивом сервисов, которые вы будете тестировать или макетировать.

Покрытие кода

Ранее было описано, что успешность тестируемой системы выражается в двух показателях: количество багов и покрытие кода. Данные метрики возможно измерить и давать оценку проектируемой системы. В данной статье будет представлена метрика покрытия кода.

Существуют следующие подходы к оценке и измерению тестового покрытия:

  • Покрытие требований (Requirements Coverage) — оценка покрытия тестами функциональных и нефункциональных требований к продукту путем построения матриц трассировки.
  • Покрытие кода (Code Coverage) — оценка покрытия исполняемого кода тестами, путем отслеживания непроверенных в процессе тестирования частей программного обеспечения.
  • Тестовое покрытие на базе анализа потока управления — оценка покрытия, основанная на определении путей выполнения кода программного модуля и создания выполняемых тест кейсов для покрытия этих путей.

Различия

Метод покрытия требований сосредоточен на проверке соответствия набора проводимых тестов требованиям к продукту, в то время как анализ покрытия кода — на полноте проверки тестами, разработанной части продукта (исходного кода), а анализ потока управления — на прохождении путей в графе или модели выполнения тестируемых функций (Control Flow Graph).

В данной статье нам интересна метрика покрытия кода.

Расчет тестового покрытия относительно исполняемого кода программного обеспечения проводится по формуле:

тестовое_поккрытие = (кол-ва_строк_кода_покрытых_тестами/общее_кол-во_строк_кода) * 100%

В настоящее время существует инструментарий, позволяющий проанализировать в какие строки были вхождения во время проведения тестирования, благодаря чему можно значительно увеличить покрытие, добавив новые тесты для конкретных случаев, а также избавиться от дублирующих тестов. Проведение такого анализа кода и последующая оптимизация покрытия достаточно легко реализуется в рамках тестирования белого ящика (white-box testing) при модульном, интеграционном и системном тестировании; при тестировании же черного ящика (black-box testing) задача становится довольно дорогостоящей, так как требует много времени и ресурсов на установку, конфигурацию и анализ результатов работы, как со стороны тестировщиков, так и разработчиков.

TDD

Существует несколько подходов к написанию тестов. Первая модель — классика: сначала разработка, а затем тестирование “code first”. Это означает, что сначала происходит написание кода, затем мы тестируем продукт и отправляем его или на доработку, или переходим к следующей стадии разработки.

Другой подход можно назвать “test first” режимом. Это означает, что мы можем начать тестирование еще до написания самой функции — например, мы можем создать единичный тест или автоматически выполняемый набор тестов до того, как функция или какой-то кусок кода будет разработан и внедрен в приложение. Одним из наиболее популярных примеров здесь является Test-Driven Development.

В основе Test-driven development (TDD) лежит 5 основных этапов:

  1. Сначала разработчик пишет несколько тестов.
  2. Затем разработчик запускает эти тесты и (очевидно) они терпят неудачу, потому что ни одна из этих функций еще не реализована.
  3. Далее разработчик действительно реализует эти тесты в коде.
  4. Если разработчик хорошо пишет свой код, то на следующем этапе он увидит, что тесты проходят.
  5. Разработчик может затем реорганизовать свой код, добавить комментарии так как он уверен, что, если новый код что-то сломает, тогда тесты предупредят об этом.

На практике эти шаги можно свести в 2:

  1. Пишите тест, который не проходит, до написания кода.
  2. Затем пишите код, который сможет пройти тест.

Когда мы используем TDD, мы говорим о цикле “red, green, refactor”.

Red: вы пишите провальный тест без написания кода.

Green: пишите простейший код, который сможет пройти тест.

Refactor: рефакторинг кода, если необходим. Не беспокойтесь, если вы поменяете код и ваши юнит-тесты сломаются, если что-то пойдет не так.

Практика

Порядок разработки

Одной из целей данной статьи привить правило разработчикам писать функциональность к задаче от спецификаций к коду и только в самом конце писать верстку. При переходе на следующий этап необходимо постоянно возвращаться к предыдущим этапам. Но нельзя перепрыгивать через этап. Получается такая схема spec <-> ts <-> html <-> css.

Но сразу стоит оговорится, что такая схема работает, только с учетом, что архитектура приложения (UML диаграмма), бизнес-требования(не меняющееся ТЗ во время разработки), компетентность разработчика(хотя бы прочитал эту статью) готовы.

Далее будут рассмотрены практические примеры разработки функциональных блоков приложения. Все аспекты написания тестов не уложить в одной статье. Будет показано направление, в котором необходимо двигаться, чтобы писать успешные системы.

Классы

В экосистеме Angular все является объектом класса (Компонент, Директива, Канал, Сервис). Поэтому для каждого класса есть общие правила тестирования.

export class TableService {
public highlightNewRow(nativeElement: any): void {
const scrollBody = nativeElement.getElementsByClassName('ui-table-scrollable-body')[0];
scrollBody.scrollTop = scrollBody.scrollHeight;
const inputs = nativeElement.querySelectorAll('input');
inputs[inputs.length - 1].select();
}
}

Для запуска тестовых спецификации без побочных эффектов мы каждый тест-кейс запускаем с создания объекта класса, это достигается с помощью функций beforeEach для настройки и очистки экземпляров следующим образом:

TestBed.configureTestingModule({
imports: [
SharedModule.forRoot(),
JwtTestingModule
]
});
testBed = getTestBed();
service = testBed.get(TableService);

В каждом наборе тестов должен присутствовать тест-кейс для создания сервиса.

it('should be created', () => {
expect(service).toBeTruthy();
});

Каналы

Примеры ниже взяты из библиотеки ngx-translate, так как каналы из этой библиотеки активно используются в проекте.

Каналы выбраны первыми в описании, так как они просты в тестировании: каналы наследуют интерфейс PipeTransform которой содержит одну функцию transform, поэтому в тестовом наборе кейсов будет присутствовать минимум покрытие этой функции передачи входных данных и ожидаемые выходные данные.

Ниже представлен пример подачи входных данных с учетом конфигурации системы. Мы указываем объект перевода для английского языка и указываем, что для ключа `TEST` перевод должен соответствовать ‘This is a test’, и выставляем язык глобально на английский.

it('should translate a string', () => {
translate.setTranslation('en', {'TEST': 'This is a test'});
translate.use('en');
expect(translatePipe.transform('TEST')).toEqual('This is a test');
});

В итоге в общем случае мы проверяем что в функцию входящие ключ будет соответствовать ожидаемым выходящим данным. Если в канале присутствуют функции для работы transform они являются приватными. И тестировать их необходимо по потребности.

Сервисы

Сервисы бывают общими, без зависимостей, эти сервисы тестируются проще остальных. Ниже представлен пример тестов для сервиса дат.

describe('DataService', () => {
let service: DateService;
beforeEach(() => {
service = new DateService();
});
it('#convertDate should return null', () => {
expect(service.convertDate(undefined)).toEqual(null);
expect(service.convertDate(null)).toEqual(null);
});
it('#convertDate should return Invalid Date', () => {
expect(service.convertDate('some text')).toEqual('Invalid date');
});
it('#getMinutesCount should return number', () => {
expect(service.getMinutesCount()).toEqual(jasmine.any(Number));
});
it('#getCounterTime should return string', () => {
expect(service.getCounterTime()).toEqual(jasmine.any(String));
});
});

В большинстве случаев классы имеют зависимости, поэтому необходимо использовать TestBed с созданием тестового модуля.

Ниже представлен пример тестов для сервиса аутентификаций.

Первым делом мы загружаем все зависимости с помощью TestBed.configureTestingModule перед каждым тест-кейсом.

let testBed;
let service: AuthService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
AuthModule,
SharedModule.forRoot()
]
});
});
testBed = getTestBed();
service = testBed.get(AuthService);
httpMock = testBed.get(HttpTestingController);

Для работы с сервисом достаточно взять его из testbed.

Модуль как видно зависит от самого модуля аутентификаций, запросов на сервер через HTTPClient (для тестов используется соответственный модуль), также для работы используется SharedModule для общих сервисов.

Далее представлен пример для работы с httptestingcontroller. В данном тест-кейсе мы тестируем сброс пароля. Фактически мы проверяем, что данные переданные с бекенд не модифицируются.

it('should reset password', () => {
const email = <EmailRecovery>{email: 'admin@gmail.com'};
service.passwordReset(email, mockComplete).subscribe((res) => {
expect(res).toBe(1);
});
const req = httpMock.expectOne(environment.realHost + authConfig.api.passwordReset);
expect(req.request.method).toBe('POST');
req.flush(1);
});

Директивы

Для проверки директивы мы обычно создаем фиктивный компонент тестирования, чтобы мы могли взаимодействовать с директивой и проверять ее влияние на представление компонентов. Компонент находится в том же файле, что и тесты для директивы.

@Component({
selector: 'app-test',
template: `
<form [formGroup]="form">
<div class="form__group">
<div id="invalidField" appInvalidField="name"></div>
<input formControlName="name"/>
</div>
<button id="submitButton" type="submit"></button>
</form>
`
})
class TestComponent {
public form: FormGroup;
constructor (private readonly fb: FormBuilder) {
this.form = fb.group({
name: new FormControl('', Validators.required)
});
}
}

Директива appInvalidField связана с компонентов в шаблоне. Теперь у нас есть компонент, с которым мы можем работать, мы можем настроить TestBed и получить необходимые ссылки для тестов, например:

let componentFixture: ComponentFixture<TestComponent>;
let fakeComponent: TestComponent;
let directive;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FormsModule, ReactiveFormsModule, NoopAnimationsModule],
declarations: [InvalidFieldDirective, TestComponent]
});
componentFixture = TestBed.createComponent(TestComponent); componentFixture.detectChanges();
fakeComponent = componentFixture.componentInstance;
const directiveEl = componentFixture.debugElement.query(By.directive(InvalidFieldDirective));
directive = directiveEl.injector.get(InvalidFieldDirective);
});

Мы берем ссылку на фиксатор компонента, а также компонент и входной DebugElement из представления компонента. Эти данные необходимы для тестирования директивы.

it('submit a form show errors', () => {
expect(fakeComponent.form.valid).toBeFalsy();

const submitButton = componentFixture.debugElement.query(By.css('#submitButton'));
const invalidField = componentFixture.debugElement.query(By.css('#invalidField'));
submitButton.nativeElement.click();
expect(invalidField.nativeElement.classList[0]).toBe('invalid-message');
});

DebugElement необходим для безопасной работы на всех поддерживаемых платформах. Вместо того, чтобы создавать дерево элементов HTML, Angular создает дерево DebugElement, которое оборачивает собственные элементы для платформы времени выполнения. Свойство nativeElement разворачивает DebugElement и специфичный для платформы элемент объекта. Поэтому нет смысла напрямую использовать nativeElement в тестах.

Компоненты

Компонент, в отличие от всех других частей приложения Angular, сочетает в себе шаблон HTML и класс TypeScript. Компонент действительно является шаблоном и классом, работающим вместе. и чтобы адекватно протестировать компонент, мы должны проверить, что они работают вместе, как предполагалось.

Такие тесты требуют создания хост-элемента компонента в DOM браузера, как это делает Angular, и изучения взаимодействия класса компонента с DOM, как описано в его шаблоне.

Тестирование компонентов включают методики описанные выше, я опишу тестирование маршрутных компонентов — это компоненты, которые указывают глобальному объекту роутинга перейти по адресу.

Для тестирования роутинга необходимо либо создать spy.

const routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: routerSpy }
]
})

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

TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([]),
]
});
router = TestBed.get(Router);

И далее используем роутер через spy.

it('should cancel to prev route', () => {
const spy = spyOn(router, 'navigate');
component.onCancel();
expect(spy).toHaveBeenCalledWith(['../']);
});

Что дальше

После внедрения документации в проектах. Программисты фронтенд теперь при разработке пишут сразу документируемый код, это сделано за счет инструментов проверки документирования кода. Также на этапе ревью кода указывается, что необходимо задокументировать функционал.

Такое же поведение должно быть и для тестов. Необходимо при планировании работ закладывать юнит тесты. И начинать писать функционал с юнит тестов.

Предполагается, что в дальнейшем следующие проекты будут писаться по методологии TDD, модульные тесты пишутся к каждому компоненту. Если система предполагает интеграционные и функциональные тесты, то они будут реализованы в дальнейшем.

--

--