Тесты необязательны.

Чем больше я читал о разработке программного обеспечения, тем чаще повторял эту мантру. И с каждым дальнейшим повторением пункта я виновато корчился. Санди Мец; Расс Олсен; Дядя Боб Мартин; Мартин Фаулер; Эрик Эллиот… каждый, все выдающиеся авторы превозносят достоинства тестирования.

Моя вина, конечно, заключалась в том, что я никогда не писал юнит-тесты. В бесплатных онлайн-курсах по Java, которые я использовал, чтобы переключиться на программирование, модульные тесты не упоминались. В книге «Изучай C++ за 17 пикосекунд», которую я использовал, чтобы взломать главу моей докторской диссертации по физике, модульные тесты не упоминались. Мое первое знакомство с программированием было на коротком курсе лекций на FORTRAN для студенческих лабораторий, и вам лучше поверить, что тогда никто не упоминал модульные тесты. И мои коллеги по моей новой работе младшим разработчиком их тоже не писали. Поэтому, когда я впервые прочитал что-то, говорящее мне просто написать тесты уже сейчас, я успокоился. По мере того как каждая проходящая книга и пост в блоге повторяли это сообщение, я позволял чувству вины медленно накапливаться, подобно легендарной лягушке, которая пассивно кипит в слегка нагретой воде.

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

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

Что

Автоматический тест – это фрагмент кода, задачей которого является проверка того, что какой-то другой фрагмент кода работает так, как вы думаете. Фраза модульныйтест подразумевает, что ваш автоматический тест просто рассматривает одну небольшую автономную часть вашего кода. (В Ruby это обычно означает один класс, хотя ведутся споры по поводу более мелких деталей, которые нас сейчас не касаются.) КАК в этой статье будет позже, но давайте возьмем предварительный просмотр.

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

Вот тест, который мы могли бы написать, чтобы проверить, что наш метод add «работает правильно», под которым мы подразумеваем «дает нам ожидаемый результат». (Я объясню цинизм кавычек позже.)

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

Создайте два вышеуказанных файла в текстовом редакторе внутри одной папки (чтобы require_relative мог найти MathDoer) и откройте окно терминала с этой папкой в ​​качестве рабочего каталога. Линия

assert_equal 4, math_doer.add(2,2)

определяет нашу «схему оценок» для теста, так что тест проходит, если наш метод возвращает 4 при вводе (2,2).

Запустите команду ruby test_math_doer.rb, чтобы насладиться теплым светом прохождения тестов:

Run options: — seed 30491
# Running:
.
Finished in 0.000802s, 1246.8827 runs/s, 1246.8827 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

Эта удручающая точка указывает на то, что тест пройден, а это означает, что код ведет себя так, как ожидалось. Если бы мы написали больше проходных тестов (методы, начинающиеся с test_), точек было бы больше. Чтобы увидеть, что происходит, когда мы допускаем ошибку, давайте представим, что мы сделали опечатку. Попробуйте изменить знак + в нашем методе add на соседний знак минус - и снова запустите приведенную выше команду, чтобы ощутить горький вкус неудачи:

Run options: --seed 44782
# Running:
F
Finished in 0.001044s, 957.8546 runs/s, 957.8546 assertions/s.
1) Failure:
TestMathDoer#test_addition [test_math_doer.rb:7]:
Expected: 4
Actual: 0
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips

Наш код вел себя не так, как мы ожидали, поэтому наш тест не прошел, о чем свидетельствует большая буква «F» вместо точки.

ПОЧЕМУ

Очевидно, что мы хотим поставлять код без ошибок. Без автоматизированного теста мы, вероятно, проверили бы нашу работу, пройдя через процесс, который логически эквивалентен тесту выше в оболочке irb. Это займет всего одну строку кода:

MathDoer.new().add(2,2)

чтобы мы были счастливы, когда увидели результат 4 на экране. Это было бы быстрее. И это дало бы нам точно такую ​​же уверенность в поведении нашего кода, как и приведенный выше тест. Так что же мы получили от дополнительных усилий?

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

Документация

В качестве простого примера того, как тесты предоставляют документацию, рассмотрим следующую строку кода:

[0,1,2].push(3)

Все знают, что это добавит число 3 к массиву, верно? Но эта строка кода действительна как в Ruby, так и в Javascript, и ведет себя по-разному в этих двух языках. В Ruby он возвращает только что измененный массив, а в Javascript он возвращает длину этого массива (4). Если вы пишете собственную реализацию Array, вам может понадобиться метод push, который ведет себя третьим образом, возвращая новый массив [0,1,2,3], оставляя исходный массив без изменений. Следующий тест сделает это поведение кристально понятным для пользователей вашего кода:

Предотвращение регрессии

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

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

КАК

Давайте теперь вернемся к ключевым моментам вышеприведенного теста.

Во-первых, мы определяем класс, который является подклассом Minitest::Test. Экземпляры этого класса имеют методы, имена которых начинаются с test_. Поскольку мы включили minitest/autorun, и благодаря изящному метапрограммированию, все такие методы вызываются, когда мы запускаем этот файл в нашем интерпретаторе Ruby.

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

Дано/Упорядочить: вы подготавливаете необходимое состояние нашего приложения. В нашем случае это просто создание экземпляра класса MathDoer.

Когда/Действие. Вы запускаете фрагмент кода, который пытаетесь протестировать. В нашем случае мы хотим протестировать метод add, поэтому мы вызываем эту функцию с некоторыми аргументами.

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

assert_equal expected, actual

который не проходит тест, если только значения expected и actual не совпадают. Это, вероятно, наиболее часто используемое утверждение; если ваш метод возвращает значение, то в 90 % случаев поведение метода будет определяться хорошо подобранными входными и выходными данными. Тем не менее, Minitest поставляется с целым рядом утверждений для различных вариантов использования.

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

0.1 + 0.2 == 0.3

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

assert_in_delta 0.3, @math_doer.add(0.1, 0.2), 0.0001

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

assert prove_riemann_hypothesis

Существуют и другие утверждения для возбуждения исключений, указания того, что объект отвечает_на вызов метода, и многое другое. Более полную справку смотрите в документации. Поскольку это Ruby, мы можем заменить assert на refute в любом утверждении, чтобы отрицать его, так что, например. refute false или refute_equal 1,2 пройдет.

Тестирование как вид искусства

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

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

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

Для этого ключевыми являются описательные имена. Я позволил себе быть кратким с test_addition , но в случае, когда ожидаемое поведение было менее знакомым, я больше думал о том, как назвать тест. Например, если бы я тестировал логику разрешений для веб-приложения, имя test_denies_requests_without_token было бы лучше, чем test_auth .

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

В качестве небольшого примера представьте, что из-за опечатки наш метод add фактически умножил наши входные данные: addend * augend . Вышеприведенный тест все равно прошел бы, потому что 2 x 2 = 2 + 2. Другими словами, мы должны были выбрать наши входные данные лучше — буквально любая другая пара чисел (кроме 0, 0) дала бы нам больше уверенности в правильности вычисления. наш код.

Для немного менее тривиального примера представьте, что у нас есть методы sum_odd_numbers и sum_even_numbers, которые ожидают массив в качестве аргумента. [1,2,3,4] — лучший выбор ввода, чем [1,2,4,5] , потому что в последнем и нечетное, и четное подмножества в сумме дают 6, что повышает вероятность того, что наши методы могут быть реализованы неправильно. (На самом деле мне пришлось отлаживать проблему в производственном коде, вызванную двумя методами, которые, казалось бы, собирали пыль, пока я не попытался использовать один из них, названный неправильно. Серьезно.) Если бы мы выбирали числа для сложения на основе некоторых более сложных критериев, чем нечетность или четность, [1,2,4,9] будет еще лучшим примером ввода, потому что никакое подмножество этого массива не суммируется с любым другим элементом массива (в отличие от 1 + 2 = 3, 1 + 3 = 4 в первом массиве).

Подведение итогов

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