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

Только после внедрения тестов в full-stack-приложение мне удалось оценить, какую большую пользу приносит тестирование и как здорово оно помогает писать код.

Коротко о стандартном наборе инструментов тестирования

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

В одном из последних проектов мы с командой выбрали Jest благодаря простоте использования и широкому спектру встроенных функций, которые существенно упрощают тестирование. Ко всему прочему, она очень просто настраивается и по умолчанию использует библиотеку Expect для валидации данных.

Enzyme фактически уже стал стандартом тестирования приложений на React. Волшебство его заключается в том, что он рендерит компоненты React внутри Jest, что позволяет эффективно тестировать JSX-код, не прибегая к ручной конвертации кода в чистый JavaScript (transpiling). Ренднерить свои компоненты можно, используя один из трех методов: shallow, mount или render.

SuperTest позволяет имитировать обращение к API без фактической установки соединения с помощью перехвата вызовов. Обращение напрямую к API в модульных тестах может быть настолько ресурсоемким и/или медленным, что вам не захочется отправлять запросы и тратить время на ожидание ответа. В противном случае это заступорит весь процесс тестирования и будет длиться вечность.

К каким наблюдениям я пришла

Тестирование предотвращает ошибки

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

Как это ни странно, довольно часто встречается такая глупая ситуация — не все программисты могут объяснить код, который они пишут. Бывало ли у вас когда-нибудь, что на просьбу рассказать, как работает код, который вы написали на коленке под гнетом дедлайна, вы начинали заикаться и не знали, что ответить? Написание тестов заставляет вас задумываться о том, какие именно данные ваша функция принимает в качестве аргумента и что именно возвращается в качестве результата.

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

Модульное тестирование (unit test) с помощью mocks и stubs

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

Чтобы эффективно изолировать части кода для дебаггинга, вы можете заменить эти зависимости макетом (mock) или заглушкой (stub). Это поможет контролировать поведение или результат, которые вы ожидаете.

К примеру представим, что вы хотите протестировать следующий метод:

import database from 'Database';
import cache from 'Cache';

const getData = (request) => {
 if (cache.check(request.id)) { // check if data exists in cache
  return cache.get(request.id); // get from cache
 }
 return database.get(request.id); // get from database
};

Заглушка (stub):

test('should get from cache on cache miss', (done) => {
 const request = { id: 10 };
 cache.check = jest.fn(() => false);

getData(request);
 expect(database.get).toHaveBeenCalledWith(request.id);
 done();
});

Макет (mock):

test('should check cache and return database data if cache data is not there', (done) => {
 let request = { id: 10 };
 let dummyData = { id: 10, name: 'Foo' }
 
 let cache = jest.mock('Cache');
 let database = jest.mock('Database');
 cache.check = jest.fn(() => false);
 database.get = jest.fn(() => dummyData);

expect(getData(request)).toBe(dummyData);
 expect(cache.check).toHaveBeenCalledWith(request.id);
 expect(database.get).toHaveBeenCalledWith(request.id);
 done();
});

Основное различие между ними лежит в цели проверки — в одном случае тестируется состояние функции, в другом —её поведение.

При использовании макетов (mocks) вы заменяете весь модуль объектом макета. Заглушка (stob) же представляет собой принудительный вызов функции независимо от заданных параметров ввода. Макеты используются для проверки того, насколько правильные аргументы были переданы функции при вызове, а заглушки используются для проверки того, как функция работает с данным ответом. Заглушки также применяются для проверки состояния метода, а к макетам прибегают в случаях, когда нужно оценить поведение метода.

У Jest есть метод jest.fn, который предоставляет базовые функции как для применения шаблонов, так и для применения заглушек. Шаблон в Jest может также применяться для имитирования результатов работы метода, и в этом случае быть и макетом, и заглушкой.

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

Знайте то, что вам тестить НЕ нужно

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

С помощью Jest вы можете легко отслеживать тестовое покрытие, добавив тег --coverageв тестовый скрипт в свой CLI. Хотя это и полезный совет, всегда проверяйте целесообразность его применения— способ, которым Jest измеряет охват теста, заключается в отслеживании стека вызовов, поэтому более высокое покрытие теста не обязательно означает, что ваши тесты эффективны.

Например, в предыдущем проекте я использовала библиотеку для реализации компонента “Карусель” (carousel) . Внутри компонента была функция для отображения списка на основе массива. Чтобы увеличить тестовое покрытие, я написала тест, который подсчитывал и сравнивал количество отображаемых элементов с количеством элементов в массиве. Компонент карусели менял количество генерируемых в DOM элементов, и их соотношение становилось больше, чем 1: 1, хотя визуально в браузере отображалось правильное количество элементов. Я решила отказаться от тестового покрытия этого компонента, потому что тест фактически тестировал библиотеку вместо моего кода.

Предположим, что компонент Listingsимеет метод renderCarousel, который отрисовывает (render) карусель из внешней библиотеки:

Неэффективный тест

test('should return the same number of elements as the array', (done) => {
    // Full DOM render
    let mountWrapper = mount(<Listings />);

    // State change to trigger re-render
    mountWrapper.instance().setState({ listings: [listing, listing, listing] });

    // Updates the wrapper based on new state
    mountWrapper.update();

    expect(mountWrapper.find('li').length).toBe(3);
    done();
  })

Эффективный тест:

test('should call renderCarousel method when state is updated', (done) => {
    // Mock function inside component to track calls
    wrapper.instance().renderCarousel = jest.fn();

    // State change to trigger re-render
    wrapper.instance().setState({ listings: [listing, listing, listing]

    expect(wrapper.instance().renderCarousel).toHaveBeenCalled();
    done();
  });

Разница между ними заключается в том, что тесты на самом деле проверяют.

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

В этом случае нам достаточно проверить только то, что библиотека в принципе подключается, а в остальном довериться её разработчикам, которые, будем надеяться, её протестировали.

Понимание того, что нужно тестировать, а без чего можно и обойтись, позволяет вам не тратить время на лишние бесполезные действия.

Хорошо разработанные тесты приводят к хорошо продуманному коду

На этапе создания кода знание того, что вам придется писать тесты, повышает качество кода. Этот подход к программированию известен как test-driven development (TDT) — разработка через тестирование. Он поддерживается широким кругом профессиональных разработчиков.

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

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

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

Слишком много логики:

// Define endpoint
app.get('/project', (request, response) => {
  let { id } = request.params;
  let query = `SELECT * FROM database WHERE id = ${id}`;
  
  database.query(query)
    .then((result) => {
      const results = [];
      for (let index = 0; index < data.length; index++) {
        let result = {};
        result.newKey = data[index].oldKey;
        results.push(result);
      }
      response.send(results);
    })
    .catch((error) => {
      response.send(error);
    })
  })

Проще тестировать:

// Make call to database for data
const getData = (request) => {
  return new Promise((resolve, reject) => {
    let { id } = request.params;
    let query = `SELECT * FROM database WHERE id = ${id}`;
    database.query(query)
      .then(results => resolve(results))
      .catch(error => reject(error));
    };
}

// Format data to send back
const formatData = (data) => {
const results = [];
for (let index = 0; index < data.length; index++) {
let result = {};
result.newKey = data[index].oldKey;
results.push(result);
}
return results;
}

// Send back data
const handleRequest = (request, response) => {
getData(request)
.then((result) => {
let formattedResults = formatData(result)
response.send(formattedResults);
.catch((error) => {
response.send(error);
}

// Define endpoint
app.get('/project', handleRequest);

Хотя второй пример длиннее, его гораздо проще читать. Логика здесь явно абстрагирована и изолирована, что значительно облегчает тестирование.

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

Пишите тесты по мере того, как вы пишете код

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

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

В системе с хорошо проработанными API вы можете протестировать каждый класс, прежде чем двигаться дальше, чтобы выяснить, какая часть логики нарушена. Например, в моей программе метод «get» вызывает getData для взаимодействия с базой данных. Сначала я бы протестировала getData и удостоверилась, что тесты успешно пройдены и отмечены зеленым. Таким образом, я буду знать, что если упадет какой-либо из тестов над контроллерами, то это возможно, будет связано с тем, как я вызываю getData.

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

Перевод статьи Evelyn Chan “Why I now appreciate testing, and why you should, too