Тестирование
Основные моменты тестирования
Общие рекомендации
Разрабатывая какую-либо функциональность (модуль или компонент), мы оцениваем его корректность (соответствие предъявляемым требованиям) непосредственно во время разработки. В случае, если нас все удовлетворяет и ошибки не были обнаружены - мы создаем 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. Все описания тесты должны быть написаны в декларативном стиле на бизнес-языке. Описание теста должно состоять из трех частей:
- Описание того, что именно тестируется (например, UserService)
- Что ожидается в результате (should should return UnauthorizedException)
- При каких обстоятельствах (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.