Статьи

Clojure: тестирование создания частичной функции

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

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

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

(ns original)

(def state (atom {}))

(defn pure-legally-drunk? [bac bac-increase]
  (-> bac (+ bac-increase) (>= 0.08)))

(defn update-bac [x]
  (swap! state assoc :bac x))

(defn legally-drunk? [bac-increase]
  (pure-legally-drunk? (or (:bac @state) 0) bac-increase))

Следующие (проходящие) тесты показывают, что все работает как положено. 

(ns expectations.original-expectations
  (:use expectations original))

(expect {:bac 0.04} (with-redefs [state (atom {:bac 0.02})]
             (update-bac 0.04)))

(expect false (with-redefs [state (atom {})]
                (legally-drunk? 0.02)))

(expect false (with-redefs [state (atom {})]
                (update-bac 0.00)
                (legally-drunk? 0.02)))

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

(ns refactored)

(def state (atom {}))

(defn pure-legally-drunk? [bac bac-increase]
  (-> bac (+ bac-increase) (>= 0.08)))

(defn update-bac [x]
  (swap! state assoc :legally-drunk?* (partial pure-legally-drunk? x)))

(defn legally-drunk? [bac-increase]
  (let [{:keys [legally-drunk?*]
         :or {legally-drunk?* (partial pure-legally-drunk? 0)}} @state]
    (legally-drunk?* bac-increase)))

Два из трех тестов не меняются; однако тест, который проверял состояние, теперь не пройден.

(ns expectations.refactored-expectations
  (:use expectations refactored))

;;; "hmmm, how do I rewrite this?"
(expect {:bac 0.04} (with-redefs [state (atom {:bac 0.02})]
                      (update-bac 0.04)))

;; Ran 3 tests. 1 failures, 0 errors.
;;       expected: {:bac 0.04}
;;       was: {:legally-drunk?* #<clojure.core$partial$fn__4070>, :bac 0.02}
;;
;;       :legally-drunk?* with val #<clojure.core$partial$fn__4070>
;;                        is in actual, but not in expected
;;       :bac expected: 0.04
;;       was: 0.02

(expect false (with-redefs [state (atom {})]
                (legally-drunk? 0.02)))

(expect false (with-redefs [state (atom {})]
                (update-bac 0.00)
                (legally-drunk? 0.02)))

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

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

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

(ns expectations.refactored-passing-expectations
  (:use expectations refactored))

(expect {:legally-drunk?* [pure-legally-drunk? 0.04]}
        (with-redefs [state (atom {})
                      partial vector]
          (update-bac 0.04)))

(expect false (with-redefs [state (atom {})]
                (legally-drunk? 0.02)))

(expect false (with-redefs [state (atom {})]
                (update-bac 0.00)
                (legally-drunk? 0.02)))

Все эти тесты пройдены, и должны ли обеспечить безопасность, что пьяный по закону? и функции update-bac достаточно протестированы. Чисто юридически пьяный? Функция все еще нуждается в тестировании, но это должно быть легко, так как это чистая функция. 

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

(ns expectations.high-level-expectations
  (:use expectations refactored))

(expect (not (legally-drunk? 0.02)))
(expect (legally-drunk? 0.08))
(expect (legally-drunk? 0.09))


(expect false
        (with-redefs [state (atom {})]
          (update-bac 0.01)
          (legally-drunk? 0.02)))

(expect true
        (with-redefs [state (atom {})]
          (update-bac 0.01)
          (legally-drunk? 0.07)))

(expect true
        (with-redefs [state (atom {})]
          (update-bac 0.01)
          (legally-drunk? 0.08)))

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

(ns expectations.unit-level-expectations
  (:use expectations refactored))

(expect (not (pure-legally-drunk? 0.01 0.02)))
(expect (pure-legally-drunk? 0.01 0.07))
(expect (pure-legally-drunk? 0.01 0.08))

(expect {:legally-drunk?* [pure-legally-drunk? 0.04]}
        (with-redefs [state (atom {})
                      partial vector]
          (update-bac 0.04)))

(expect (interaction (a-fn 0.02))
        (with-redefs [state (atom {:legally-drunk?* a-fn})]
          (legally-drunk? 0.02)))

(expect (interaction (a-fn 0 0.02))
        (with-redefs [state (atom {})
                      pure-legally-drunk? a-fn]
          (legally-drunk? 0.02)))

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

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

Следующий код точно такой же, как код в refactored.clj, за исключением того, что он содержит опечатку из 1 символа. (нет необходимости обнаруживать опечатку, тестовый вывод ниже покажет, что вы хотите) 

(ns refactored-with-typo)

(def state (atom {}))

(defn pure-legally-drunk? [bac bac-increase]
  (-> bac (+ bac-increase) (>= 0.08)))

(defn update-bac [x]
  (swap! state assoc :legally-drunk?** (partial pure-legally-drunk? x)))

(defn legally-drunk? [bac-increase]
  (let [{:keys [legally-drunk?*]
         :or {legally-drunk?* (partial pure-legally-drunk? 0)}} @state]
    (legally-drunk?* bac-increase)))

Тесты высокого уровня дают нам следующую обратную связь.

failure in (high_level_expectations.clj:14) : expectations.high-level-expectations
(expect
 true
 (with-redefs
  [state (atom {})]
  (update-bac 0.01)
  (legally-drunk? 0.07)))

           expected: true 
                was: false

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

failure in (unit_level_expectations.clj:8) : expectations.unit-level-expectations
(expect
 {:legally-drunk?* [pure-legally-drunk? 0.04]}
 (with-redefs [state (atom {}) partial vector] (update-bac 0.04)))

           expected: {:legally-drunk?* [#
 
   0.04]} 
                was: {:legally-drunk?** [#
  
    0.04]}
 
           :legally-drunk?** with val [#
   
     0.04] 
                             is in actual, but not in expected
           :legally-drunk?* with val [#
    
      0.04] 
                            is in expected, but not in actual
    
   
  
 

Приведенный выше вывод указывает нам непосредственно на дополнительную звездочку в update-bac, которая вызвала сбой. 

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

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

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

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

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