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

А уверенность в изменениях зависит от тестового покрытия.

С тех пор как мы это поняли, автоматические тесты стали просто необходимы. Это привело к массовому внедрению шаблонов для старта работы.

Однако начать работу недостаточно.

Будьте осторожны у края платформы (изображение отсюда)
Будьте осторожны у края платформы (изображение отсюда)

Что плохого в шаблонах для старта работы?

Большинство ресурсов/инструментов можно довольно быстро начать использовать в работе. Это относится и к stackoverflow, и к codewhisperer, и к copilot, и к Bard, и к ChatGPT. Даже руководства по тестовым фреймворкам.

Почему?

  1. Они нацелены на наиболее низкий средний уровень пользователей, поэтому опускают сложные детали.

  2. Они откладывают на потом крутую часть кривой обучения. Вы встретитесь с ней только после того, как уже подсели на фреймворк.

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

  4. Многие оптимизации зависят от возможностей команды, культуры и конкретного кейса.

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

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

Отсутствующая реальность

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

«Мы все здесь сумасшедшие» / Чеширский кот. (изображение отсюда)
«Мы все здесь сумасшедшие» / Чеширский кот. (изображение отсюда)

Реальность, которую мы упускаем, такова:

Кодовая база создается один раз, но тестируется постоянно на протяжении всей своей жизни.

T.T.R. (Time to Recovery) — Время на восстановление 

Кодовые базы не могут не расти, не могут не завершаться ошибкой. 

Но сколько времени требуется для восстановления после теста с ошибкой?

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

  • Охраняет ли тестовый код действующую спецификацию, которую необходимо соблюдать?

- или -

  • Новая спецификация привела к устареванию старой?

Чем меньше вам нужно расшифровывать, тем лучше ваш TTR. Есть реальность еще лучше:

  • Что, если бы тесты могли точно сообщать, какие требования они защищают?

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

  • Что, если бы они могли сделать тоже самое с такой же ясностью даже 6 месяцев спустя?

Ну, вы могли бы пропустить эту самую угнетающую часть восстановления...

Культурный ключ

Передача знаний, 5-й элемент (изображение отсюда)
Передача знаний, 5-й элемент (изображение отсюда)

Наша индустрия находится в состоянии вечной неопытности. Посмотрите выступление дяди Боба Мартина об этом. Культуре не удается распространиться.

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

Хорошая культура поощряет непрерывное совершенствование.

Отношение к тесту, завершившемуся с ошибкой, как к потере времени — еще один элемент культуры.
Оптимизация TTR — это элемент культуры.

Но как этого добиться?

Ведущие факторы

Что общего между уведомлением о перебое в работе сервиса и уведомлением о неудавшейся сборке?

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

  • И то, и другое, скорее всего, заставит вас приступить к поиску неисправностей, чтобы определить контекст.

  • Любая минута, которую вы тратите на них, — это время, потраченное на тушение пожара вместо совершения прогресса.

Конечно, пламя не такого размера, но по сути — то же самое паршивое ощущение расточительства и рассогласованности.

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

Какие можно сделать выводы

Относительно говоря... (изображение отсюда)
Относительно говоря... (изображение отсюда)

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

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

  2. Не предполагайте, что разработчик знает. Добавьте дополнительный этап, чтобы описать сценарий использования и контекст.

  3. Постарайтесь избавиться от автоматической кодогенерации и инструментария.

  4. Сосредоточьте когнитивную нагрузку на значимых деталях тест-кейса.

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

Уровень (-5) — наивное начало

К сожалению, в роли консультанта я все еще сталкиваюсь с тест-сьютами в таком духе:

const myModule = ... //require or import the System-Under-Test

it("should work", async () => {
  await setup...;
  await step1(...);
  expect(…)... .
  await step2(...);
  expect(…)... .
  await step3(...);
  expect(…)... .

  // and a load more of those in the same function
});

Это тот минимум, который может предотвратить развертывание ошибочного кода.

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

И все же многие команды не требуют, чтобы их тестовый код был чем-то бОльшим.

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

Что не так с уровнем (-5)?

Несколько проблем.

  1. Он поддерживает принцип BDD «следовать английской формулировке API». Но он делает это таким образом, что не предоставляет никакой информации о тест-кейсе или сценарии.

  2. Когда какой-либо шаг терпит неудачу — весь сценарий ломается, и все последующие шаги не выполняются. Иногда я вижу try-catch с попыткой очистки. Это не намного лучше, потому что вам все равно придется вернуть ошибки, которые должен выдать тест. Это заставляет вас работать на тест-раннер вместо того, чтобы он работал на вас.

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

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

  5. Когда происходит сбой, вы понятия не имеете, где находится виновник. Проблема в тестовом коде? То есть тест не смог организовать, взаимодействовать или убрать за тестируемой системой (SUT, System-Under-Test)? Или это потому, что SUT не сработал, то есть произошло разрушающее изменение в продакшен коде? Иногда я вижу комментарии //arrange или //setup и //cleanup или //teardown. Но это комментарии, видимые в тестовом коде, где цель — избавить нас от чтения тестового кода.

Фейспалм. Довольно старый жест. (изображение отсюда)
Фейспалм. Довольно старый жест. (изображение отсюда)

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

Устранение всех «иногда», упомянутых выше, может перевести вас с уровня (-5) на уровень (-2), но все равно оставит далеко позади.

Подобная структура приведет к провалу на любом приличном собеседовании.

Уровень 0 — Использование заголовков

Следующий уровень соответствует такому духу.

describe('my-module', () => {
  // async api_one(...) 
  it('should do this when called with ...', async () => { ...
  it('should do that when called after ...', async () => { ...
  it('should throw that error when ...', async () => { ...

  // async api_two(...)
  it('should do this when called with ...', async () => { ...
  it('should do that when called after ...', async () => { ...
  it('should throw that error when ...', async () => { ...

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

Здесь мы находимся в гораздо лучшем положении, чем в предыдущем фрагменте:

  • Он организован.

  • Есть очевидная мысль о матрице примеров.

  • Это основа для изоляции тестов — сбой в одном тесте не помешает выполнению других тестов.

  • При завершении любого теста ошибкой в выводе теста появляется объяснение на английском языке с указанием ошибки.

Что еще не так?

Во-первых — начните с малого. Порядок текста изменен на противоположный.

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

А что, если в аналогичных условиях вы хотите проверить несколько требований? Должны ли вы повторять условия в каждом из их названий? Будете ли действовать асинхронно, проверяя каждый раз разные условия?

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

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

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

Третье — секции разделены комментариями, которые недоступны для spec-reporters.

Reporter — это часть, которую использует тест-раннер для выдачи результатов в тестовый вывод. Большинство reporters в конце выдают сводку об ошибках.

spec-reporter — это репортер, который выводит все дерево тест-кейсов, используя описания и заголовки, обычно до сводки об ошибках.

Он помечает каждый тест в дереве записями "pass/fail/skip". Это помогает прочитать историю, которую рассказывает дерево тестов, и место, которое в ней занимают ошибки.

Комментарии недоступны для составителей отчетов о тестировании, что возвращает вас к чтению кода тестов.

Все еще facepalm, уже современный (изображение отсюда).
Все еще facepalm, уже современный (изображение отсюда).

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

Spec-reporters хорошо работают в сочетании с отложенными тестами. Использование отложенных тестов — это добавление заголовков спецификаций без предоставления их обработчика. В результате они появляются в дереве как пропущенные (skipped).

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

Я всегда буду ностальгировать по чистому виду и ощущениям от Mocha.js.

Spec reporter — это стандартный reporter для Mocha, встроенный в tap и поддерживаемый встроенным раннером в Node. Он работает с Jest с помощью пакета плагинов.

Имейте в виду, что вам не нужно сначала писать тесты или работать с TDD/BDD, чтобы использовать spec-reporters. Запускайте spec-reporters, когда захотите посмотреть, о чем рассказывает ваше дерево тестов :)

И последнее — вы можете попросить тест-раннер сделать за вас следующее:

  1. Выполнить за вас настройку и очистку.

  2. Убедиться, что если тест провалился, очистка все равно произойдет.

  3. Завершить тест ошибкой, если его настройка или очистка не удалась.

  4. Уведомлять вас об ошибке, если тест завершился ошибкой при настройке или во время самого выполнения теста.

Исправив эти четыре проблемы, вы перейдете на уровень (4).

Уровень (4) — базовый профессиональный

describe('my-module', () => {
  context('when used in cased A…', () => {
    before(async () => { ... //case setup
    it('should fulfil requirement 1…', () => { ...
    it('should fulfil requirement 2…', () => { ...
    ...
    after(async () => { ...  //cleanup
  })
  context('when used in case B…', () => {
    before(async () => { ... //case setup
    it('should fulfil requirement 1…', () => { ...
    it('should fulfil requirement 2…', () => { ...
    ...
    after(async () => { ... //cleanup

Mocha BDD рекомендует использовать api context для описания контекста кейса. Фактически, это псевдоним для describe. Jest поддерживает только describe и позволяет вложить его, как mocha, так что использование describe создает единообразие между ними.

Этапы Arrange и Act выполняются на асинхронных хуках before — например, внести тестовые данные и выполнить HTTP-запрос. Затем все этапы Assert работают с полученным объектом ответа и происходят синхронно.

Итог

Чего мы достигли на данный момент?

  1. Тест-раннер обеспечивает выполнение кодов подготовки и очистки даже при неудачном сценарии. Никаких try-catch, никакого console.log. При каждой ошибке тест-раннер сообщит вам, какой именно обработчик не сработал. Он отметит, был ли это хук подготовки/очистки или сам тест.

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

  3. Каждый контекст — это отдельное замыкание со своими переменными. Вы можете использовать его для хранения состояния, которое имеет отношение к тест-кейсу.

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

Лично мне не нравится использование this в JavaScript, и я предпочитаю хранить состояние в замыканиях, но что поделаешь...

Итак, что дальше?

Есть еще много уровней, на которых можно набрать очки.

Например:

  1. Вы можете освоить мокинг с помощью шпионов (spies) и стабов (stubs). Но будьте осторожны, чтобы не заблудиться в области ответственности и не пропустить тестирование работы системы в целом.

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

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

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

Приглашаем всех тестировщиков на завтрашний открытый урок, на котром познакомимся с основами популярного фреймворков для написания тестов на JavaScript — Mocha и библиотеки утверждений Chai. Напишем пару Unit и API тестов. Записаться на урок можно на странице курса.

Комментарии (1)


  1. dyadyaSerezha
    13.12.2023 10:47

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

    2) только к середине статьи я понял, что речь идет о JavaScript. Надо упомянуть это в самом начале.

    3) не тест-сьют, а тест-свит (суит).