[Из песочницы] Переписываем сценарии тестирования на Clojure за 24 часа
Предлагаю читателям «Хабрахабра» вольный перевод статьи «Rewriting Your Test Suite in Clojure in 24 hours» от основателя CircleCI.
Эта история о том, как я написал компилятор для автоматической трансляции комплекта тестов 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.
Источник заглавной картинки