Переписуємо сценарії тестування на Clojure за 24 години

Пропоную читачам «Хабрахабра» вільний переклад статті «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=> "привіт"

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

Реальне перетворення використовує близько сорока 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-ок)
(foo 42) => (contains [:a :b] :in-any-order)
(foo 42) => (contains "привіт")

Останній особливо цікавий кейс. Для вираження
(foo 42) => (contains "привіт")

існує дві зовсім різні ситуації, при яких тест буде успішно пройдено.
(foo 42)
може повернути список, який містить елемент «hello», або може повернути рядок, яка містить підрядок «hello»:
"hello world" => (contains "привіт")
["foo" "привіт" "bar"] => (contains "привіт")

У загальному випадку форма
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, Джерело, chort409 і artemyarulin.
Джерело великої картинки
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.