Pull to refresh

Переписываем сценарии тестирования на Clojure за 24 часа

Reading time 7 min
Views 4.8K

Предлагаю читателям «Хабрахабра» вольный перевод статьи «Rewriting Your Test Suite in Clojure in 24 hours» от основателя CircleCI.


image

Эта история о том, как я написал компилятор для автоматической трансляции комплекта тестов CircleCI (14000 строк), в другую библиотеку тестирования за 24 часа.


На сегодняшний день этот набор тестов возможно один из самых больших в мире Clojure. Наш серверный код на 100% Clojure, включая тесты, состоящие из 14000 строк в 140 файлах, с 5000 ассертов. Без распараллеливания выполнение занимает 40 минут.


На старте этого приключения все тесты были написаны на Midje — библиотека для BDD тестирования, что-то похожее на RSpec. Мы были не особо довольны Midje, и решили перейти на clojure.test — вероятно наиболее широко используемая библиотека для тестирования. clojure.test проще и в ней меньше магии, и при этом более развитая экосистема инструментов и плагинов.


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


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


Чтение


Ключем ко всему преобразованию является функция read. read-string — встроенная в Clojure функция, которая принимает строку, содержащую любой Clojure код, и возвращает его в виде структуры данных. Эту же самую функцию использует компилятор, когда загружает исходные файлы. Пример: (read-string "[1 2 3]") вернет [1 2 3].


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


Преобразование


Наши тесты были написаны на midje, и мы хотим преобразовать их под clojure.test. Пример теста, использующего midje:


(ns circle.foo-test
  (:require [midje.sweet :refer :all]
            [circle.foo :as foo]))
(fact "foo works"
  (foo x) => 42)

и преобразованная версия, использующая clojure.test:


(ns circle.foo-test
  (:require [clojure.test :refer :all]))

(deftest foo-works
  (is (= 42 (foo x))))

Преобразование включает замену:


  • midje.sweet на clojure.test в ns форме


  • (fact "a test name"...) на (deftest a-test-name ...), потому что в clojure.test для именования тестов применяются идентификаторы, а не строки


  • (foo x) => 42 на (is (= 42 (foo x)))


  • мелкие детали, которые пока пропустим

Преобразование — это простой обход дерева в глубину:


(defn munge-form [form]
  (let [form (-> form
                 (replace-midje-sweet)
                 (replace-foo)
                 ...)]
    (cond
      (or (list? form)
          (vector? form)) (-> form
                              (replace-fact)
                              (replace-arrow)
                              (replace-bar)
                              ...
                              (map munge-form)))
      :else form))

Поведение -> похоже на chaining в Ruby или JQuery, или на Bash’s pipes: передаёт результат вычисления вызова функции, как аргумент в вызов следующей функции.


Первая часть (let [form ...]) берёт форму Clojure и применяет к ней каждую функцию преобразования. Вторая часть берет список форм, представляющих другие Clojure выражения и функции – и рекурсивно преобразует их.


Интересный процесс происходит в функциях замены. Они все имеют примерно такой вид:


(if (this-form-is-relevant? form)
  (some-transformation form)
  form)

т.е., они проверяют соответсвует ли переданная форма критерию замены, и если так, преобразует её нужным образом. Например, replace-midje-sweet выглядит так:


(defn replace-midje-sweet [form]
  (if (= 'midje.sweet form)
    'clojure.test
    form))

Стрелки


Весь синтаксис тестов в Midje крутится вокруг “стрелок” — неидеоматическая конструкция, которую Midje использует для повышения декларативности тестов в стиле BDD. Простой пример:


(foo 42) => 5

проверяет что (foo 42) возвращает 5.


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


(foo 42) => map?

Если в примере выше map? — это функция, то проверяется что результат применения этой функции к левой части выражения истинен (truthy — не равен nil или false). В Clojure это было бы так:


(map? (foo 42))

Несколько примеров Midje стрелок:


(foo 42) => falsey
(foo 42) => map?
(foo 42) => (throws Exception)
(foo 42) =not=> 3
(foo 42) => #"hello world" ;; regex
(foo 42) =not=> "hello"

Замена стрелок


Реальное преобразование использует порядка сорока core.match правил. Но все они выглядят примерно так:


(match [actual arrow expected]
  [actual '=> 'truthy] `(is ~actual)
  [actual '=> expected] `(is (= ~expected ~actual)
  [actual '=> (_ :guard regex?)] `(is (re-find ~contents ~actual))
  [actual '=> nil] `(is (nil? ~actual)))

(Для экспертов Clojure: чтобы повысить читаемость, я опустил множество символов ~’ в макросе выше. Чтобы посмотреть как это выглядит на самом деле, смотрите исходники.)


Большинство преобразований весьма прямолинейны. Однако, всё становится гораздо сложнее с формой contains:


(foo 42) => (contains {:a 1})
(foo 42) => (contains [:a :b] :gaps-ok)
(foo 42) => (contains [:a :b] :in-any-order)
(foo 42) => (contains "hello")

Последний кейс особенно интересный. Для выражения


(foo 42) => (contains "hello")

существует две совершенно разные ситуации, при которых тест будет успешно пройден. (foo 42) может вернуть список, который содержит элемент “hello”, или может вернуть строку, которая содержит подстроку “hello”:


"hello world" => (contains "hello")
["foo" "hello" "bar"] => (contains "hello")

В общем случае форма contains сложна для автоматического преобразования. Некоторые кейсы требуют дополнительной информации во время выполнения (как последний пример), и т.к. не существует реализации для многих кейсов contains в языке Clojure, таких как (contains [:a :b] :in-any-order), мы решили игнорировать все кейсы contains. Вместо попыток транслировать их автоматически, мы используем "провальное" правило, которое выглядит так:


[actual arrow expected] (is (~arrow ~expected ~actual))

Оно превращает (foo 42) => (contains bar) в (is (=> (contains bar) (foo 42))). Такой код не скомпилируется, потому как определение функции стрелки из Midje не загружено, и мы можем поправить это вручную.


Информация о типах во время выполнения


Была ещё одна дополнительная сложность с автоматическим преобразованием. Если имеем два выражения:


(let [bar 3]
  (foo) => bar

и


(let [bar clojure.core/map?]
  (foo) => bar

интерпретация стрелки Midje зависит от выражения справа, которое может быть определено (без заморочек) только во время выполнения. Если bar резолвится в данные, например string, number, list или map — Midje проверяет на равенство. Но если bar резолвится в функцию, Midje вызывает эту функцию, т.е. (is (= bar (foo))) против (is (bar (foo))). Наше 90%-решение подключает (require) пространство имён из исходного теста, и резолвит (resolve) функции во время процесса преобразования:


(defn form-is-fn? [ns f]
  (let [resolved (ns-resolve ns f)]
    (and resolved (or (fn? resolved)
                      (and (var? resolved)
                           (fn? @resolved)))))))

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


(let [s [1 2 3]
      count (count s)]
  (foo s) => count)

В этом случае мы хотим (is (= count (foo s))), но получаем (is (count (foo s))), что ошибочно, т.к. в локальном окружении count — это число, и (3 [1 2 3]) вызывает ошибку. К счастью, таких ситуаций было мало, потому что решение этой проблемы потребовало бы написания полноценного компилятора с определением локальных переменных в окружении.


Выполнение тестов


Когда код преобразования был написан, нам нужно было понять работает ли он. Т.к. мы запускаем код в REPL во время выполнения, нужно (после преобразования) просто запускать тесты с помощью встроенной функцией clojure.test.


Реализация clojure.test помогает связать вместе процессы преобразования и вычисления. Все тестовые функции могут быть вызваны из REPL, и даже (clojure.test/run-all-tests) возвращает осмысленное значение — отображение (map), содержащее количество тестов, пройденных и упавших:


{:pass 61, :test 21, :error 0, :fail 0}

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


Чтение


Однако, не все работало так просто.


“reader” (термин в Clojure для обозначения части компилятора, которая имплементирует функцию read) спроектирован для преобразования исходных файлов в структуры данных, прежде всего для использования компилятором. Он убирает комментарии, раскрывает макросы, что требует от нас проверки всех diff-ов вручную, чтобы вернуть эти строки. К счастью в тестах их было всего несколько. В нашем стиле программирования мы как правило предпочитаем docstrings комментариям, и изолируем макросы в небольшом количестве файлов, так что это нас не сильно затронуло.


Отступы


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


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


Результаты


Около 40% файлов с тестами прошли без нашего вмешательства, что на самом деле потрясающе, учитывая насколько быстро мы собрали это решение. В оставшихся файлах около 90% тест-ассертов были преобразованы и пройдены. Итого 94% ассертов во всех файлах были преобразованы автоматически — великолепный результат.


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


От переводчика. Благодарю за помощь: comerc, Source, chort409 и artemyarulin.
Источник заглавной картинки

Tags:
Hubs:
+18
Comments 3
Comments Comments 3

Articles