Pull to refresh

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

Reading time7 min
Views4.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:
Total votes 20: ↑19 and ↓1+18
Comments3

Articles