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

Основные моменты тестирования

Общие рекомендации

Разрабатывая какую-либо функциональность (модуль или компонент), мы оцениваем его корректность (соответствие предъявляемым требованиям) непосредственно во время разработки. В случае, если нас все удовлетворяет и ошибки не были обнаружены - мы создаем pull request и наш код сливается с существующим кодом проекта.
Пока в проверенный участок кода не вносятся какие-либо изменения, сомневаться в его корректности не приходится. Однако, если в нашем коде существуют зависимости (использование сторонних пакетов или методов других модулей программы), то ошибка может произойти в случае их изменения. При этом сам тестируемый код изменениям не подлежал. Ошибка, которая возникла в уже проверенном коде и не подлежащем изменениям коде, называется регрессией.
Именно для поиска регрессий мы используем автоматическое тестирование в проекте Naris.

Не делайте хрупкие тесты

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

Не дублируйте тесты

Если вы покрыли тестом какой-либо метод, то старайтесь не дублировать точно такие же проверки в модулях, которые используют Ваш метод в качестве зависимостей. Вместо этого лучше поставьте на данный метод заглушку. Да, тогда тест может стать больше похожим на white-box тестирование, но в данном случае - это необходимое зло во благо.
В противном случае Вы лишь увеличите себе объем необходимой работы при написании и поддержании тестов.

Допустим, существует AuthService с вот такой реализацией. Для него необходимо написать unit-тест.

`js // auth.service.ts export class AuthService { async signIn(userCredentials: UserCredentialsDto) { const userInDb = await this.userService.getByEmail(userCredentials.email); if(userInDb == null) { return new Error('User not found') } const isPasswordMatch = await this.comparePassword(userCredentials.password, userInDb.passwordHash); if(isPasswordMatch instanceof Error) return isPasswordMatch;

return userInDb;

}

async comparePassword(passwordFromUser: string, passwordHashInDb: string){ const isPasswordsMatch = await bcrypt.compare(passwordFromUser, passwordHashInDb);

if (!isPasswordsMatch) {
  return new UnauthorizedException('Invalid password');
}

return true;

} } `

Плохо. Обратите внимание, мы дублируем тестирование метода comparePassword.

`js // auth.service.spec.ts describe('AuthService', () => { describe('signIn', () => { it.todo('should return error if user with such email not exists') it.todo("should return error if passed password don't match hash") it.todo('should return exist user') })

describe('comparePassword', () => { it.todo("should return error if passed password don't match hash") it.todo('should return true if passed password match hash') }) }) `

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

</details>

Предпочитайте black-box тестирование

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

Формат тестов

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

  1. Описание того, что именно тестируется (например, UserService)
  2. Что ожидается в результате (should should return UnauthorizedException)
  3. При каких обстоятельствах (when passed password don't match exist hash)

Если при определенном условии (when), может быть несколько ожидаемых утверждений (should), то лучше поменять 2 и 3 пункт местами для повышения читаемости.

<details> <summary> :pencil2: Пример кода</summary>

:thumbsup: Такой тест читается очень хорошо.

`js describe('Auth e2e-test', () => { describe('POST /auth/signin', () => { it.todo('should signin user when pass valid credentials'); it.todo('should return error when pass invalid credentials'); });

describe('POST /auth/signup', () => { it.todo('should create user when pass valid data'); it.todo('should return error when pass invalid data '); }); }); `

:clap: Отлично! Тесты сгруппированы по условиям и так их очень удобно читать.

js describe('EditAbstracteFormComponent', () => { describe('when form is in preview state', () => { it('should prewiewFlag assert true'); it('should not display soer-editor'); it('should contain markdown blocks with workbook blocks text'); }); describe('when form is in edited state', () => { it('should display soer-editor'); it('should not display markdown blocks'); }); });

</details>

Правила написания Unit-тесты

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

Не создавайте unit-тесты для методов-оберток

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

<details> <summary> :pencil2: Пример кода</summary> Посмотрите на эту функцию

`js async compareUsersByPassword(passwordFromUser: string, passwordHashInDb: string) { const isPasswordsMatch = await bcrypt.compare(passwordFromUser, passwordHashInDb);

if (!isPasswordsMatch) {
  return new UnauthorizedException('Invalid password');
}

return true;

} `

Для данной функции можно было бы написать примерно такую структуру теста:

js describe('compareUsersByPassword', () => { it.todo('should return true if passwords match'); it.todo("should return UnauthorizedException if passwords don't match"); });

Однако, в действительности данная функция не сравнивает пароли. Вместо нее это выполняет метод compare из библиотеки bcrypt, и сейчас наш тест охватывает код, не относящийся к тестируемой функции, т.е. производит повторное тестирование.
Можно, конечно, заменить данный тест на что-то подобное

`js jest.mock('bcrypt', () => ({ compare: jest.fn(), }));

describe('compareUsersByPassword', () => { it.todo('should return true if bcrypt.compare return true'); it.todo('should return UnauthorizedException if bcrypt.compare return false'); }); `

Но если задуматься, то даже такой unit-тест нам не нужен, так как сама функция не сложная и вряд ли в ней произойдет какая-либо регрессия. Поэтому достаточно, чтобы код, который использует данную функцию был покрыт интеграционными тестами. </details>

Ставь заглушки на все внешние зависимости тестируемого кода

При разработке unit-теста на ВСЕ внешние зависимости (как сторонние библиотеки, так и методы другого модуля) для тестируемого кода устанавливаются заглушки. Это необходимо для того, чтобы не повторять тестирование внешнего кода.
В этом случае тест зависит от реализации тестируемого кода и становится white-box, но в противном случае придется дублировать тесты. При этом приватные методы того же тестируемого класса, которые использует тестируемый метод, заглушать не нужно.

Правила написания интеграционных тестов

Делай заглушки на модули, взаимодействующие с внешним миром

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

<details> <summary> :pencil2: Пример кода</summary>

:thumbsdown: Неправильно, не стоит ставить заглушку на UserService, потому что в этом случае мы исключаем его из теста.

`js //auth.module.ts @Module({ providers: [AuthService, JwtStrategy, UserService], controllers: [AuthController], }) export class AuthModule {}

//auth.module.spec.ts const moduleRef = await Test.createTestingModule({ imports: [AuthModule], }) .useMocker((token) => { if (token == UserService) // Не нужно ставить заглушку на написанный Вами код return { getById: jest.fn(), }; }) .compile(); `

:thumbsup: Правильно. Заглушка ставиться на репозиторий TypeORM, непосредственно работающий с БД.

`js //auth.module.ts @Module({ providers: [AuthService, JwtStrategy, UserService], controllers: [AuthController], }) export class AuthModule {}

//auth.module.spec.ts const moduleRef = await Test.createTestingModule({ imports: [AuthModule], }) .useMocker((token) => { if (token == getRepositoryToken(UserEntity)) return { findOne: jest.fn(), }; }) .compile(); `

</details>

Особенности Backend тестирования

Правила именования интеграционных тестов

Данный вид тестов охватывает всю функциональность какого-то конкретного модуля, контроллера или endpoint'а и служит для проверки того, что данная функциональность по-прежнему работает. Расширение для файлов интеграционных тестов - .e2e.spec.ts, и размещать их необходимо рядом с тестируемыми модулями.

Не повторяй тестируемый модуль, а импортируй его

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

<details> <summary> :pencil2: Пример кода</summary>

:thumbsdown: Неправильно, не стоит воспроизводить структуру тестируемого модуля.

`js //auth.module.ts @Module({ providers: [AuthService, JwtStrategy, UserService], controllers: [AuthController], }) export class AuthModule {}

//auth.module.spec.ts const moduleRef = await Test.createTestingModule({ providers: [AuthService, JwtStrategy, UserService], controllers: [AuthController], }).compile(); `

:thumbsup: Правильно. Тестируемый модуль импортируется в тестовый модуль.

`js //auth.module.ts @Module({ providers: [AuthService, JwtStrategy, UserService], controllers: [AuthController], }) export class AuthModule {}

//auth.module.spec.ts const moduleRef = await Test.createTestingModule({ imports: [AuthModule], }).compile(); `

</details>

Особенности Frontend тестирования

Не тестируйте внешний вид компонентов

Не создавайте тесты для проверки внешнего вида компонентов!!! Примеры плохих тестов: - Кнопка невидима, если передан входной параметр isShow = false; - Переданный входной параметр отображается внутри кнопки - При переданом входном параметре isDisabled для кнопки устанавливается класс ".disabled" - и т.д.

Все приведенные примеры - это хрупкие тесты, которые проще тестировать визуально.
Для тестирования внешнего вида компонента и его различного визуального поведения в зависимости от входных параметров используйте Storybook.