Вступление переводчика

В статье про тестируемость я косвенно упоминал подход "разработка через тестирование" (TDD); сейчас же хочу поделиться переводом статьи от гуру TDD, Роберта Мартина, о том как типизация в разных языках влияет (или не влияет) на характер тестов. Приятного прочтения!

Типы и тесты

У нас с Марком Симаном развернулась увлекательная дискуссия в сети. Всё началось с моего сообщения:

“От этого никуда не деться. Независимо от того, используете ли вы статическую или динамическую типизацию, доказывать корректность вы всё равно должны с помощью тестов. Статическая типизация не уменьшает количество тестов, потому что эти тесты являются поведенческими и эмпирическими”.

Как часто бывает в социальных сетях, многие оставили под этим твитом грубые и/или оскорбительные комментарии. Эти люди нас не интересуют. А вот ответы Марка были уважительными и содержательными. Спор начался так:

“При всём уважении, не могу согласиться. Некоторые системы типов допускают null-ссылки. В таких системах типов приходится писать тесты, которые демонстрируют, как тестируемая система взаимодействует со значениями null.

В других системах типов (например, Haskell) значения null нет. Эквивалентный тест в них не напишешь”.

Развернулась горячая дискуссия. Возможно, вам было бы интересно и поучительно пройти по всем её веткам. Однако я хочу сосредоточиться на одном из ответов Марка. Он сослался на свой пост, написанный ещё в 2018 году. Я рекомендую прочитать его. Вы узнаете многое о тестировании, статической типизации и Haskell. А также увидите, как можно в прошлом опубликовать аргументы для спора в будущем. ;-)

Марк рассматривает в своём блоге простую функцию: rndselect(n,list). Она возвращает список из n элементов, случайно выбранных из входного списка. Он пошагово объясняет, как писал эту функцию на Haskell с помощью типов, QuickCheck и написанных пост-фактум тестов.

Мне стало интересно: а как бы отличался процесс и конечный результат при использовании динамически типизированного функционального языка (например, Clojure), и строгих методов TDD?

Давайте выясним.

Начнём с негативных тестов. Мы возвращаем пустой список, если входной список пуст или если количество запрашиваемых элементов равно нулю.

(deftest random-element-selection
  (testing "degenerate case"
   (is (= [] (random-elements 0 [])))
   (is (= [] (random-elements 0 [1])))
   (is (= [] (random-elements 1 [])))
   ))

(defn random-elements [n xs]
  [])

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

Разберём тривиальный случай: извлечение одного элемента из списка, содержащего один элемент.

(testing "trivial cases"
  (is (= [1] (random-elements 1 [1]))))

(defn random-elements [n xs]
  (if (or (< n 1) (empty? xs))
    []
    [(first xs)]))

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

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

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

(testing "repetitive case"
    (is (= [1 1] (random-elements 2 [1]))))

(defn generate-indices [n]
  (repeat n 0))

(defn random-elements [n xs]
  (if (or (< n 1) (empty? xs))
    []
    (map #(nth xs %) (generate-indices n))))

Возможно, вы скажете, что я немного забежал вперёд. Я заменил вызов first на вызов nth, при этом не написав отдельного теста. Почему? Пожимаю плечами

Следующая наименее сложная вещь, с которой следует разобраться — индексы. Сейчас они все просто равны нулю. Было бы здорово убедиться, что индексы, отличные от нуля, также работают правильно. Чтобы это протестировать, придётся сделать мок генерирующей индексы функции, а для этого нужно немного изменить структуру кода. Я разобью функцию generate-indices, чтобы иметь возможность сделать мок отдельного индекса.

В коде ниже странный вызов with-bindings временно заменяет реализацию функции index и всегда возвращает индекс 1. А необычный атрибут ^:dynamic необходим в Clojure, чтобы мокать (переопределять) функцию.

(testing "singular random case"
  (with-bindings {#'index (fn [] 1)}
    (is (= [2] (random-elements 1 [1 2])))))
	
(testing "repeated random case"
    (with-bindings {#'index (fn [] 1)}
      (is (= [2 2] (random-elements 2 [1 2])))))

(defn ^:dynamic index []
  0)

(defn generate-indices [n]
  (repeatedly n index))

(defn random-elements [n xs]
  (if (or (< n 1) (empty? xs))
    []
    (map #(nth xs %) (generate-indices n))))

Давайте проверим, что вызывается функция rand-int. Для этого убедимся, что сумма 10 случайных элементов, выбранных из [0 10 20 30], больше нуля и меньше 300.

(testing "random selection"
  (let [ns (random-elements 10 [0 10 20 30])
        sum (reduce + ns)]
    (is (< 0 sum 300))))
	
(defn ^:dynamic index [limit]
  (rand-int limit))

(defn generate-indices [n limit]
  (repeatedly n (partial index limit)))

(defn random-elements [n xs]
  (if (or (< n 1) (empty? xs))
    []
    (map #(nth xs %) (generate-indices n (count xs)))))

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

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

(testing "trivial cases"
  (is (= [1] (random-elements 1 [1]))))

(testing "repetitive case"
  (is (= [:x :x] (random-elements 2 [:x]))))

(testing "singular random case"
  (with-bindings {#'index (fn [_] 1)}
    (is (= ['b'] (random-elements 1 ['a' 'b'])))))

(testing "repeated random case"
  (with-bindings {#'index (fn [_] 1)}
    (is (= ["two" "two"] (random-elements 2 ["one" "two"])))))

Думаю, на этом можно остановиться.

В моих тестах 8 условий. Однако большинство из этих условий были добавлены в процессе TDD. Сколько из них по-настоящему необходимы для итоговой, работающей функции? Вероятно, только вырожденные случаи и конечная проверка на случайность. Иначе говоря, 4 условия. Я оставлю все остальные для документации; но, строго говоря, они не необходимы.

А как быть с недопустимыми аргументами? Что произойдёт, если вызвать: (random-elements -23 nil)? Должен ли я писать тесты для этих случаев?

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

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

Суть

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

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

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

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

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

О чём бишь мы...

Вернёмся к random-elements.

Если вы сравните две функции, которые написали Марк и я (и если вы, в отличие от меня, понимаете Haskell), я думаю, вы обнаружите, что наши реализации в чём-то похожи. А вот стратегии тестирования, которые мы использовали, совершенно разные.

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

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

Какой подход лучше?

"Конкретно ответить на этот вопрос не входит в мои полномочия." - Барак Обама.

У кого из нас оказалось меньше тестов? Мне кажется, что четырёх базовых условий вполне достаточно. Но в ходе работы я написал 8 условий, а Марк написал два теста QuickCheck на основе свойств и три регрессионных теста пост-фактум. Значит ли это, что наш счёт — 5:8? А может, тесты QuickCheck стоит считать за большее количество очков? Я не знаю. Решать вам.

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