Этот пост (Принципы тестирования для разработчиков) изначально был опубликован на Sargalias.

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

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

Но, похоже, это тема, с которой борются многие разработчики.

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

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

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

Итак, приступим.

В качестве превью основными принципами тестирования являются:

  • В программном обеспечении мы тестируем конечный результат.
  • Тесты более высокого уровня (например, сквозные тесты) обеспечивают большую достоверность, чем тесты более низкого уровня.
  • Тесты более низкого уровня по-прежнему необходимы, потому что тесты более высокого уровня имеют ограничения.
  • Избегайте тестирования деталей реализации и насмешек (в частности, обезьяньих исправлений).
  • Будьте прагматичны. Иногда можно протестировать детали реализации, если вы знаете, что делаете.

Цели тестирования

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

  • Код, который работает по назначению/правильно.
  • Код, который легко изменить.

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

Кроме того:

  • В идеале тесты должны быть воспроизводимы с помощью одной команды.
  • Тест должен быть быстрым.

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

Основной принцип тестирования

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

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

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

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

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

Почему мы тестируем другим методом

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

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

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

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

Пример: изменение класса некоторого HTML

Давайте представим, что нам нужно добавить класс foo к некоторому HTML. Но вместо этого мы совершаем ошибку и вместо этого пытаемся удалить класс foo.

Вот код JavaScript:

function foo() {
  const myElement = document.getElementById('foo');
  myElement.classList.remove('foo');
}

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

const myElement = document.getElementById('foo');
jest.spyOn(myElement.classList, 'remove'); // spy on the myElement.classList.remove function
foo(); // run our code
expect(myElement.classList.remove).toHaveBeenCalledWith('foo'); // assert myElement.classList.remore was called properly

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

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

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

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

foo(); // run our code
const myElement = document.getElementById('foo'); // get the element
expect(myElement.classList.contains('foo')).toBe(true); // assert on the end result

Этот тест работает независимо от нашей реализации.

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

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

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

Тесты более высокого уровня обеспечивают большую уверенность

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

Это, наверное, сразу видно.

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

Если вы фронтенд-разработчик, собираетесь ли вы опубликовать новую функцию, не увидев, как она работает в браузере?

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

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

Модульные тесты — это хорошо, но я еще не встречал никого, кто бы что-то опубликовал, не проверив его сначала от начала до конца.

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

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

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

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

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

Важны связи и взаимодействия частей.

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

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

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

Тесты нижнего уровня

Тесты более низкого уровня по-прежнему необходимы.

Это связано с тем, что тесты более высокого уровня имеют несколько ограничений:

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

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

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

Вот почему они необходимы.

Пример: добавить в корзину

Например, рассмотрим некоторые функции «добавить в корзину».

У нас могут быть сквозные тесты для некоторых критических путей, таких как:

  • Добавьте один товар в корзину.
  • Удалить один товар из корзины.

Но что насчет:

  • Добавьте 2 товара в корзину.
  • Добавьте 1000 товаров в корзину.
  • Добавьте 0 товаров в корзину.
  • Добавьте -1 товаров в корзину.
  • Возможно, нам нужно протестировать еще несколько величин из-за нашей реализации (тестирование белого ящика). Например. возможно, наш код странно обрабатывает 5 продуктов, и нам нужно протестировать этот конкретный случай.
  • Добавьте товар в корзину, затем удалите его, а затем снова добавьте в корзину.
  • Добавьте товар в корзину без токена CSRF.
  • Добавьте товар в корзину, если вы не вошли в систему.
  • Сделайте 1000 запросов, чтобы быстро добавить 1 товар в корзину.
  • Добавьте 10 товаров в корзину, когда в наличии всего 5 товаров.
  • Добавьте несуществующий товар в корзину.
  • И так далее…

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

Но в противном случае интеграционные тесты и модульные тесты восполнили бы слабину.

У нас могут быть интеграционные тесты для всех вышеперечисленных сценариев.

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

Пример: структура данных стека

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

Вероятно, нам нужны тесты, которые охватывают множество возможных сценариев:

  • Выталкивать из пустого стека. Должна ли это вызывать ошибку или тихо сбой?
  • Вынуть из стека 1 предмет.
  • Вынуть из стека 2 предмета.
  • Поместите один элемент в пустой стек.
  • Нажмите дважды, чтобы очистить стек.
  • И Т. Д.

Мы определенно хотели бы правильно протестировать нашу структуру данных стека.

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

Избегайте тестирования деталей реализации и насмешек (в частности, исправлений обезьян)

Для этого есть несколько причин.

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

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

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

Это принцип наименьшего знания. Принцип гласит, что если наш код о чем-то не знает, то его изменение не влияет на него, пока контракт остается прежним.

Если мы рассмотрим предыдущий пример о добавлении класса foo в некоторый HTML. Посмотрите на «хороший» тест.

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

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

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

Это в корне плохо. Изменения кода чреваты ошибками. Мы всегда хотим свести к минимуму изменения, которые мы должны внести в код. Принцип открыто-закрыто говорит именно об этом.

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

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

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

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

Теперь, очевидно, если мы издеваемся над myElement.classList.remove, мы можем быть достаточно уверены, что он работает нормально, поскольку это функция браузера, которая уже была тщательно протестирована.

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

Итак, следующий вопрос:

Что такое деталь реализации?

Исправление обезьян

Детали реализации — это, как правило, когда мы не просто тестируем конечный результат чего-то или когда мы что-то исправляем. Monkey-patching — это когда мы «навязчиво» шпионим или издеваемся над чем-то в нашем коде.

Например, в нашем примере класса HTML мы следим за функцией myElement.classList.remove. Мы получаем некоторый код, функцию Element.classList.remove, и изменяем его. Мы полностью заменяем функцию нашим шпионом очень навязчивым способом.

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

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

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

Примечание об использовании обезьяньих исправлений в JavaScript

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

Обычно это то, что мы делаем в JavaScript.

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

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

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

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

Я предлагаю следовать соглашению ООП для этих вещей.

Например, в C# и Java наименьшая единица — общедоступный метод класса. Частные методы недоступны даже для тестов.

С чем-то вроде JavaScript я предлагаю следовать тем же соглашениям, если это возможно.

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

Будьте прагматичны

Последний принцип – быть прагматичным.

Иногда можно нарушить все вышеперечисленные принципы. Все зависит от того, понимаешь ли ты, что делаешь.

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

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

Так что будьте прагматичны.

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

Примеры сценариев

Импорт и использование статистического пакета

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

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

I/O

Другой сценарий — при тестировании ввода-вывода вашего приложения. Тестирование ввода-вывода довольно сложно.

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

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

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

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

В качестве другого примера рассмотрим ведение журнала на стандартный вывод. В этом случае мы не склонны использовать замещающую среду. Вместо этого мы склонны просто исправлять наши функции ведения журнала (например, console.log в JavaScript). Да, мы сказали, что вообще не должны использовать обезьянью исправление, но в данном случае это, вероятно, наш лучший вариант.

Удобство

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

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

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

И это нормально, потому что мы можем восполнить утраченную уверенность в других тестах.

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

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

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

Эти тесты дали бы нам достаточную уверенность в том, что страница входа работает правильно.

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

Дополнительная уверенность

В общем, мы не хотим тестировать детали реализации.

Однако мы всегда можем делать все, что захотим.

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

Это нормально. Просто экспортируйте его и протестируйте.

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

Общий

Мы тестируем для достижения определенных целей с помощью программного обеспечения:

  • Код, который работает по назначению/правильно.
  • Код, который легко изменить.

Также:

  • В идеале тесты должны быть воспроизводимы с помощью одной команды.
  • Тест должен быть быстрым.

Вот наши принципы тестирования:

  • В программном обеспечении мы тестируем конечный результат.
  • Тесты более высокого уровня (например, сквозные тесты) обеспечивают большую достоверность, чем тесты более низкого уровня.
  • Тесты более низкого уровня по-прежнему важны, потому что тесты высокого уровня имеют ограничения.
  • Избегайте тестирования деталей реализации и насмешек (в частности, обезьяньих исправлений).
  • Будьте прагматичны. Иногда можно протестировать детали реализации, если вы знаете, что делаете.

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

Однако у нас есть принципы тестирования, поскольку в большинстве случаев они являются лучшим способом достижения наших целей. За некоторыми исключениями, у нас есть последний принцип, который просто гласит: «Будьте прагматичны и делайте то, что необходимо для достижения целей тестирования вашего программного обеспечения».

Впрочем, это все.

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

Первоначально опубликовано на https://www.sargalias.com.