Чистые функции проще всего покрыть модульными тестами.

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

Что такое модульные тесты

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

В реальных проектах JavaScript люди обычно используют одну из этих платформ с открытым исходным кодом для тестирования:

  • Жасмин
  • Шутка
  • Мокко

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

Что такое чистые функции?

Чистые функции — это функции, результаты которых зависят только от предоставленных аргументов. Они не хранят внутреннее состояние и не считывают внешние значения, кроме аргументов. Они такие же, как функции в математическом смысле. Например:

  • sin(x),
  • cos(x),
  • f(x) = 4 * x + 5.

Операция с данными

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

function greet(name, surname) {
  return `Hello ${name} ${surname}!`;
}

Это чистая функция: каждый раз, когда я запускаю greet('Marcin', 'Wosinek'), она возвращает «Hello Marcin Wosinek!».

Попробуй это!

Как я могу протестировать эту функцию?

expect(greet('Lorem', 'Ipsum')).toEqual('Hello Lorem Ipsum!');

Фреймворки для тестирования превращают приведенный выше код во что-то вроде:

if(greet(‘Lorem’, ‘Ipsum’) !== ‘Hello Lorem Ipsum!’) {
  throw new Error(“greet(‘Lorem’, ‘Ipsum’) doesn’t equal ‘Hello Lorem Ipsum!’”)
}

И показывает вам отчет о результатах всех проверок, которые они проводят.

Пограничные случаи

Написание модульных тестов заставляет задуматься о крайних случаях. Например, что должно произойти, если наша функция вызывается только с одним параметром? Приветствие человека по имени звучит как самая разумная вещь, но текущая реализация делает другое:

greet(‘Marcin’); // returns “Hello Marcin undefined!”

Если мы хотим выполнять вызовы поддержки только по имени, мы можем добавить следующий тестовый пример:

expect(greet(‘Lorem’)).toEqual(‘Hello Lorem!’);

Это потребует доработок в нашей реализации:

function greet(name, surname) {
  if (surname) {
    return `Hello ${name} ${surname}!`;
  } else {
    return `Hello ${name}!`;
  }
}

Точно так же мы могли бы продолжить добавлять другие пограничные случаи. Например, что должно получиться:

  • когда мы приветствуем кого-то только по фамилии
  • без имени и фамилии
  • при вызове метода с тремя параметрами - с отчеством или второй фамилией

Думая об этих случаях и добавляя для них тесты, мы создаем более устойчивый код.

Расчет скидки

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

function calculateDiscountedPrice(originalPrice, discount) {
  return originalPrice - originalPrice * discount;
};

Попробуй это!

Давайте рассмотрим несколько случаев, которые мы могли бы протестировать:

// case 1
expect(calculateDiscountedPrice(10, 1/4)).toBe(7.5);

// case 2
calculateDiscountedPrice(0.9, 2/3).toBe(0.3)

// case 3
expect(calculateDiscountedPrice(10, 1/3)).toBe(6.67);
expect(calculateDiscountedPrice(10, 2/3)).toBe(3.33);

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

Пограничные случаи

Что происходит в наших крайних случаях? В случай 2 мы сталкиваемся с ошибкой округления, вызванной тем, как числа хранятся в JavaScript. В JavaScript есть только числа с плавающей запятой, поэтому каждое круглое десятичное число представлено в памяти с аппроксимацией, которая вносит крошечную ошибку округления. Когда вы выполняете операции с числами, эти ошибки могут накапливаться, и в итоге вы получите результат, немного отличающийся от ожидаемого. В нашем случае:

calculateDiscountedPrice(0.9, 2/3)
0.30000000000000004

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

Тест случай 3 завершится ошибкой из-за отсутствия округления — вместо этого функция возвращает 6.666666666666667 и 3.333333333333334. В большинстве систем нас интересует только значение до второго десятичного знака — до цента.

Обе проблемы могут быть решены с помощью одной и той же настройки реализации:

function calculateDiscountedPrice(originalPrice, discount) {
  const newPrice = originalPrice - originalPrice * discount;
  return Math.round(newPrice * 100) / 100
};

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

Математические операции

Рассмотрим некоторые чисто математические операции:

function power(base, exponent) {
  return base ** exponent;
};

Есть ли что-нибудь интересное, что мы могли бы здесь протестировать?

Попробуй это!

// case 1
expect(power(2, 2)).toBe(4);
expect(power(2, 10)).toBe(1024);
expect(power(2, 0)).toBe(1);

// case 2
expect(power(0, 0)).toBe(NaN);

Случай 1 работает, как и ожидалось, тогда как случай 2 терпит неудачу: JS возвращает 1, что отличается от того, что мы узнали из математики.

Пограничные случаи

Что еще мы можем здесь протестировать? Мы могли бы расширить наше тестирование и охватить случаи с некорректными аргументами, такие как:

  • power(),
  • power(‘lorem’, ‘ipsum’),
  • power({}, 0).

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

  • возвращение NaN,
  • выдает ошибку или
  • по умолчанию какое-то разумное значение, например 1

И что бы вы ни решили, вы можете сделать это явным в своих модульных тестах.

Хотите знать больше?

Подробнее о модульных тестах я писал в своем блоге:

Краткое содержание

Чистые функции проще всего покрыть модульными тестами. Тем не менее, их написание настраивает вас на поиск пограничных случаев, которые в противном случае не были бы продуманы должным образом. Это ценное упражнение и навык для начинающих программистов: оно учит вас мыслить очень точно, как машина; а улучшение покрытия юнит-тестами — долгожданный вклад во многие проекты.

Первоначально опубликовано на https://how-to.dev.

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку. Подпишитесь на нас в Twitter, LinkedIn, и Раздор.