Типы Doubles в Unit tests

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Меня зовут Владислав Василенко, я сотрудничаю с компанией Dev.Pro в роли Software Engineer. В своей профессиональной деятельности, во время разработки новых или улучшения старых фич, я всегда покрываю эти изменения Unit tests. В 2021 году это устоявшийся принцип SDLC, который давно принят и приветствуется сообществом программистов.

В этой статье я хочу помочь разобраться с необходимостью юнит-тестирования, типами Doubles в Unit tests и поделиться полезными ресурсами для всех желающих углубиться в эту тему.

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

Unit testing. Что это и зачем?

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

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

Если этого недостаточно в качестве причин для написания юнит-тестов, то на картинке снизу я добавил еще несколько.

Итак, разобравшись с юнит-тестированием, перейдем к типам Doubles в Unit tests.

Test double. Что это и зачем?

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

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

Изолированное тестирование отдельных фрагментов кода и избегание тяжеловесных запросов в БД, потока HTTP-запросов или взаимодействие с файловой системой, за счет грамотного использования test doubles, позволяет упростить запуск юнит-тестов и значительно ускорить их проверку любым раннером тестов.

Таким образом, можно выделить ряд причин, которые являются маркером для использования test doubles:

  1. Низкая скорость работы с внешним объектом (БД, HTTP-запрос и т.д.).
  2. Необходимость запуска тестов, независимо от окружения и возможностей компьютера разработчика.
  3. Необходимость работать с реальными и/или чувствительными к изменениям данными.
  4. Сложность проверки корректности взаимодействия между частями.

Говоря о типах test doubles, Gerard Meszaros выделяет следующие 5, представленные ниже на картинке. Предлагаю рассмотреть каждый из них более детально.

Dummy Object

Чаще всего методы класса или функции нуждаются в каких-то параметрах, но не всегда эти параметры могут быть важны для теста. В таких ситуациях следует использовать Dummy object.

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

Такой объект нужен просто для того, чтобы тест прошел. Очень часто это просто NULL или пустой объект. Dummy object не является как таковым test double, поэтому на картинке выше он и имеет пунктирную границу, но рассматривая тему «Doubles в Unit tests» его нельзя не упомянуть.

class TestClass {
 public logger(message: string): void {
   console.log(message);
 }
 
 public callAnotherFN(value: string): string {
   this.logger(value);
   return value;
 }
}
 
const instance = new TestClass();
 
it('should call function with Dummy Object', () => {
 jest.spyOn(instance, 'logger');
 const result = instance.callAnotherFN(null);
 expect(instance.logger).toHaveBeenCalled();
 expect(result).toBe(null);
});

В данном юнит-тесте нам важно проверить работу функции и то, что она вызывает внутри себя другую. Нам не важен результат ее выполнения, поэтому в качестве Dummy object здесь используется null.

Test Stub

В переводе с английского stub означает «заглушка». Такой перевод довольно ярко отображает принцип работы Test stub, ведь это объект, содержащий предопределенные данные, которые он использует для ответа на вызовы во время тестов.

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

Вместо реального объекта мы используем Test stub и определяем для него данные, которые нужно возвращать.

class TestClass {
 public summ(arg1: number, arg2: number): number {
   return arg1 + arg2;
 }
 
 public callAnotherFN(arg1: number, arg2: number): number {
   const result = this.summ(arg1, arg2);
   return result;
 }
}
 
const instance = new TestClass();
 
it('should return stubbed value', () => {
 instance.summ = jest.fn().mockReturnValue(100);
 const result = instance.callAnotherFN(1, 2); // 3
 expect(result).toBe(100);
});

В данном примере мы тестируем метод callAnotherFN(). Нам важно убедиться, что он корректно отрабатывает, то есть возвращает значение вызова другого метода. Поскольку в этом тесте нас не интересует корректность работы метода summ(), мы используем Test stub.

Test Spy

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

Так что во многих отношениях Test spy — это просто Test stub с возможностью записи. Хотя он используется для той же фундаментальной цели, что и Mock object, стиль теста, который мы пишем с помощью Test spy, больше похож на тест, написанный с помощью Test stub.

class TestClass {
 public summ(arg1: number, arg2: number): number {
   return arg1 + arg2;
 }
 
 public callAnotherFN(arg1: number, arg2: number): number {
   const result = this.summ(arg1, arg2);
   return result;
 }
}
 
const instance = new TestClass();
 
it('should call spied function', () => {
 jest.spyOn(instance, 'summ');
 instance.callAnotherFN(1, 2);
 expect(instance.summ).toHaveBeenCalled();
});

В этом юнит-тесте мы проверяем, что метод callAnotherFN() вызывает метод summ(). Нам важен не результат выполнения, а лишь факт вызова.

Mock Object

Mock object и Test spy очень похожи между собой. Однако первый не сохраняет цепочку вызовов, зато самостоятельно может проверить корректность поведения объекта.

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

interface Letter {
 email: string;
 message: string;
}
 
const letters: Letter[] = [
 { email: 'test1@gmail.com', message: 'Hello!' },
 { email: 'test2@gmail.com', message: 'Hi!' }
];
 
// Внешний сервис
class EmailService {
 public static sendEmail(email: string, message: string): void {
   // Реализация функционала
 }
}
 
class TestClass {
 public sendEmail(letter: Letter): void {
   EmailService.sendEmail(letter.email, letter.message);
 }
 
 public forEach(items: any[], callback: Function): void {
   for (let index = 0; index < items.length; index++) {
     callback(items[index]);
   }
 }
}
 
const instance = new TestClass();
 
it('should use Mock Object and not call External service, but keep record of calls and parameters', () => {
 const mockCallback = jest.fn(letter => `Email to ${letter.email} was sent`);
 
 instance.forEach(letters, mockCallback);
 
 // Количество вызовов функции = 2
 expect(mockCallback.mock.calls.length).toBe(2);
 // Первый аргумент функции = 0
 expect(mockCallback.mock.calls[0][0]).toBe(letters[0]);
 // Последний аргумент функции = 5
 expect(mockCallback.mock.calls[1][0]).toBe(letters[1]);
 // Первый результат = 'Email to test1@gmail.com was sent'
 expect(mockCallback.mock.results[0].value).toBe('Email to test1@gmail.com was sent');
 // Последний результат = 'Email to test2@gmail.com was sent'
 expect(mockCallback.mock.results[1].value).toBe('Email to test2@gmail.com was sent');
});

Нам не нужна фактическая отправка писем на каждый запуск теста, поэтому мы используем Mock Object. Он позволяет нам убедиться, что сервис по отправке писем вызвался необходимое количество раз, а также позволяет проверить параметры, которые использовались во время вызова функции.

Fake Object

Мы используем Fake Object, чтобы заменить функциональность реального компонента в тесте. Как правило, он реализует те же функции, что и настоящий компонент, но гораздо проще.

Наиболее частая причина использования Fake Object заключается в том, что реальный зависимый компонент еще недоступен, слишком медленный или не может использоваться в тестовой среде из-за вредных побочных эффектов.

Ярким примером использования Fake Objects являются подмены HTTP-запросов или запросов в БД.

it('returns an object containing all payments', done => {
 // В данном примере мы будем использовать Sinon.js
 const server = sinon.createFakeServer();
 server.respondWith('GET', '/payments', [
   200,
   { 'Content-Type': 'application/json' },
   `[
     { "id": 1, "amount": 1100, "recipient": "Vladyslav" }, 
     { "id": 2, "amount": 230, "name": "Maria" }
   ]`
 ]);
 
 Payments.all().done(collection => {
   const expectedCollection = [
     { id: 1, amount: 1100, recipient: 'Vladyslav' },
     { id: 2, amount: 230, name: 'Maria' }
   ];
 
   expect(collection.toJSON()).to.eql(expectedCollection);
 
   done();
 });
 
 server.respond();
 server.restore();
});

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

Выводы

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

На этом статья подходит к своему логическому завершению, а если у вас остались какие-то вопросы, комментарии или предложения, то с радостью готов обсудить!

👍НравитсяПонравилось3
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Перевели на русский?

Який унікальний доробок! Впевнений, що на хабрі-швабрі ніколи нічого подібного не було! (за 15+ років)

Теперь на Доу от Владика есть

Теперь на Доу от Владика есть

Треба ще від antonчика

Ви ще не вивчили мінімальну англійську для іт?
facepalm.webp

Плохой пример про Mock Object, в нём тест тестирует сам себя, а

class EmailService

вообще в данном примере не используется, а только усложняет понимание.
Пример с Fake Object я тоже не особо понял

Подписаться на комментарии