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

Автор: S0ER

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

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

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

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

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

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

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

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

// 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.

// 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 приватным и не делать для него отдельный тест.

Предпочитайте 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 пункт местами для повышения читаемости.

:pencil2: Пример кода
:thumbsup: Такой тест читается очень хорошо.
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: Отлично! Тесты сгруппированы по условиям и так их очень удобно читать.
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');
  });
});

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

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

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

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

:pencil2: Пример кода Посмотрите на эту функцию
  async compareUsersByPassword(passwordFromUser: string, passwordHashInDb: string) {
    const isPasswordsMatch = await bcrypt.compare(passwordFromUser, passwordHashInDb);

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

    return true;
  }

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

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

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

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-тест нам не нужен, так как сама функция не сложная и вряд ли в ней произойдет какая-либо регрессия. Поэтому достаточно, чтобы код, который использует данную функцию был покрыт интеграционными тестами.

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

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

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

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

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

:pencil2: Пример кода
:thumbsdown: Неправильно, не стоит ставить заглушку на UserService, потому что в этом случае мы исключаем его из теста.
//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, непосредственно работающий с БД.
//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();

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

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

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

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

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

:pencil2: Пример кода
:thumbsdown: Неправильно, не стоит воспроизводить структуру тестируемого модуля.
//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: Правильно. Тестируемый модуль импортируется в тестовый модуль.
//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();

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

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

Не создавайте тесты для проверки внешнего вида компонентов!!! Примеры плохих тестов:

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

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