15 апреля 2023 г.

Автоматическое тестирование c использованием фреймворка Mocha

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

Зачем нам нужны тесты?

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

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

Если функция работает не так, как мы ожидаем, то можно внести исправления в код и запустить её ещё раз. Так можно повторять до тех пор, пока функция не станет работать так, как нам нужно.

Однако, такие «ручные перезапуски» – не лучшее решение.

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

Например, мы работаем над функцией f. Написали часть кода и решили протестировать. Выясняется, что f(1) работает правильно, в то время как f(2) – нет. Мы вносим в код исправления, и теперь f(2) работает правильно. Вроде бы, всё хорошо, не так ли? Однако, мы забыли заново протестировать f(1). Возможно, после внесения правок f(1) стала работать неправильно.

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

Автоматическое тестирование означает, что тесты пишутся отдельно, в дополнение к коду. Они по-разному запускают наши функции и сравнивают результат с ожидаемым.

Behavior Driven Development (BDD)

Давайте начнём с техники под названием Behavior Driven Development или, коротко, BDD.

BDD – это три в одном: и тесты, и документация, и примеры использования.

Чтобы понять BDD – рассмотрим практический пример разработки.

Разработка функции возведения в степень — «pow»: спецификация

Допустим, мы хотим написать функцию pow(x, n), которая возводит x в целочисленную степень n. Мы предполагаем, что n≥0.

Эта задача взята в качестве примера. В JavaScript есть оператор **, который служит для возведения в степень. Мы сосредоточимся на процессе разработки, который также можно применять и для более сложных задач.

Перед тем, как начать писать код функции pow, мы можем представить себе, что она должна делать, и описать её.

Такое описание называется спецификацией (specification). Она содержит описания различных способов использования и тесты для них, например:

describe("pow", function() {

  it("возводит в степень n", function() {
    assert.equal(pow(2, 3), 8);
  });

});

Спецификация состоит из трёх основных блоков:

describe("заголовок", function() { ... })

Какую функциональность мы описываем. В нашем случае мы описываем функцию pow. Используется для группировки рабочих лошадок – блоков it.

it("описание", function() { ... })

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

assert.equal(value1, value2)

Код внутри блока it, если функция работает верно, должен выполняться без ошибок.

Функции вида assert.* используются для проверки того, что функция pow работает так, как мы ожидаем. В этом примере мы используем одну из них – assert.equal, которая сравнивает переданные значения и выбрасывает ошибку, если они не равны друг другу. Существуют и другие типы сравнений и проверок, которые мы добавим позже.

Спецификация может быть запущена, и при этом будет выполнена проверка, указанная в блоке it, мы увидим это позднее.

Процесс разработки

Процесс разработки обычно выглядит следующим образом:

  1. Пишется начальная спецификация с тестами, проверяющими основную функциональность.
  2. Создаётся начальная реализация.
  3. Для запуска тестов мы используем фреймворк Mocha (подробнее о нём чуть позже). Пока функция не готова, будут ошибки. Вносим изменения до тех пор, пока всё не начнёт работать так, как нам нужно.
  4. Теперь у нас есть правильно работающая начальная реализация и тесты.
  5. Мы добавляем новые способы использования в спецификацию, возможно, ещё не реализованные в тестируемом коде. Тесты начинают «падать» (выдавать ошибки).
  6. Возвращаемся на шаг 3, дописываем реализацию до тех пор, пока тесты не начнут завершаться без ошибок.
  7. Повторяем шаги 3-6, пока требуемая функциональность не будет готова.

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

Давайте посмотрим этот поток разработки на нашем примере.

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

Спецификация в действии

В этой главе мы будем пользоваться следующими JavaScript-библиотеками для тестов:

  • Mocha – основной фреймворк. Он предоставляет общие функции тестирования, такие как describe и it, а также функцию запуска тестов.
  • Chai – библиотека, предоставляющая множество функций проверки утверждений. Пока мы будем использовать только assert.equal.
  • Sinon – библиотека, позволяющая наблюдать за функциями, эмулировать встроенные функции и многое другое. Нам она пригодится позднее.

Эти библиотеки подходят как для тестирования внутри браузера, так и на стороне сервера. Мы рассмотрим вариант с браузером.

Полная HTML-страница с этими библиотеками и спецификацией функции pow:

<!DOCTYPE html>
<html>
<head>
  <!-- добавим стили mocha для отображения результатов -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.css">
  <!-- добавляем сам фреймворк mocha -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.js"></script>
  <script>
    // включаем режим тестирования в стиле BDD
    mocha.setup('bdd');
  </script>
  <!-- добавим chai -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/chai/3.5.0/chai.js"></script>
  <script>
    // chai предоставляет большое количество функций. Объявим assert глобально
    let assert = chai.assert;
  </script>
</head>

<body>

  <script>
    function pow(x, n) {
      /* Здесь будет реализация функции, пока пусто */
    }
  </script>

  <!-- скрипт со спецификацией (describe, it...) -->
  <script src="test.js"></script>

  <!-- элемент с id="mocha" будет содержать результаты тестов -->
  <div id="mocha"></div>

  <!-- запускаем тесты! -->
  <script>
    mocha.run();
  </script>
</body>

</html>

Условно страницу можно разделить на пять частей:

  1. Тег <head> содержит сторонние библиотеки и стили для тестов.
  2. Тег <script> содержит тестируемую функцию, в нашем случае – pow.
  3. Тесты – в нашем случае внешний скрипт test.js, который содержит спецификацию describe("pow", ...), представленную выше.
  4. HTML-элемент <div id="mocha"> будет использован фреймворком Mocha для вывода результатов тестирования.
  5. Запуск тестов производится командой mocha.run().

Результаты:

Пока что тест завершается ошибкой. Это логично, потому что у нас пустая функция pow, так что pow(2,3) возвращает undefined вместо 8.

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

Начальная реализация

Давайте напишем простую реализацию функции pow, чтобы пройти тесты.

function pow(x, n) {
  return 8; // :) сжульничаем!
}

Вау, теперь всё работает!

Улучшаем спецификацию

Конечно, мы сжульничали. Функция не работает. Попытка посчитать pow(3, 3) даст некорректный результат, однако тесты проходят.

…Такая ситуация вполне типична, она случается на практике. Тесты проходят, но функция работает неправильно. Наша спецификация не идеальна. Нужно дополнить её тестами.

Давайте добавим ещё один тест, чтобы посмотреть, что pow(3, 3) = 27.

У нас есть два пути организации тестов:

  1. Первый – добавить ещё один assert в существующий it:

    describe("pow", function() {
    
      it("возводит число в степень n", function() {
        assert.equal(pow(2, 3), 8);
        assert.equal(pow(3, 3), 27);
      });
    
    });
  2. Второй – написать два теста:

    describe("pow", function() {
    
      it("2 в степени 3 будет 8", function() {
        assert.equal(pow(2, 3), 8);
      });
    
      it("3 в степени 3 будет 27", function() {
        assert.equal(pow(3, 3), 27);
      });
    
    });

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

Разделять тесты предпочтительнее, так как мы получаем больше информации о том, что конкретно пошло не так.

Помимо этого есть одно хорошее правило, которому стоит следовать.

Один тест проверяет одну вещь.

Если вы посмотрите на тест и увидите в нём две независимые проверки, то такой тест лучше разделить на два более простых.

Давайте продолжим со вторым вариантом.

Результаты:

Как мы и ожидали, второй тест провалился. Естественно, наша функция всегда возвращает 8, в то время как assert ожидает 27.

Улучшаем реализацию

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

function pow(x, n) {
  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

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

describe("pow", function() {

  function makeTest(x) {
    let expected = x * x * x;
    it(`${x} в степени 3 будет ${expected}`, function() {
      assert.equal(pow(x, 3), expected);
    });
  }

  for (let x = 1; x <= 5; x++) {
    makeTest(x);
  }

});

Результат:

Вложенные блоки describe

Мы собираемся добавить больше тестов. Однако, перед этим стоит сгруппировать вспомогательную функцию makeTest и цикл for. Нам не нужна функция makeTest в других тестах, она нужна только в цикле for. Её предназначение – проверить, что pow правильно возводит число в заданную степень.

Группировка производится вложенными блоками describe:

describe("pow", function() {

  describe("возводит x в степень 3", function() {

    function makeTest(x) {
      let expected = x * x * x;
      it(`${x} в степени 3 будет ${expected}`, function() {
        assert.equal(pow(x, 3), expected);
      });
    }

    for (let x = 1; x <= 5; x++) {
      makeTest(x);
    }

  });

  // ... другие тесты. Можно писать и describe, и it блоки.
});

Вложенные describe образуют новую подгруппу тестов. В результатах мы можем видеть дополнительные отступы в названиях.

В будущем мы можем написать новые it и describe блоки на верхнем уровне со своими собственными вспомогательными функциями. Им не будет доступна функция makeTest из примера выше.

before/after и beforeEach/afterEach

Мы можем задать before/after функции, которые будут выполняться до/после тестов, а также функции beforeEach/afterEach, выполняемые до/после каждого it.

Например:

describe("тест", function() {

  before(() => alert("Тестирование началось – перед тестами"));
  after(() => alert("Тестирование закончилось – после всех тестов"));

  beforeEach(() => alert("Перед тестом – начинаем выполнять тест"));
  afterEach(() => alert("После теста – заканчиваем выполнение теста"));

  it('тест 1', () => alert(1));
  it('тест 2', () => alert(2));

});

Порядок выполнения будет таким:

Тестирование началось – перед тестами (before)
Перед тестом – начинаем выполнять тест (beforeEach)
1
После теста – заканчиваем выполнение теста (afterEach)
Перед тестом – начинаем выполнять тест (beforeEach)
2
После теста – заканчиваем выполнение теста (afterEach)
Тестирование закончилось – после всех тестов (after)
Открыть пример в песочнице.

Обычно beforeEach/afterEach и before/after используются для инициализации, обнуления счётчиков или чего-нибудь ещё между тестами (или группами тестов).

Расширение спецификации

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

Как было сказано, функция pow(x, n) предназначена для работы с целыми положительными значениями n.

Для обозначения математических ошибок функции JavaScript обычно возвращают NaN. Давайте делать также для некорректных значений n.

Сначала давайте опишем это поведение в спецификации.

describe("pow", function() {

  // ...

  it("для отрицательных n возвращает NaN", function() {
    assert.isNaN(pow(2, -1));
  });

  it("для дробных n возвращает NaN", function() {
    assert.isNaN(pow(2, 1.5));
  });

});

Результаты с новыми тестами:

Новые тесты падают, потому что наша реализация не поддерживает их. Так работает BDD. Сначала мы добавляем тесты, которые падают, а уже потом пишем под них реализацию.

Другие функции сравнения

Обратите внимание на assert.isNaN. Это проверка того, что переданное значение равно NaN.

Библиотека Chai содержит множество других подобных функций, например:

  • assert.equal(value1, value2) – проверяет равенство value1 == value2.
  • assert.strictEqual(value1, value2) – проверяет строгое равенство value1 === value2.
  • assert.notEqual, assert.notStrictEqual – проверяет неравенство и строгое неравенство соответственно.
  • assert.isTrue(value) – проверяет, что value === true
  • assert.isFalse(value) – проверяет, что value === false
  • …с полным списком можно ознакомиться в документации

Итак, нам нужно добавить пару строчек в функцию pow:

function pow(x, n) {
  if (n < 0) return NaN;
  if (Math.round(n) != n) return NaN;

  let result = 1;

  for (let i = 0; i < n; i++) {
    result *= x;
  }

  return result;
}

Теперь работает, все тесты проходят:

Открыть готовый пример в песочнице.

Итого

В BDD сначала пишут спецификацию, а потом реализацию. В конце у нас есть и то, и другое.

Спецификацию можно использовать тремя способами:

  1. Как Тесты – они гарантируют, что функция работает правильно.
  2. Как Документацию – заголовки блоков describe и it описывают поведение функции.
  3. Как Примеры – тесты, по сути, являются готовыми примерами использования функции.

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

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

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

  1. Внести изменения, и неважно, что будет. Потом у наших пользователей станут проявляться ошибки, ведь мы наверняка что-то забудем проверить вручную.
  2. Или же, если наказание за ошибки в коде серьёзное, то люди просто побоятся вносить изменения в такие функции. Код будет стареть, «зарастать паутиной», и никто не захочет в него лезть. Это нехорошо для разработки.

Автоматическое тестирование кода позволяет избежать этих проблем!

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

Кроме того, код, хорошо покрытый тестами, как правило, имеет лучшую архитектуру.

Это естественно, ведь такой код легче менять и улучшать. Но не только по этой причине.

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

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

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

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

Задачи

важность: 5

Что не так в нижеприведённом тесте функции pow?

it("Возводит x в степень n", function() {
  let x = 5;

  let result = x;
  assert.equal(pow(x, 1), result);

  result *= x;
  assert.equal(pow(x, 2), result);

  result *= x;
  assert.equal(pow(x, 3), result);
});

P.S. Тест не содержит синтаксических ошибок и успешно проходит.

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

У нас тут, по сути, три теста, но они написаны как одна функция с тремя проверками.

Иногда так проще писать, но если произойдёт ошибка, то гораздо сложнее понять, что пошло не так.

Если ошибка происходит посередине сложного потока выполнения, то нам придётся выяснять, какие данные были в этом месте. По сути, придётся отлаживать тест.

Гораздо лучше разбить тест на несколько блоков it и ясно описать входные и ожидаемые на выходе данные.

Примерно так:

describe("Возводит x в степень n", function() {
  it("5 в степени 1 будет 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  it("5 в степени 2 будет 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 в степени 3 будет 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});

Мы заменили один it на describe и группу блоков it. Теперь, если какой-либо из блоков завершится неудачно, мы точно увидим, с какими данными это произошло.

Также мы можем изолировать один тест и запускать только его, написав it.only вместо it:

describe("Возводит x в степень n", function() {
  it("5 в степени 1 будет 5", function() {
    assert.equal(pow(5, 1), 5);
  });

  // Mocha будет запускать только этот блок
  it.only("5 в степени 2 будет 25", function() {
    assert.equal(pow(5, 2), 25);
  });

  it("5 в степени 3 будет 125", function() {
    assert.equal(pow(5, 3), 125);
  });
});
Карта учебника