В последней главе мы поговорим о тестировании приложений. Читатель узнает, что такое тесты и каких типов они бывают. Мы рассмотрим методы тестирования и хорошие практики. Постараемся избежать лишней сложности: не будем злоупотреблять терминами вроде TDD и BDT. Покажем, что в мире Clojure легко писать и поддерживать тесты.

Содержание

Основные понятия

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

Внимание! Вы читаете черновик к книге “Clojure на производстве”. Для книги я переписывал его много раз, но в блоге осталась старая версия. Здесь она для истории, а вам я рекомендую купить книжку.

Тест — это код, который проверяет другой код. Например, мы написали функцию для перевода температуры из Цельсия в Фаренгейты:

(defn ->fahr [cel]
  (+ (* cel 1.8) 32))

Мы вызвали ее вручную несколько раз убедились, что результаты такие же, как в школьном справочнике. Зафиксируем расчеты в специальной функции проверки. Функция сравнивает вызов ->fahr с каноничными значениями 68 и 212. Их посчитали заранее и расценивают как верные.

(defn test-fahr []
  (assert (= (int (->fahr 20)) 68))
  (assert (= (int (->fahr 100)) 212)))

Макрос assert бросает исключение, если его тело вычисляется в ложь. На данный момент вызов (test-fahr) вернет nil без ошибок. Если внести в алгоритм изменения, функция бросит исключение:

(defn ->fahr [cel]
  (+ (* cel 1.9) 32))

(test-fahr)
Assert failed: (= (int (->fahr 20)) 68)

Функция test-fahr и есть тест. Она проверяет, что никто не изменил код ->fahr так, что мы получим другие результаты. В наших интересах вызвать (test-fahr) перед сборкой программы. Так мы не допустим, чтобы алгоритм с ошибкой попал в производство.

Техническая деталь: в тесте выше мы сравниваем результат ->fahr с целым числом. Без обертки в (int …) функция вернет число с плавающей запятой, которое трудно сравнить с другим таким же числом. Поэтому мы приводим значение к целому.

Тест не знает о внутреннем устройстве функции, которую проверяет. Мы вправе менять алгоритм до тех пор, пока тест выполняется без ошибок. Предположим, мы тестируем функцию факториала. Первая версия работает как линейное перемножение чисел, что не эффективно. Но уже для первой версии мы написали тест, который проверяют, что (= (fact 5) 120). Если заменить линейный алгоритм на дерево или таблицу значений, тест должен пройти без ошибок. В противном случае это значит, что в новом алгоритме ошибка.

Кейсы и покрытие

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

(defn square-roots [a b c]
  (let [D (- (* b b) (* 4 a c))]
    (cond
      (pos? D) [(/ (+ (- b) (Math/sqrt D)) (* 2 a))
                (/ (- (- b) (Math/sqrt D)) (* 2 a))]
      (zero? D) (/ (- b) (* 2 a))
      (neg? D) nil)))

Чтобы проверить алгоритм, тест вызывает функцию (square-roots a b c) минимум три раза. Параметры должны быть подобраны таким образом, чтобы отработала каждая ветка. Еще лучше, если на каждую ветку приходится отдельный тест с разными значениями. Каждый тест можно расширить в будущем. Три теста ниже образуют кейс, который проверяет алгоритм целиком.

(defn test-square-roots-two-roots []
  (let [[x1 x2] (square-roots 1 -5 6)]
    (assert (= [(int x1) (int x2)] [3 2]))))

(defn test-square-roots-one-root []
  (assert (= (square-roots 1 6 9) -3)))

(defn test-square-roots-no-roots []
  (assert (= (square-roots 2 4 7) nil)))

Как правило, в объектно-ориентированных языках кейсы это классы, а тесты — их методы. Но в мире Clojure тест это просто функция.

В разговорах о тестах часто упоминают их покрытие. Под этим словом имеют в виду долю кода, которая сработала в момент запуска теста. Предположим, мы написали тесты только для двух корней. В этом случае ветки (zero? D) и (neg? D) не выполнятся. Специальный модуль в тестовом фреймворке читает общее число строк в исходной функции, количество исполненных строк и коэффициент покрытия. Продвинутые фреймворки строят отчет, где выполненные строки помечены зеленым (плюсом), а пропущенные красным (минусом).

+(defn square-roots [a b c]
+  (let [D (- (* b b) (* 4 a c))]
+    (cond
+      (pos? D) [(/ (+ (- b) (Math/sqrt D)) (* 2 a))
+                (/ (- (- b) (Math/sqrt D)) (* 2 a))]
-      (zero? D) (/ (- b) (* 2 a))
-      (neg? D) nil)))

В функции square-roots всего 7 строк. При запуске test-square-roots-two-roots сработали 5 из них. Покрытие составит 5/7, что приблизительно 71%.

Принято считать, что покрытия 80% достаточно для того, чтобы код работал надежно. Значения ниже говорят о недостаточном покрытии. Это значит, в проекте встречается код, который не защищен от спонтанных изменений. При попытке приблизиться к 100% возрастают затраты на разработку и поддержку тестов. Поэтому в каждой команде придерживаются той величины, которая удобна для производства.

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

Не только числа

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

Рассмотрим пример с подписью командной строки. Функция sign-params принимает словарь параметров и секретный ключ. Алгоритм подписи следующий:

  • отсортировать параметры по именам ключей;
  • составить строку вида param1=value1&param2=value2...;
  • экранировать некоторые символы, например, пробел, знак плюса и другие;
  • получить сигнатуру строки по алгоритму HMAC SHA256 и секретному ключу;
  • вернуть словарь параметров с полем :signature

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

(defn test-sign-params []
  (let [api-key "2Ag48&@%776^634Tsdf23"
        params {:action :postComment
                :user_id 42
                :post_id 1999
                :comment "This is a great article!"}
        signature "e36b331823b..."]
    (assert (= (sign-params params api-key)
               (assoc params :signature signature)))))

Заметим, что sign-params работает не с числами, а коллекциями, строками и криптографией. Это не помешало написать для нее тест. Чтобы улучшить нашу работу, добавьте проверку на пустой ключ, экранирование символов, кириллические языки, эмодзи и так далее.

Когда писать тесты

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

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

Разделять бизнес-логику и код это особый навык программиста. Пишите тесты так, чтобы они проверяли именно логику, а не код. Это абстрактный навык, и его трудно передать на словах. Здесь и помогут тесты: они подтолкнут вас писать код с упором на потребности бизнеса.

О чистоте функций

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

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

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

Было:

(defn process-csv [path]
  (let [content (slurp path)]
    (for [line (clojure.string/split content #"\n")]
      (remap-line line))))

Стало:

(defn process-csv-content [content]
  (for [line (clojure.string/split content #"\n")]
    (remap-line line)))

(defn process-csv [path]
  (process-csv-content (slurp path)))

Напишем тест для process-csv-content:

(def CONTENT
  (str "Ivan;ivan@test.ru;http://example.ru"
       \newline
       "John;john@test.com;http://example.com"))

(assert (= (process-csv-content CONTENT)
           [{:name "Ivan" :email ...}
            {:name "John" :email ...}]))

Другой способ в том, чтобы заменить функцию на мультиметод. Его реализация зависит от типа первого аргумента. Если это java.io.File, мы читаем файл и вызываем метод с содержимым. Для String срабатывает разбор данных.

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

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

Производство

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

Если разработчик добавил код, не не покрыл его тестами, это снизит долю покрытия. Можно задать правило, что при значении ниже заданного слияние такой ветки запрещено. Сервисы GitHub, CircleCI и другие гибко реагируют на ошибки в тестах. Они шлют письма с отчетом, пишут сообщения в Slack, всячески сигнализируют о проблеме.

В некоторых случаях тесты заменяют документацию. Это утверждение нельзя воспринимать буквально — настоящую документацию не заменит ничто. Но в open source-проектах ее недостаток иногда компенсируют тестами. Это нормально, потому что на качественную документацию уходит столько же времени, сколько на техническую часть. Если вы не уверены, как работает та или иная функция, возможно, ее поведение прояснится из тестов.

Устройство тестов

Функции с макросом assert это сильно упрощенные тесты. Они пригодятся на раннем этапе разработки, когда автор только обозначил контуры программы. Assert-ы это временные меры чтобы сдержать код от изменений. Для полноценной работы с тестами понадобится фремворк. Это библиотека, которая определяет, где и как описаны тесты.

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

На первом этапе фреймворк ищет тесты в кодовой базе проекта. По-другому фаза называется test discovery. Это не простая задача: фреймворк загружает все модули и ищет среди них тесты. Каждый найденный тест попадает в глобальный регистр.

Тест должен носить какой-то признак, по которому его можно отличить от обычной функции. В примере выше test-sign-params это именно функция, а не тест (префикс test- не делает функцию тестом). Далее мы рассмотрим, как превратить ее в тест.

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

На этапе прогона фреймворк выполняет тесты один за другим как функции. Система фиксирует их количество и имена. Вызов теста обернут в try/catch, чтобы исключение не прервало процесс. Каждая проверка попадает в глобальную переменную. Эти сведения необходимы, чтобы построить отчет.

На этапе отчета система выводит накопленные данные в консоль. Качественный фреймворк отличает удобство вывода — насколько он понятен человеку. Проблемные тесты должны быть обозначены красным цветом. Для неудачных сравнений мы ожидаем полные формы, а не финальные значения. Например, (= (int (->fahr 20)) 68) вместо (= 69 68).

Промышленные фреймворки показывают отчет в разных форматах. Кроме текста в консоли это может быть HTML-файл. Систему сборки настраивают так, что отчет о тестах публикуется на внутреннем веб-сервере по адресу /<project>-<artifact>-test.html. Часть <artifact> это имя ветки или хэш коммита.

Стандарт XUnit определяет, как записать отчет в виде XML-файла. Промышленные системы вроде TeamCity и Atlassian понимают, как вывести этот файл в удобном виде. XUnit удобен тем, что это стандарт. У вас могут быть тесты на Python, JavaScript или Clojure, но если прогон запущен с флагом –xunit (или подобным), отчеты будут выглядеть одинаково.

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

Типы тестов

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

Понятие “юнит-тест” происходит от английского unit, модуль. Термин пришел из мира Java и фреймворка JUnit. Согласно его идеологии, тесты группируют по модулям. На каждый модуль из кодовой базы приходится одноименный тестовый, который проверяет функциональность первого.

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

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

В качестве примера вспомним регистрацию на сайте. Это сложный процесс из нескольких шагов:

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

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

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

Чтобы проиграть сценарий регистрации, понадобится запущенная система и смежные ресурсы: веб-сервер, почтовый сервер, база данных. Интеграционный тест подключается к браузеру по протоколу WebDriver и командует им. Открывает страницу регистрации, вводит данные и нажимает кнопку. Проверяет, что появилось сообщение с просьбой проверить почту. По протоколу SMTP скачивает последнее письмо. Ищет ссылку активации регулярным выражением. Открывает ссылку в новой вкладке. Переходит на страницу авторизации и вводит почту и пароль. Убеждается, что попал в личный кабинет.

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

Интеграционные тесты выполняются дольше. Открыть страницу, дождаться ее загрузки и заполнить поля занимает больше времени, чем отправить запрос машинным способом. Иногда управление браузером намеренно замедляют, чтобы согласовать команды с содержимым. Например, не кликнуть на кнопку, которую браузер еще не успел отрисовать.

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

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

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

К другим категориям тестов относятся Smoke-, Sanity-, регрессионные и другие. Полный их список состоит из десяти и более пунктов. Мы не ставим цель рассмотреть их все. В этой главе мы глубоко изучим юнит- и интеграционные тесты. На этих двух категориях основаны остальные. Читателю хватит знаний, чтобы разобраться с ними самостоятельно.

Тесты в Clojure

Переходим к практике. В этом разделе мы познакомимся со стандартным тестовым фреймворком Clojure. Мы напишем настоящие тесты — такие, которые отвечают требованиям фреймворка. Будем тестировать функции Фаренгейта и квадратного уравнения из примеров выше.

Предположим, что функция ->fahr находится в модуле src/book/util.clj. Создайте файл test/book/util_test.clj с содержимым:

(ns book.util-test
  (:require [book.util :refer [->fahr]]
            [clojure.test :refer [deftest testing is]]))

(deftest test-fahr
  (is (= (int (->fahr 20)) 68))
  (is (= (int (->fahr 100)) 212)))

Получился тестовый модуль. Он импортирует ->fahr и объявляет для нее один тест test-fahr. Макросы is и deftest заимствованы из пакета clojure.test. Это тестовый фреймворк из поставки Clojure.

Шесть строк выше несут много новой информации. У читателя появятся резонные вопросы. Почему тесты лежат в папке test, а не src? Разве не логично хранить тесты рядом с тем, что они проверяют? Как система найдет их? Почему тест объявляют особым макросом deftest? Автор утверждал, что тест это функция, или это не так? Зачем было менять assert на is? Ответим на эти вопросы по порядку.

В Clojure разделяют код приложения и тестов. Код приложения находится в папке src/, а тесты в test/. Такой подход несет преимущества. Во-первых, делит код на смысловые части. Чтобы найти неисправный тест, мы сужаем область поиска до папки test/, а не ищем по всему проекту. Во-вторых, когда тесты отделены, это снижает риск того, что они станут частью скомпилированного результата. В этом нет смысла, потому что тесты запускают только на этапе разработки. Мы только увеличим время сборки и объем конечного файла.

Во время тестов проект запускается с особым параметром. Он определяет, где еще кроме src/ искать исходные файлы. Технически это список, в котором по умолчанию один путь src/. В режиме тестов система добавляет к нему путь test/. Когда компилятор ищет файл, он по очереди просматривает пути из списка. С точки зрения программиста пути как будто сливаются друг с другом. Далее в главе мы рассмотрим, как настроить пути под нужды проекта.

Мы упоминали, что фреймворку необходим какой-то признак, чтобы отличить тест от обычной функции. Макрос deftest объявляет функцию и сообщает ее метаданными особое поле :test. Чтобы выбрать все тесты, фреймворк сканирует пространства имен и читает метаданные каждой переменной. Если среди полей есть :test, переменная считается тестом.

Заметим, что deftest не оставляет возможности задать аргументы теста. Это сделано нарочно: в Clojure тест не зависит от внешних данных. Если тесту нужны особые условия, их задают фикстурами. Мы рассмотрим фикстуры в отдельном разделе.

Макрос is отличается от assert побочными эффектами. Assert это чистое сравнение: мы получим либо nil, либо исключение. Макрос is изменяет глобальные переменные. Он записывает данные о том, сколько раз и с какими параметрами его вызвали. Из этих данных фреймворк строит отчет. Например, покажет все выражения как они записаны в коде, которые не прошли проверку.

Пока что мы только написали тест, но не запустили его. Clojure предлагает несколько способов сделать это.

Командная строка

Самый простой способ — поручить прогон утилите для сопровождения проекта. Если проект основан на Leiningen, достаточно вызвать в терминале lein test. Потребуется некоторое время, чтобы загрузить код из папок src/ и test/, найти тесты и выполнить их. Вы увидите следующий отчет:

lein test book.util-test

Ran 1 tests containing 2 assertions.
0 failures, 0 errors.

Clojure группирует тесты по пространствам имен. Под каждым пространством сводная информация о том, сколько выполнено тестов (объявлений deftest) и проверок (вызовов is). В нашем случае это один тест test-fahr с двумя проверками is.

Выражение lein test book.util-test напечатано для того, чтобы его можно было скопировать и запустить в терминале. Тогда сработают тесты только из пространства book.util-test.

Фреймворк различает т.н. failures и errors. Failure (анг. неудача) — это ошибка в проверке. Например, мы утверждаем, что (= (int (->fahr 20)) 68). Если формула изменится и значения станут не верны, счетчик failures увеличится на единицу. Каждое неудачное утверждение помнит о том, с какими параметрами его запустили. Если была хотя бы одна неудача, мы увидим отчет об ошибке.

Откройте определение ->fahr и измените коэффициент 1.8 на 1.9. Сохраните файл и запустите в терминале lein test:

$ lein test

lein test :only book.util-test/test-fahr

FAIL in (test-fahr) (util_test.clj:6)
expected: (= (int (->fahr 20)) 68)
  actual: (not (= 70 68))

lein test :only book.util-test/test-fahr

FAIL in (test-fahr) (util_test.clj:7)
expected: (= (int (->fahr 100)) 212)
  actual: (not (= 222 212))

Ran 1 tests containing 2 assertions.
2 failures, 0 errors.
Tests failed.

Отчет показывает 2 failures, что верно: в теле test-fahr макрос is встречается два раза. Обе проверки неверны, потому что опираются на ->fahr. Для каждой из них отчет показывает исходное выражение (= (int (->fahr 100)) 212) и конечные значения (= 222 212).

Выражение

lein test :only book.util-test/test-fahr

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

Errors или ошибки это непойманные исключения, которые случились во время работы теста. Чаще всего ошибки возникают, когда в функции передают некорректные данные. Например, мы решили проверить поведение ->fahr с nil. Добавим второй тест:

(deftest test-fahr-nil
  (is (nil? (->fahr nil))))

Отчет будет таким:

$ lein test

lein test :only book.util-test/test-fahr-nil

ERROR in (test-fahr-nil) (Numbers.java:3849)
expected: (nil? (->fahr nil))
  actual: java.lang.NullPointerException: null
 at clojure.lang.Numbers.multiply (Numbers.java:3849)
    book.util$__GT_fahr.invokeStatic (util.clj:5)
    book.util$__GT_fahr.invoke (util.clj:4)
    book.util_test$fn__370.invokeStatic (util_test.clj:10)
    book.util_test/fn (util_test.clj:9)
    ... <truncated>

Ran 2 tests containing 3 assertions.
0 failures, 1 errors.
Tests failed.

Вызов (->fahr nil) приводит к выбросу NullPointerException. Фреймворк оборачивает тест в try/catch и запоминает исключение. Для ошибок отчет выводит их стек-трейс. В примере выше мы сократили его для экономии места.

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

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

REPL

У тестов в консоли один недостаток: это долго. Код на Clojure, когда загружен в память, выполняется быстро, но его загрузка занимает от трех до десяти секунд. Время зависит от подключенных библиотек, размера кодовой базы и мощности компьютера. Даже если вы запускаете один тест, lein стартует новую копию проекта, что неэффективно.

Когда вы работаете с проектом, скорее всего, вы подключены к его сеансу REPL. Будет удобно выполнить тест прямо в REPL по команде, не запуская новый проект. Тогда издержки на загрузку кода станут равны нулю.

Наивный способ выполнить тест — запустить его как функцию. Выражение (test-fahr) вернет nil, что означает, что все утверждения сработали верно. Теперь исправим тест: пусть одно из утверждений заведомо ошибочно:

(is (= (int (->fahr 20)) 999))

Даже если сохранить файл и вызвать (test-fahr), по-прежнему получим результат без ошибок. Это происходит потому, что мы только сохранили файл, но не загрузили новый теста в память JVM. То, как изменения в файле перетекают в память Lisp-машины, зависит от вашего окружения.

Если это Emacs/CIDER, выполните одно из действий. Переместите курсор за последнюю закрывающую скобку формы (deftest test-fahr …) и нажмите C-c C-e. Сработает команда cider-eval-last-sexp, которая выполняет последнее S-выражение. Другой способ в том, чтобы выполнить весь буфер. Независимо от того, где сейчас курсор, нажмите C-c C-k (или M-x cider-eval-buffer ). Эта команда равносильна тому, чтобы скопировать буфер и вставить в сеанс REPL.

Повторный вызов (test-fahr) вернет nil, но в консоли появятся строки:

FAIL in (test-fahr) (util_test.clj:14)
expected: 68
  actual: (999)

Если вызвать тест (test-fahr-nil), который мы все еще не исправили, увидим данные об исключении:

ERROR in (test-fahr-nil) (Numbers.java:3849)
expected: (nil? (->fahr nil))
  actual: java.lang.NullPointerException: null
 at clojure.lang.Numbers.multiply (Numbers.java:3849)
    book.util$__GT_fahr.invokeStatic (form-init3606582116051051694.clj:5)
    book.util$__GT_fahr.invoke (form-init3606582116051051694.clj:4)
    ...

Заметим, это только вывод в консоль, а не брошенное исключение. Тест устроен так, что его тело обернуто в try/catch. Поэтому дополнительно оборачивать вызов теста не нужно.

Когда тест запускают вручную как функцию, к нему не применяются фикстуры. Это пре- и пост-обработчики, которые подготавливают среду для теста. О фикстурах мы поговорим в дальнейших разделах главы. Пока что отметим, что функция test-vars выполняет тесты с учетом фикстур. Она принимает вектор объектов Var:

(require '[clojure.test :refer [test-vars]])
(test-vars [#'test-fahr #'test-fahr-nil])

Функция run-test запускает все тесты пространства (текущего или указанного). Технически она сводится к вызову test-vars со всеми переменными из этого пространства:

(require '[clojure.test :refer [run-tests]])
(run-tests)

Еще одна функция run-all-tests из выполняет тесты для всех загруженных пространств:

(require '[clojure.test :refer [run-all-tests]])
(run-all-tests)

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

Testing nrepl.middleware.interruptible-eval
Testing cider.nrepl.middleware.util.nrepl
Testing clojure.test
Testing clojure.core.server
Testing clojure.core.specs.alpha
Testing book.util
...

Чтобы отбросить лишние пространства, в run-all-tests передают регулярное выражение. С этим выражением сверяют имена пространств. Например, чтобы выполнить только наши тесты, укажем, что имя модуля начинается с book:

(run-all-tests #"$book")

Emacs/CIDER

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

Чтобы запустить один тест, переместите на него курсор и нажмите C-c C-t t (или M-x cider-test-run-test). Курсор может быть в любом месте (deftest …). Если тест прошел без ошибок, вы увидите в области сообщений зеленый текст. Если случились ошибки, откроется специальный буфер cider-test-report.

Этот буфер выводит отчет в более удобной форме. Ошибки в утверждениях подсвечены красным цветом, а непойманные исключения желтым. Буфер интерактивен: если подвести курсор к блоку с ошибкой и нажать Enter, соседний буфер покажет исходный код теста. То же самое работает для исключений: чтобы не засорять отчет, CIDER показывает только класс и сообщение. По Enter открывается отдельный буфер с полным стек-трейсом.

Перечислим другие полезные команды:

  • cider-test-rerun-failed-tests (C-c C-t r) повторно выполняет неудачный тест из прошлого запуска. Это удобно, если в первый раз вы запустили блок тестов, и ошибка случилась где-то на середине.

  • cider-test-run-ns-tests (C-c C-t n) выполняет тесты для определенного пространства. С помощью этой команды можно запустить тесты без перехода в их модуль. Чтобы сопоставить пространство и тесты, CIDER добавляет к имени “-test”. Например, для book.util целевой модуль получится book.util-test.

Запуск тестов это рутинная операция; избегайте ее ручного вызова. Чтобы не терять время, изучите, как это сделать напрямую из редактора или IDE.

Полезные практики

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

Testing

Макрос testing оборачивает произвольное тело строкой. Это сообщение, которое говорит о том, что мы собираемся делать:

(deftest test-square-roots
  (testing "Two roots"
    (let [result (square-roots 1 -5 6)]
      (is (= (mapv int result) [3 2]))))
  (testing "One root"
    (is (= (square-roots 1 6 9) -3)))
  (testing "No roots"
    (is (nil? (square-roots 2 4 7)))))

Сообщение важно по двум причинам: визуальной и технической. Оно разбивает тест на логические блоки, заменяет комментарии, делает тест понятнее. Если утверждение не сработало, в отчете будет указано сообщение, в которое оно обернуто.

(is (= (square-roots 1 6 10) -3))

Fail in test-square-roots
One root
expected: nil
  actual: -3

Макросы testing могут быть вложенными. Тогда каждое следующее сообщение добавляется к предыдущему через пробел. Тест ниже проверяет, что API createUser действительно заносит пользователя в базу данных. Дополнительно мы убеждаемся, что пароль в базе зашифрован. Если отбросить основной код, получим скелет теста из форм testing:

(deftest test-some-api
  (testing "API call"
    ...
    (testing "HTTP response"
      ...))
  (testing "DB checks"
    ...
    (testing "common user fields"
      ...)
    (testing "password is hashed"
      ...)))

Исключения

Мы все еще не решили вопрос о том, как проверять код с исключениями. Хотелось бы убедиться, что вызов (->fahr nil) порождает ошибку, а не возвращает nil. Если теста на исключение нет, код не защищен от таких изменений:

(defn ->fahr [cel]
  (when cel
    (+ (* cel 1.8) 32)))

В этом случае nil провалится ниже по стеку вызовов, что усложнит поиск причины.

Пример ниже с try/catch решает проблему лишь отчасти. В таком виде (is true) действительно сработает. Если же заменить (->fahr nil) на (->fahr 1), то исключения не произойдет, и ветка (catch…) будет отброшена вместе с содержимым. Это значит, что исключение, которое не выбросили там, где его ожидают, — тоже ошибка.

(deftest test-fahr-nil
  (try
    (->fahr nil)
    (catch NullPointerException e
      (is true))))

Макрос (is (thrown?...)) решает эту проблему. Форма thrown? принимает класс исключения и произвольное тело. Если в момент исполнения было брошено исключение этого класса (с учетом наследования), внешний (is…) сработает положительно. Если исключения не было, получим ошибку в утверждении:

(deftest test-fahr-nil
  (is (thrown? NullPointerException
               (->fahr nil))))

Исправьте nil на любое число и убедитесь, что тест не проходит.

Иногда одной проверки на класс недостаточно. Когда тестируют большой участок кода, NullPointerException возникает на разных уровнях программы. Например, если кто-то изменит ->fahr так, что она возвращает nil, ошибка придет из другой функции, которая принимает результат ->fahr. Это приводит к ложному тестированию: тест проходит, но на самом деле не фиксирует поведение программы.

Проблему решают двумя шагами. Первый — исправить функцию так, чтобы она бросала что-то более осмысленное, чем NPE. Например, специальное исключение IllegalArgumentException. Его дополняют сообщением о том, что пошло не так:

(defn ->fahr [cel]
  (if cel
    (+ (* cel 1.8) 32)
    (throw (new IllegalArgumentException
            "Fahrenheit temperature should be a real number"))))

Второй шаг — убедиться, что исключение пришло именно из ->fahr. Форма (is (thrown-with-msg?…)) проверяет, что текст исключения совпадает с регулярным выражением. Тест ниже покрывает все перечисленные требования:

(deftest test-fahr-nil
  (is (thrown-with-msg?
       IllegalArgumentException #"Fahrenheit temperature"
       (->fahr nil))))

Пакетная проверка

Вспомним, как выглядит test-fahr:

(deftest test-fahr
  (is (= (int (->fahr 20)) 68))
  (is (= (int (->fahr 100)) 212)))

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

Макрос are (анг. множественная форма глагола is) выполняет несколько is по шаблону. Он принимает форму связывания, шаблон выражения и произвольные аргументы. Число аргументов должно быть кратно переменным из формы связывания. На каждом шаге макрос связывает часть аргументов с переменными и выполняет шаблон в рамках is:

(deftest test-fahr
  (are [f c] (= (int (->fahr f)) c)
    20 68
    100 212))

Аргументы записывают столбиком по принципу одна строка — один is. В примере выше f и c означают цифры по Фаренгейту и Цельсию, то есть исходное и ожидаемое значения. Выражение (int (->fahr f)) переехало в шаблон, поэтому нет смысла повторять его каждый раз.

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

Предположим, мы тестируем HTTP API для создания пользователя. Один из тестов проверяет входные параметры. Мы ожидаем негативный ответ для случаев, когда нет обязательного поля или оно в неверном формате. В переменную params-ok запишем удачные параметры:

(def params-ok {:name "John Smith" :email "john@test.com"})

Чтобы проверить негативные случаи, объявим вектор params-variations. Каждый его элемент это пара: словарь и текст. Словарь это измененные параметры, которые мы добавим к params-ok функцией merge. Текст это сообщение с описанием проблемы, например, неверный адрес почты, слишком длинное имя и тд.

(def params-variations
  [[{:name nil} "Empty name"]
   [{:name (apply str (repeat 999 "A"))} "Name is too long"]
   [{:email "dunno"} "Wrong email"]
   [{:email nil} "No email"]
   [{:extra 42} "Extra field"]])

Тест ниже пробегает по params-variations. На каждом шаге он вызывает api-create-user с испорченными параметрами. Обратите внимание, что вызов обернут в форму testing с сообщением. Если на одном из шагов утверждение не сработает, мы узнаем причину по сообщению из отчета.

(deftest test-api-create-user-bad-params
  (testing "Sending bad parameters"
    (doseq [[params* description] params-variations]
      (testing description
        (let [params (merge params-ok params*)
              response (api-create-user params)
              {:keys [status]} response]
          (is (= 400 status)))))))

Когда тест разделяет данные и проверку, его легко поддерживать. Если в API появятся новые поля, достаточно расширить params-variations, не изменяя тест.

Именование

Приучите себя к правилу: имя теста всегда начинается с префикса test-. Мы упоминали, что с точки зрения Clojure это необязательно. Тестовый фремворк находит тесты по метаданным, а не имени. Поэтому выражения (defn test-if-suspended [user]) и (deftest if-suspended) останутся функцией и тестом.

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

Emacs предлагает навигацию по коду командой imenu и ее улучшенной версией helm-imenu. При вызове команды открывается буфер со списком определений. С вводом текста в буфере останутся только те элементы, имена которых содержат текст. Называйте определения так, чтобы по команде M-x helm-imenu test в списке остались только тесты.

В общем случае имя теста устроено по правилу test-<what>-<case>, где <what> означает что мы тестируем, а <case> — сценарий. Например, test-create-user-ok означает, что это положительный сценарий для создания пользователя. Имя test-create-user-bad-params говорит о попытке создать пользователя с неправильными параметрами. От теста test-user-login-signature-expired мы ожидаем, что пользователь не смог авторизоваться, потому что подпись устарела.

Фикстуры

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

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

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

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

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

Запуск и остановка системы. Иногда фикстуры управляют глобальным состоянием программы. Например, для интеграционного теста необходимы все компоненты системы (веб-сервер, база, почта и тд). Логику запуска и остановки системы логично вынести в фикстуру.

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

С точки зрения фреймворка фикстура это функция, которая принимает тест. Функция выполняет любые действия, в том числе вызывает тест. Вопрос в какой момент это сделать остается на усмотрение разработчика. Например, если фикстура готовит данные в базе, логично сперва очистить таблицы и выполнить несколько запросов:

(def db {:dbtype "postgres" :dbname "book"
         ;; other JDBC fields go here...
         })

(defn fix-db-data [t]
  ;; purge
  (jdbc/execute! db "truncate users cascade;")
  (jdbc/execute! db "truncate orders cascade;")
  ;; write
  (jdbc/insert! db :users user-data)
  (jdbc/insert! db :profile profile-data)
  ;; execute
  (t))

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

(import 'java.io.File
        'org.apache.commons.io.FileUtils)

(defn fix-clear-files [t]
  (t)
  (FileUtils/cleanDirectory (new File "/tmp/tests/reports")))

Иногда фикстура сообщает тесту и предварительные, и финальные шаги. В этом случае вызов (t) располагается где-то посередине.

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

Регистрация

Если запустить тест прямо сейчас, фикстура не сработает. Мы только объявили фикстуру, но не указали фреймворку взять ее в работу. Это делают отдельным шагом – регистрацией фикстуры. На первый взгляд это выглядит странно. Если фреймворк сам находит тесты, почему бы ему не искать фикстуры и применять их?

Причина в том, что фикстура может быть запущена на разных этапах теста. Фреймворк Clojure выделяет стадии :each и :once. Зарегистрировать фикстуру означает связать ее с одним из этих ключей. Значение :each означает, что фикстура сработает для каждого теста из текущего пространства. С ключом :once фикстура выполняется только однажды в рамках пространства.

Одна и та же фикстура может быть :each или :once в зависимости от семантики теста. Вспомним фикстуру fix-clear-files, которая очищает директорию. Если каждый тест порождает случайные имена файлов, логично очистить их единоразово в конце. Это значит, что фикстуру регистрируют с ключом :once. В случае, если имена файлов одинаковы, возрастает риск их коллизии (чтение файла из другого теста и тд). Тогда фикстуру связывают с ключом :each.

Другой пример — фикстура базы данных. На время теста она добавляет в таблицы данные и очищает их. Если точно известно, что тесты только читают данные, фикстуру регистрируют с :once. Это дает выигрыш в производительности: для тридцати тестов фикстура сработает единожды. Для тестов на запись важно, чтобы ни один из них не влиял на другой. Поэтому фикстуру регистрируют с :each. Чтобы не было коллизий, тесты на чтение и запись разносят по разным модулям.

Функция use-fixtures из пакета clojure.test принимает ключ и переменное число фикстур:

(use-fixtures :once fix-db-server fix-clear-files)
(use-fixtures :each fix-db-data)

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

Пример

Чтобы лучше понять, в какой момент срабатывает фикстура, добавим в нее побочный эффект. Функция fix-factory принимает тип фикстуры, ее номер и возвращает функцию-фикстуру. Такая фикстура обрамляет тест выводом в консоль.

(defn fix-factory [type number]
  (fn [t]
    (println (format "%s %s starts" type number))
    (t)
    (println (format "%s %s ends" type number))))

Добавим по две фикстуры каждого типа:

(use-fixtures :once
  (fix-factory :once 1)
  (fix-factory :once 2))

(use-fixtures :each
  (fix-factory :each 3)
  (fix-factory :each 4))

Если запустить тесты, увидим следующий текст. Фикстуры 1 и 2 типа :once сработали однажды на границах. Обратите внимание, что в момент завершения из порядок меняется на противоположный: сначала завершается фикстура 2 и только потом 1. В нашем модуле 4 теста, поэтому фикстуры 3 и 4 встречаются столько же раз. Чтобы отобразить процесс наглядней, мы добавили отступы в каждое сообщение. С ними становится ясна иерархия фикстур.

:once 1 starts
 :once 2 starts
  :each 3 starts   +
   :each 4 starts  | test1
   :each 4 ends    |
  :each 3 ends     +
  :each 3 starts   +
   :each 4 starts  | test2
   :each 4 ends    |
  :each 3 ends     +
  :each 3 starts   +
   :each 4 starts  | test3
   :each 4 ends    |
  :each 3 ends     +
  :each 3 starts   +
   :each 4 starts  | test4
   :each 4 ends    |
  :each 3 ends     +
 :once 2 ends
:once 1 ends

На нижнем уровне

Пытливому читателю будет интересно узнать, где лежит информация о фикстурах. Очевидно, вызов use-fixtures меняет глобальное состояние. Это не атом и не переменная, а нечто другое, с чем мы еще не работали — метаданные текущего пространства. Проверим его ключи:

(keys (meta *ns*))
(:clojure.test/each-fixtures :clojure.test/once-fixtures)

Под каждым ключом находится список функций, который мы передали в use-fixtures с :each или :once. Вызов use-fixtures заменяет в метаданных все фикстуры этого типа. Важно, что это полная замена, а не запись в конец списка. Например, чтобы отказаться от всех фикстур типа :each без перезагрузки REPL, запустите выражение:

(use-fixtures :each nil)

Другой способ – удалите эту строку и выполните весь буфер целиком. Тогда определение (ns…) сработает еще раз с новыми метаданными.

Другие языки и фреймворки выделяют еще одну стадию :session. Это когда фикстура запускается в разрезе всего прогона тестов. Можно сказать, что это фикстура-синглтон: система гарантирует, что даже для нескольких модулей она сработает один раз. В Clojure нет такой стадии. Если бы она была, это бы значило, что несколько пространств связаны друг с другом, что идеологически неверно. Начинающим на Clojure часто не хватает session-фикстур, но мы научимся обходиться без них.

Связанные переменные

Особенно выразительны фикстуры в связке с динамическими переменными. Мы рассматривали их в главе про изменяемость. Напомним, динамические переменные это формы def с тегом ^:dynamic и ушками по краям имени. Макрос binding выполняет тело в рамках нового значения этой переменной.

Форма deftest порождает функцию без аргументов; мы не можем сообщить тесту его параметры как обычной функции. А ведь тесту нужны имена файлов, сессия БД и многое другое. Чтобы пробросить эти данные, применяют binding-фикстуры. Это фикстура, которая выполняет тест в теле binding. При этом глобальная переменная связана с актуальным значением. Сослаться на переменную можно только из теста.

Предположим, мы тестируем функцию, которая принимает путь к файлу и пишет в него PNG-картинку (карту, график). Объявим фикстуру, которая на время теста свяжет переменную file с временным файлом. С точки зрения теста file будет экземпляром java.util.File. При выходе из теста он будет удален, а переменная восстановится в nil.

(defonce ^:dynamic *file* nil)

(defn with-fix-tmp-file [t]
  (let [^File tmp-file (TmpFile/createFile "....")]
    (binding [*file* tmp-file]
      (t))
    (.delete tmp-file)))

(use-fixture :each with-fix-tmp-file)

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

(import 'some.path.PngImage)

(deftest test-plot-chart-png
  (let [dataset [[...] [...] [...]]
        filepath (.getAbsPath *file*)]
    (plot-chart dataset filepath)
    (let [png (new PngImage *file*)
          width (.getWidth png)
          height (.getHeight png)])
    (is (= [640 480] [width height]))))

Для полноты картины рассмотрим случай с базой данных. Пусть это будет Cassandra, а не привычный JDBC-драйвер. Фикстура with-fix-db связывает db с текущей сессией. Поскольку установка соединения это дорогая операция, вынесем ее в разовую фикстуру:

(defonce ^:dynamic ^Session *db* nil)

(defn with-fix-db [t]
  (let [cluster ...
        session ...]
    (binding [*db* session]
      (t))
    (.close session)
    (.close cluster)))

(use-fixture :once with-fix-db)

Другая фикстура восстанавливает данные для каждого теста. Фикстуры :once запускаются раньше, чем :each. Поэтому при подготовке данных мы уже будем внутри (binding [*db* ...]) из тела with-fix-db. Это значит, фикстура fix-db-prepare-data свободно обращается к *db* как к сессии:

(defn fix-db-prepare-data [t]
  (alia/execute! *db* "truncate project.users;")
  (alia/execute! *db* "truncate project.orders;")
  (alia/execute! *db* "insert into project.users (name, email) values (%s, %s)" user-data)
  (t))

(use-fixture :each fix-db-prepare-data)

Мульти-фикстуры

Любопытный вопрос: что произойдет, если фикстура вызывает тест несколько раз? Например, объявим такую фикстуру:

(defn fix-multi [t]
  (t) (t) (t))

(use-fixtures :each fix-multi)

и запустим один тест. С точки зрения фреймворка прошло три теста, о чем написано в отчете:

book.util-test: Ran 6 assertions, in 3 test functions. 0 failures, 0 errors.

На первый взгляд в этом нет никакого смысла. Многократный прогон теста не несет пользы, а только потребляет ресурсы и время. (Если же число запусков теста на что-то влияет, это плохой тест.) Но в паре с динамическими переменными такая фикстура дает полезный эффект. Один и тот же тест можно запустить в разных окружениях.

Предположим, наша программа работает с базой данных. В системных требованиях указано, что это может быть PostgreSQL или MySQL. Мы должны убедиться, что программа действительно поддерживает обе СУБД. Чтобы не дублировать тесты под каждый тип базы, эту логику выносят в фикстуру.

Объявим JDBC-подключения к базам данных:

(def db-pg {:dbtype "postgresql" :host "..."})
(def db-mysql {:dbtype "mysql" :host "..."})

Добавим динамическую переменную *db*. Новая фикстура перебирает список подключений и связывает их с *db* на каждом шаге. Когда переменная связана, запускается тест. Он зависит от текущего подключения и таким образом обращается либо в PostgreSQL, либо в MySQL.

(defonce ^:dynamic *db* nil)

(defn fix-multi-db-backend [t]
  (doseq [db [db-pg db-mysql]]
    (binding [*db* db]
      (testing (format "Testing with DB: %s" (:dbtype *db*))
        (t)))))

Обратите внимание, что вызов (t) дополнительно обернут в форму testing. В ней мы указываем тип базы. Если произошла ошибка, нам важно знать, с какой базой мы работали в тот момент. Пример теста на чтение пользователя:

(defn test-get-user-by-id
  (let [user (com.system.orm/get-user *db* 1)]
    (is (= user {:name "Ivan"}))))

Перечислим другие сценарии для мульти-фикстур. Это может быть запуск интеграционных тестов в нескольких браузерах (Chrome, Firefox). Или мы хотим убедиться, что логика не зависит от формата передачи данных (JSON, YAML, XML). Если программа работает с изображениями, тесты прогоняют на разных типах файлов (PNG, jpeg) и разрешениях.

Минутка неадекватности

(Просим не воспринимать этот раздел слишком серьезно.) Перейдем в другую крайность: что случится, если фикстура не вызовет тест ни разу? Например:

(defn fix-mute [t])
(use-fixtures :each fix-mute)

Это приведет к тому, что ни один тест не будет выполнен. Объясним это на техническом уровне. Такая фикстура не отменяет тесты, и они по-прежнему видны тестовому фреймворку. В отчете вы увидите их список, но для каждого будет указано 0 assertions. Формально мы запустили тест, но его тело так и не сработало из-за фикстуры fix-mute. Она заглушает любой тест в текущем пространстве.

Если включить fix-mute и запустить тест из Emacs, мы увидим предупреждение:

No assertions (or no tests) were run.Did you forget to use ‘is’ in your tests?

Модуль Cider считает подозрительным, что в тесте не было ни одного is. Это справедливо: без is тест может подать сигнал об ошибке только исключением, что ведет к ряду проблем. Исключение и неверный результат это разные сигналы. Например, функция может давать верный результат, а деление на ноль происходит только в особых случаях. Если заменить деление на умножение, мы больше никогда не получим ArithmeticException, но результат будет далек от истины.

Чтобы обмануть Cider, улучшим фикстуру. Вместо запуска теста добавим в нее is, который всегда истинен. Даже если тест с ошибками, его тело не сработает, но в отчете будет как минимум 1 assertion. Назовем такую фикстуру fix-this-is-fine:

(defn fix-this-is-fine [t]
  (is true))

(use-fixtures :each fix-this-is-fine)

С ее помощью мы пройдем тест Оруэлла:

(deftest test-1984
  (is (= (* 2 2) 5)))
;; OK, ran 1 assertions

Задание читателю: напишите фикстуру, которая портит любой тест. Добейтесь, чтобы 100% тестов завершились с ошибкой. Если ваша система версий поддерживает отложенные коммиты, запланируйте такую фикстуру на 1 апреля.

Теперь вернемся к серьезному повествованию.

Фикстуры с условиями

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

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

(defn fix-mac-only [t]
  (when (= (System/getProperty "os.name") "Mac OS X")
    (t)))

Эта фикстура выполнит тест только на платформе Mac семейства X. Недостаток в том, что мы сравниваем конкретные значения в лоб. Это наивный подход. В системах Windows или Linux имена различаются даже в рамках одного семейства. Чтобы определить платформу правильно, свойство “os.name” проверяют на вхождение строки или регулярным выражением.

Доработаем фикстуру: во-первых, сделаем ее подробнее. Обернем тест в форму testing, чтобы видеть в отчете, в рамках какой ОС был выполнен тест. Если мы не поддерживаем систему, добавим заведомо ложное утверждение:

(defn fix-mac-only [t]
  (let [os (System/getProperty "os.name")]
    (if (= os "Mac OS X")
      (testing (format "OS: %s" os)
        (t))
      (testing (format "Unsupported OS: %s" os)
        (is false)))))

Каким способом лучше зарегистрировать фикстуру — :once или :each? Это зависит от характера условия. В нашем примере тип системы не меняется от теста к тесту, поэтому условие может быть выполнено лишь однажды (:once). Если же фикстура проверяет файл на диске, это условие выполняют для каждого теста.

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

Метки и селекторы

Фикстуры с условиями похожи на систему фильтров. С их помощью запускают не все тесты, а подмножество, которое подходит текущему окружению. Однако, избегайте ситуаций, когда фикстуры перегружены условиями сверх меры. Clojure предлагает особые средства для выборочного прогона. Это метки и селекторы тестов.

Метки устроены проще, чем фикстуры с условием. Это метаданные, которые вы сообщаете тесту при его описании. Например, тесту ниже

(deftest ^:special test-special-case
  (is true))

мы сообщили флаг :special со значением true. Напомним, что выражение ^:field это сокращенная форма ^{:field true}. Сокращенная форма полезна, чтобы задать несколько флагов за один раз:

(deftest ^:special ^:backend ^:no-db
  test-special-case
  (is true))

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

По такому набору тегов легко догадаться, что это тест на серверную логику (backend); он не зависит от базы данных (no-db); это какой-то особый тест (special). Семантика тегов зависит от соглашений в команде. Проверим флаги: прочитаем метаданные переменной ‘test-special-case. Среди прочих полей вы найдете special и остальные:

(meta #'test-special-case)
{:special true :backend true :no-db true ...}

Логично, что если тестам назначены теги, можно выбрать подмножество тестов. Например, только особые (special) или на серверную логику (backend). Такая выборка называется селектором тестов. Селекторы полезны по нескольким причинам.

Подмножество тестов отработает быстрее, чем полный набор. Если мы работаем над задачей и точно знаем, какие тесты она затрагивает, то нет смысла запускать все доступные тесты. Назначим тег и будем запускать только подмножество. Иногда окружение разработчика не настроено должным образом, и выполнить тесты целиком невозможно.

Даже если окружение в порядке, отдельные тесты могут занять много времени. Например, интеграционный тест длится на порядок дольше модульного и вдобавок требует инфраструктуру: очередь задач, сторонние API. Поэтому интеграционные тесты выносят в отдельный шаг Continuous Integration-процесса. В общих чертах он выглядит так:

  • запускается минимальное окружение (база данных);
  • выполняются модульные тесты (селектор :default);
  • если не было ошибок, то запускается дополнительное окружение (Kafka, заглушки API);
  • выполняются интеграционные тесты (селектор :integration).

Чем сложнее проект, тем больше в нем тестов и дополнительных шагов по их запуску.

В тестовом фреймворке Clojure нет селекторов. Он запускает либо один тест, либо пространство, либо все тесты целиком. Селекторы доступны в сторонних библиотеках и утилитах. Рассмотрим, как задать их в проекте на базе Leiningen.

Откройте файл project.clj. Внутри defproject добавьте ключ :test-selectors. Это словарь, где ключ это метка селектора, а значение — функция одного аргумента. Если при запуске тестов сообщить метку, каждый тест проходит проверку этой функцией. В нее передают метаданные теста. Если функция вернула ложь или nil, тест не попадает в набор.

Поскольку кейворд в Clojure это функция, селектором может быть сам тег. Пример ниже можно прочитать как “набор тестов :special, у которых тег :special”:

:test-selectors {:special :special
                 :backend :backend}

Чтобы запустить тесты по селектору, передайте его метку в команду test:

lein test :special

Если метка не задана, фреймворк назначит ей значение :default. Этому селектору удовлетворяет любой тест (функция всегда вернет true). Ниже пример из реального проекта. Для :default мы задали селектор, который вернет все не интеграционные тесты. Этим мы добьемся того, что команда lein test запустит только модульные тесты. На случай, когда нужно прогнать весь проект, мы завели метку :all. Функция identity вернет исходный словарь метаданных, который вырождается в true.

:test-selectors {:default (complement :integration)
                 :integration :integration
                 :all identity}

Селектор может быть и полноценной функцией, а не только ключом. Предположим, программа должна работать с разными версиями БД, чтобы обеспечить совместимость. Для каждого теста укажем минимальную версию базы. Это поле :pg/version и числовое значение. Тогда можно задать селектор так, чтобы выбрать тесты по версиям. В примере ниже тест, который проверяет экспериментальные разработки на новой версии базы.

(deftest ^{:pg/version 11}
  test-db-experimental-feature
  (is true))

С точки зрения селектора тест экспериментальный, если поле :pg/version больше или равно 11:

:test-selectors
{:db-experimental
 (fn [test-meta]
   (some-> test-meta :pg/version (>= 11)))}

Запустите их командой lein test :db-experimental.

Когда в модуле много тестов, расставлять теги утомительно. Вдобавок повышается риск забыть тег, и тест выпадет из набора. Если все тесты модуля связаны по смыслу, тег назначают не тесту, а пространству. С точки зрения lein тест наследует теги пространства, в котором он объявлен:

(ns ^:integration
  book.integration-test
  (:require [clojure.test :refer :all]))

(deftest test-user-login-ok
  ...)

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

lein test :integration

Тесты в deps.edn

Не все Clojure-проекты используют lein; некоторые перешли на deps.edn. Это утилита, разработанная в фирме Cognitect. Основная задача утилиты — управлять зависимостями проекта. С версии 1.9 deps.edn идет в поставке с Clojure.

Если проект работает на deps, мы теряем часть функциональности lein (в отдельных случаях их удается скрестить, но об этом в другой раз). В том числе это прогон тестов, команда lein test. В deps замещают возможности lein сторонними библиотеками. Проект test-runner это библиотека, которая делает то же самое: находит тесты, запускает их и выводит отчет.

Чтобы включить библиотеку в проект, добавьте в файл deps.edn новую сущность:

:aliases
{:test
 {:extra-paths ["test"]
  :extra-deps {com.cognitect/test-runner
               {:git/url "https://github.com/cognitect-labs/test-runner.git"
                :sha "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}
  :main-opts ["-m" "cognitect.test-runner"]}}

Объясним этот словарь. Ключ :aliases это примерно то же самое, что профили в lein: дополнительные опции, которые включаются, если указан этот профиль. Ключ :extra-paths это список дополнительных путей для поиска файлов с кодом. Поле :extra-deps указывает зависимости, с которыми запустится проект с этим профилем. Поскольку test-runner нужен только для тестов, нет смысла вносить его в глобальные зависимости. В примере выше библиотека скачивается прямиком с GitHub; поле :sha означает номер коммита. Это коренное отличие deps от lein: последний скачивает зависимости только из репозиториев (maven, clojars). Ключ :main-opts задает входную точку программы. Это функция -main из пространства cognitect.test-runner.

Библиотека предлагает несколько ключей для выборки тестов с логикой “только с этим тегом” (-i, include, включить ) или “без этого тега” (-e, exclude, исключить). Команда ниже выполнит все тесты кроме интеграционных:

clj -Atest -e :integration

В плане селекторов test-runner устроен проще lein. Он учитывает только логические теги и не работает со сложным функциями, как мы делали это в lein на примере версий БД. С другой стороны, test-runner умеет искать пространства по регулярному выражению (ключ -r, namespace-regex). Например, выражение #”project.api.\w+-test$” найдет тесты из модулей project.api.users-test, project.api.tasks-test и так далее.

Проблема окружения

До сих пор мы писали примитивные тесты, которые проверяют вычисления. На практике вы столкнетесь с проблемой: в обычном коде преобладают не вычисления, а ввод-вывод данных. Это обращение к сторонним ресурсам: базе данных, очереди задач, HTTP API. Такой код трудно тестировать по двум причинам.

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

Чтобы тест зависел от окружения, применяют две техники: моки и стабы. В следующих разделах мы рассмотрим их устройство, преимущества и недостатки.

Моки

Мок (анг. mock — ложный, фиктивный) — это объект, который временно заменяет другой объект. Цель такой подмены в том, чтобы при обращении к объекту сработал не исходный код, а тот, который нужен именно сейчас. Чаще всего моки накладывают на методы и функции с доступом в сеть. С их помощью проверяют, как поведет себя функция при различных сценариях: данные получены, пришел код ошибки, соединение не удалось.

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

Представим, что пишем веб-сервер для мобильного приложения. Его тематика — развлечение и отдых. На главном экране пользователь видит карту, на которую нанесены кафе, рестораны и ближайшие события: фильмы, выставки, фестивали. Сервер работает по протоколу HTTP/JSON API. Данные для главного экрана возвращает функция view-main-page. Она извлекает из запроса координаты пользователя, собирает сведения о местах общепита и событиях и отсылает мобильному приложению:

(ns book.views
  (:require [clj-http.client :as client]))

(defn view-main-page [request]
  (let [location (-> request :params (select-keys [:lat :lon]))
        sites (get-sites-by-location location)
        events (get-events-by-location location)]
    {:status 200
     :body {:sites sites :events events}}))

Проблема в том, что у сервера нет этих данных. Мы извлекаем из сторонних сервисов, например, условных Яндекс.Карт и Яндекс.Афиши. Функции get-sites… и get-events… общаются с ними по протоколу HTTP. Вот так может выглядеть функция get-sites-by-location для поиска кафе и ресторанов в радиусе 300 метров:

(defn get-sites-by-location
  [{:keys [lat lon]}]
  (-> {:method :get
       :url "https://maps.yandex.ru/search/v1/"
       :as :json
       :query-params {:apikey "....."
                      :lat lat :lon lon :distance 300
                      :type "cafe,restaurant"}}
      client/request
      :body))

Функция get-events-by-location для поиска мероприятий аналогична первой. Разница лишь в URL-адресе (не maps.yandex.ru, a events.yandex.ru) и query-параметрах.

Очевидно, view-main-page совершает два сетевых вызова, что затрудняет ее тестирование. Это значит, нам нужно два API-ключа; если запускать тесты часто, мы исчерпаем квоты на число запросов. Поскольку мы получим настоящие, живые данные, мы не можем быть уверены в их надежности (завтра в этом месте откроется новое кафе или закроется старое). Решим эту проблему моками.

В главе про изменяемость мы работали с макросом with-redefs. Он заменяет переменную по ее пути на что-то другое. Простейший мок — это подмена функции с сетевым вызовом на анонимную функцию, которая возвращает заведомо известный результат. Напомним, что получить функцию из результата можно с помощью формы constantly.

Напишем тест главного экрана с двумя моками. Данные моков удобно вынести в отдельные переменные в let. Важно, что view-main-page должна быть выполнена внутри with-redefs, иначе эффект моков теряется.

(deftest test-main-page
  (let [sites [{:name "Cafe1"} {:name "Cafe2"}]
        events [{:name "Event1"} {:name "Event2"}]]
    (with-redefs
      [book.views/get-sites-by-location (constantly sites)
       book.views/get-events-by-location (constantly events)]
      (let [request {:params {:lat 55.751244
                              :lon 37.618423}}
            result (view-main-page request)]
        (is (= (:body result)
               {:sites sites :events events}))))))

С этим подходом мы избежали похода в сеть. Тест не зависит от API-ключей, прав доступа и новых заведений. Можно запустить его в любое время дня и ночи, и результат будет одинаковым. Теперь рассмотрим, как улучшить этот тест.

Вынести данные в файл

Переменные sites и events, которые мы якобы получили от сторонних сервисов, крайне скудны. Будет правильным сообщить им данные, скопированные из реального источника, например, десять ресторанов и семь мероприятий. Чтобы не засорять код огромными массивами данных, поместите их в .json-файлы в папку с ресурсами. Считайте их один раз на уровне модуля в переменную:

(def data-events
  (-> "data/events.json"
      clojure.java.io/resource
      slurp
      (cheshire.core/parse-string true)))

В промышленном запуске функция get-sites-by-location устроена сложнее, чем в нашем примере. Скорее всего, данные от сервиса карт придется обработать согласно бизнес-логике нашего приложения. Например, отбросить заведения с низкой оценкой и те, что сейчас закрыты. Это логику можно проверить тестом: добавить в json-файл заведение с низким рейтингом и убедиться, что его нет в ответе.

Перенести мок в фикстуру

Если требуется больше одного теста на главный экран, оборачивать каждый из них в with-redefs будет утомительно. Логично вынести мок в фикстуру. Тем самым мы снизим число повторов в коде, а другие тесты смогут ее переиспользовать:

(defn fix-mock-main-view [t]
  (with-redefs [...]
    (t)))

(use-fixtures :each fix-mock-main-view)

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

Негативные сценарии

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

Чтобы проверить главный экран на устойчивость, необходимы негативные сценарии:

  • получили кафе, но не мероприятия;
  • наоборот: с кафе все в порядке, ошибка с мероприятиями;
  • не работают оба сервиса.

Кроме того, под общими словами “не работают” имеют в виду варианты:

  • проблемы доступа или квот: статусы 403 и 429;
  • недоступность сервиса: статусы 500 и 503;
  • проблемы связи: исключение по таймауту и поиску хоста.

Чтобы сократить код с with-redefs, напишем его краткую версию. Макрос with-mock принимает путь к функции, результат функции-мока и произвольное тело.

(defmacro with-mock
  [path result & body]
  `(with-redefs
     [~path (fn [& ~'_] ~result)]
     ~@body))

Важный момент: для замены path мы создаем анонимную функцию с помощью fn, а не constantly. Иначе невозможно смоделировать выброс исключения. Поскольку constantly это функция, ее аргументы вычисляются до того, как она сработает. Поэтому выражение

(constantly (throw (new Exception "error")))

бросит исключение еще на этапе связывания.

С помощью with-mock мы собираем комбинации успехов и неудач. Тест ниже проверяет сценарий, когда сервис карт сработал без ошибок, а с мероприятиями что-то пошло не так. Если запустить тест, окажется, что test-main-page не отлавливает исключения. Это значит, пользователь не увидит ничего.

(deftest test-sites-ok-events-err
  (with-mock book.views/get-sites-by-location [...]
    (with-mock book.views/get-events-by-location
      (throw (new java.net.UnknownHostException "DNS error"))
      (let [response (view-main-page {})]
        (is (:body response) {...})))))

Доработайте view-main-page так, чтобы тест увидел ответ со статусом 200 и полем :sites. Добавьте тесты с другими комбинациями: ошибка соединения в get-sites, статусы 403 и 500, недоступность сразу двух сервисов.

Сбор данных

Не заглядывая в конец главы, подумайте, как организовать мок промышленного уровня. Такой мок не только возвращает результат, но и:

  • считает, сколько раз его вызвали;
  • запоминает аргументы каждого вызова;
  • принимает список результатов и возвращает их поочередно, например, в первый и второй раз словари, а на третий раз исключение;
  • предлагает удобный доступ к этим данным.

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

Недостатки

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

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

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

Наконец, мок повышает связанность тестов с логикой. Если переименовать функцию get-sites-by-location, форма (with-redefs…) перестанет работать. Косметические исправления в коде повлекут правки в тестах, что неудобно и неверно идеологически.

Эти проблемы решают стабы, о которых речь в следующем разделе.

Стабы

Стаб (анг. stub, заглушка) — это сущность, которая заменяет объект или часть системы на время тестов. От моков она отличается тем, что тест не может ей управлять. Если в тестах с моками мы сами указываем что и чем заменить, то стаб это черный ящик с точки зрения теста.

Удачный пример стаба это локальный HTTP-сервер, имитирующий сторонний сервис, например, поиск мест на карте. Такой сервер запущен на порту 8808 и по адресу /search/v1/ возвращает JSON-данные, которые мы взяли из настоящего источника. Теперь мы не будем мокать функцию get-sites-by-location. Изменим ее так, чтобы поле :url запроса указывало не на https://maps.yandex.ru/, а на http://127.0.0.1:8808/. Если запустить тест, функция выполнит запрос к локальному серверу и прочитает его ответ.

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

Другое достоинство в том, что во время теста происходит настоящий обмен данными по сети. Да, это локальный хост без зашифрованного соединения и проверки сертификатов. Но основная часть HTTP-взаимодействия протекает так же, как и в промышленном запуске. Это разбор заголовков, чтение и парсинг JSON-данных, логика на базе статуса ответа.

Локальные сервера-заглушки еще называют фейки (анг. fake, подделка). С помощью фейка программист моделирует нештатное поведение сервера. Например, ситуацию, когда в ответе не JSON-данные, а просто текст. Или когда сервер принимает соединение, но не отвечает. Или полную недоступность сервера, когда мы выключаем его перед тестом.

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

С технической стороны стаб это фикстура. До запуска теста она включает сервер, а после выключает его. Сервер это Jetty или другой адаптер для Ring-обработчика запроса. Мы подробно рассмотрели Ring и Jetty в главе про веб-разработку. Напишем фикстуру для фейкового сервера карт.

В блок require добавим зависимости:

[ring.middleware.json :refer [wrap-json-response]]
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.keyword-params :refer [wrap-keyword-params]]
[ring.adapter.jetty :refer [run-jetty]]

Напишем обработчик запроса. Это функция, которая принимает запрос. Для пути search/v1 она возвращает JSON-данные, для всего остального ответ 404:

(defn sites-handler* [{:keys [uri]}]
  (case uri
    "/search/v1/"
    {:status 200
     :body [{:name "Cafe1" :address "..."}
            {:name "Cafe2" :address "..."}]}
    {:status 404
     :body "page not found"}))

Обернем функцию в набор middleware. Во-первых, мы хотим, чтобы тело-коллекция приводилось к JSON автоматически. Во-вторых, позже нам понадобятся параметры строки:

(def sites-handler
  (-> sites-handler*
      wrap-keyword-params
      wrap-params
      wrap-json-response))

Напишем и подключим фикстуру. Сервер не меняет состояние от теста к тесту (мы только читаем данные), поэтому фикстура срабатывает один раз (ключ :once):

(defn fix-fake-sites-server [t]
  (let [opt {:port 8808 :join? false}
        server (run-jetty sites-handler opt)]
    (t)
    (.stop server)))

(use-fixtures :once fix-fake-sites-server)

Исправим функцию get-sites-by-location так, чтобы клиент обращался к локальному хосту. Очевидно, базовый URL сервиса приходит из конфигурации. Вы уже знаете, как устроена конфигурация из предыдущих глав, поэтому весь код приводить мы не будем. Для краткости представим, что глобальная переменная config это словарь параметров. Тогда полный URL для сервиса карт формируется так:

:url (str (:maps-base-url config ) "/search/v1/")

Напишем тест главного экрана. На этот раз нам не нужны моки, поэтому тест сводится к вызову view-main-page. Прямо сейчас тест не пройдет, потому что мы решили проблему только с поиском заведений. Поиск событий по-прежнему обращается к чужому серверу. Чтобы убедиться, что стаб работает нормально, на время закомментируем вызов get-events-by-location, а поле :events сделаем nil. После этого тест сработает без ошибок: в поле :sites ответа окажутся данные, что возвращает стаб.

(deftest test-main-page
  (let [request {:params {:lat 55.751244
                          :lon 37.618423}}
        result (view-main-page request)]
    (is (= (:body result) {:sites [...] :events nil}))))

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

Как и в случае с моками, удобно, когда данные лежат в файлах. Чтобы фейковый сервер читал файл, измените ответ следующим образом:

{:status 200
 :body (-> "data/events.json"
           clojure.java.io/resource
           clojure.java.io/file)}

В файл resources/data/events.json запишите ответ реального сервера.

На текущий момент стаб всегда возвращает успешный ответ. Расширим его логику: сделаем так, чтобы при особых условиях мы получали негативные ответы. Проще всего это сделать в зависимости от входных параметров. Договоримся, что некоторые координаты, которые передает пользователь, особые. Например, для пары 0,0 сервер вернет пустой результат; при 66,66 получим ошибку доступа. С координатами 42,42 сервер отвечает с задержкой в 35 секунд, что больше стандартного ожидания в 30 секунд. Усложним sites-handler*:

(defn sites-handler* [request]
  (let [{:keys [uri params]} request
        {:keys [lat lon]} params]
    (case uri
      "/search/v1/"
      (case [lat lon]
        ["0" "0"]   {:status 200 :body []}
        ["66" "66"] {:status 403 :body {:error "ACCESS_ERROR"}}
        ["42" "42"] (do (Thread/sleep (* 1000 35))
                        {:status 200 :body []})
        {:status 200
         :body [{:name "Cafe1" :address "..."}
                {:name "Cafe2" :address "..."}]})
      {:status 404 :body "page not found"})))

Теперь напишите тесты для этих координат. Особенно интересен случай с долгим ответом (пара 42). Мы должны убедиться, что если сервис карт не отвечает, мы ждем какое-то разумное время, а не стандартные 30 секунд. Передайте в параметры (client/get) поля :socket-timeout и :connection-timeout значения 5000, что равно пяти секундам. Этого ожидания достаточно для промышленного запуска. Замерьте время выполнения view-main-page. Добавьте в тест утверждение is, что ожидание не превышает 5 секунд с небольшой погрешностью.

Можно добавить тест для экстремального случая: что произойдет, если сервер физически недоступен. Чтобы смоделировать эту ситуацию, фикстура должна предоставить доступ к переменной server. Проще всего это сделать через динамическую переменную:

(defonce ^:dynamic *server* nil)

(defn fix-fake-sites-server [t]
  (let [opt {:port 8808 :join? false}]
    (binding [*server* (run-jetty sites-handler opt)]
      (t)
      (.stop *server*))))

Напишем тест, в котором мы временно отключим сервер. Обратите внимание, что в конце его нужно включить, иначе мы испортим все следующие тесты.

(deftest test-the-website-is-down
  (.stop *server*)
  (let [request {:params {:lat 1 :lon 2}}
        result (view-main-page request)]
    (is (= (:body result) {...})))
  (.start *server*))

Сейчас тест не сработает из-за ошибки “Connection refused”. Доработайте логику так, чтобы пользователю ушел пустой результат.

Ресурсы и пути

Выше мы упомянули, что данные для моков или стабов хранят в файлах. Так мы не загромождаем код объемными списками и словарями. Файлы в папке resources называются ресурсами приложения и играют особую роль: при сборке jar-файла они становятся его частью. Код, запущенный из jar получает доступ к той его части, где располагаются ресурсы. С точки зрения системы это один файл, а изнутри JVM нам доступны все те файлы, что были в resources на этапе сборки.

В одном из примеров мы поместили файл events.json в папку resources/data и читали его как ресурс. Недостаток в том, что эти данные нужны только для тестов. Если собрать проект, events.json станет частью выходного jar-файла. Это напрасно увеличит его объем.

Чтобы отбросить лишние ресурсы при сборке, в проекте настраивают :resource-paths. Это поле-список, в котором перечислены возможные пути поиска ресурсов. По умолчанию оно равно вектору [“resources”]. При объединении профилей вектора соединяются в один. Если другой профиль задал ресурсы иначе:

:resource-paths ["resources_test"]

то в итоге программа будет искать их в папках resources и resources_test. Во время финальной сборки оставим только resources, чтобы не вбирать в jar ресурсы для тестов.

Заметим, что путь resources_test выбран неудачно. Существует другой, более удобный способ связать файловую систему и окружение. Это так называемая env-директория, о которой мы говорили в главе про изменяемость. На ее первом уровне находятся папки с именами профилей, а внутри src и resources с кодом и ресурсами. Эти пути задают профилям в описании проекта.

Создайте нужные пути и переместите данные для теста:

mkdir -p env/test/resources/data
mv resources/data/events.json env/test/resources/data/
rm -rf resources/data

В настройках проекта задайте профили и их пути к ресурсам:

:profiles {:test {:resource-paths ["env/test/resources"]}
           :dev  {:resource-paths ["env/test/resources"]}}

На первый взгляд странно, что для :dev и :test мы указали один и тот же путь. Это потому, что по умолчанию сеанс REPL запускается с профилем dev, но не test. В режиме разработки у нас не будет доступа к ресурсам из env/test. Это влечет неудобства: например, мы исправили код и тут же вызвали тест из REPL, но он не проходит из-за неверных путей. Проблему решают двумя способами.

Первый — запускают REPL с ключом with-profile +test, что значить добавить к профилю по умолчанию test. Знак плюса означает выполнить слияние; без него стандартный профиль будет отброшен. Второй способ, который мы уже рассмотрели — продублировать пути из test в dev.

В обоих случаях выражение

(def data-events
  (-> "data/events.json"
      clojure.java.io/resource
      slurp
      (cheshire.core/parse-string true)))

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

Особо любознательным предлагаем эксперимент. Скомпилируйте uberjar с разными ресурсами: в первый раз как обычно, а во второй опцией :resource-paths:

:profiles {:uberjar {:resource-paths ["env/test/resources"]}

Перед второй сборкой скопируйте файл target/uberjar/.jar в другую папку, чтобы не затереть его. Распакуйте оба архива командой jar:

jar xf <project>.jar

Среди файлов из второго архива вы найдете data/events.json. Убедитесь, что его нет в первом случае. Очевидно, если вы распространяете программу в виде jar-архива, любой желающий может распаковать его. Поэтому в ресурсах не должно быть приватных данных, например, конфигурации с паролями и ключами. Это касается и данных для тестирования — включать их в jar будет ошибкой.

Пользуясь случаем, исследуйте другие файлы, которые вы получили из jar, их структуру и содержимое.

База данных

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

Если каждый тест создает данные под себя, у вас возникнут проблемы с поддержкой таких тестов. Очевидно, требуется единая точка входа, такая, чтобы каждый тест имел доступ к одинаковым данным. В идеале они заданы декларативно в файле. Чтобы добавить новые записи, мы должны исправить проект только в одном месте. Когда тесты закончились, все, что мы записали в базу, бесследно исчезает. Недопустимо, чтобы один тест зависел от изменений, выполненных в другом тесте.

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

Физически базу создают еще до того, как запустились тесты. Способ зависит от того, как работает база: локально или в Докере. Если локально, достаточно вызвать несколько утилит. В примере ниже createuser и createdb входят в поставку PostgreSQL.

createuser book_test -S -W
createdb -O book_test book_test

Первая команда создает нового пользователя БД с именем book_test. Ключ -S означает, что это суперпользователь, т.е. обладатель высших привилегий. Супердоступ необходим, чтобы подключать расширения: триграммный поиск, прогрев индексов и другие. В промышленном запуске, наоборот, приложению выделяют минимальные права, но для тестов это неважно. Вторая команда создает одноименную пустую базу. Ключ -O (owner) задает владельца базы. Владелец имеет полный доступ ко всем ее сущностям.

Если база работает в Докере, обратитесь к главе про системы. В одноименном разделе мы рассмотрели, как задать параметры базы в .yaml-файле и переменными среды.

Пустую базу наполняют таблицами. Если приложение поддерживает миграции, например, с помощью библиотек migratus или ragtime, их запускают до тестов из командной строки:

lein migratus migrate
lein test

Шага с миграциями можно избежать, если скопировать их в папку Докер-образа /docker-entrypoint-initdb.d. Специальный скрипт выполнит все sql-файлы из этой папки на старте образа. Убедитесь, что скопировали только up-миграции, иначе их down-версии сведут пользу на нет.

К этому моменту в базе уже созданы таблицы и индексы, но нет данных. Мы переходим к основной задаче: перечислим способы их вставки и очистки во время тестов.

Ручная вставка

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

(def db-data
  [[:users {:name "Ivan" :email "ivan@test.com"}]
   [:users {:name "Juan" :email "Juan@test.com"}]
   [:groups {:name "Dog fans" :topics 6}]
   [:groups {:name "Cat fans" :topics 7}]])

Здесь и ниже мы полагаем, что глобальная переменная *db* указывает на JDBC-спеку. Мы не будем задаваться вопросом, откуда пришла эта переменная. Читатель уже знаком с системами и конфигурацией и вправе задать *db* так, как это удобно в проекте.

(defn load-data []
  (doseq [[table row] db-data]
    (jdbc/insert! *db* table row)))

(defn with-db-data [t]
  (load-data) (t))

Заметим, что для каждой записи нужно знать ее таблицу. Это может быть элемент пары или поле метаданных. Когда записей одного типа много, имя таблицы становится избыточным. Логично сгруппировать записи по таблицам и вставлять их не по одной, а разом, что быстрее.

(def db-data
  [[:users [{:name "Ivan" :email "ivan@test.com"}
            {:name "Juan" :email "Juan@test.com"}]]
   [:groups [{:name "Dog fans" :topics 6}
             {:name "Cat fans" :topics 7}]]])

(defn load-data []
  (doseq [[table rows] db-data]
    (jdbc/insert-multi! *db* table rows)))

Вставка из CSV

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

У CSV несколько преимуществ. Записи в нем это массивы, а не словари, поэтому они не дублируют имена полей. В целом CSV компактней JSON и аналогов. С ним работают табличные редакторы, например Excel и OpenOffice. Это значит, всегда можно открыть CSV как лист ячеек, добавить или удалить столбец, пересчитать ячейки по формуле и сохранить результат.

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

Предположим, нам скинули данные о пользователях с прода в CSV-файле. Вот несколько его первых строк:

name,email
ivan,ivan@test.me
juan,juan@example.com
ioan,ioan@dunno.org

Данные для тестов это ресурс, сохраним его по адресу env/test/resources/data/users.csv, как делали это раньше. Есть два способа поместить данные из него в таблицу: запросом и кодом. Рассмотрим оба из них.

Вариант с запросом: составим SQL-выражение с командой COPY. Нужно указать таблицу, путь к файлу с данными и его свойства. Под свойствами понимают тип разделителя (это может быть не только запятая, но и точка с запятой или табуляция), формат файла и признак заголовка. Последний флаг означает, что первую строку следует пропустить.

(jdbc/execute! *db*
  "
  COPY users(name, email)
  FROM '/Users/ivan/work/book/env/test/resources/data/users.csv'
  DELIMITER ',' CSV HEADER
  ")

Заметим, что запрос ожидает полный путь к файлу, что мешает командной работе. Вероятность того, что коллеги держат проект в папке /Users/ivan/work/book равна нулю. Доработайте запрос так, чтобы вместо локального пути подставлялась текущая рабочая директория.

Выполним то же самое средствами Clojure. Усложним задачу тем, что в файле users.csv очень много записей, и потому он сжат архиватором gzip. Это хорошая практика: все большие файлы в репозитории должны быть сжаты. Чтобы читать такой файл на лету (т.е. без выгрузки во временную папку), понадобятся импорты:

(:import java.io.FileInputStream
         java.util.zip.GZIPInputStream
         org.postgresql.copy.CopyManager)

Класс CopyManager делает то же, что и команда COPY. Чтобы создать его экземпляр, требуется соединение с базой (именно TCP-соединение, а не словарь спеки). Получим его функцией jdbc/get-connection. Метод copyIn принимает SQL-запрос и стрим с данными CSV. Этот стрим мы получим из ресурса data/users.csv.gz, пропустив его через серию функций и классов. Последний GZIPInputStream занимается тем, что по мере чтения приводит сжатые данные к их нормальному виду. В конце работы мы закрываем соединение с базой.

(defn load-data-gz []
  (let [conn (jdbc/get-connection *db*)
        copy (CopyManager. conn)
        stream (-> "data/users.csv.gz"
                   clojure.java.io/resource
                   clojure.java.io/file
                   FileInputStream.
                   GZIPInputStream.)]
    (.copyIn copy "COPY users(name, email)
                   FROM STDIN (FORMAT CSV, HEADER true)"
             stream)
    (.close conn)))

Нельзя загрузить CSV-файл сразу в несколько таблиц. Соблюдайте правило “один файл — одна таблица”. Если файлов несколько, перепишите функцию так, чтобы она принимала путь к файлу и имя таблицы. Помните, что импорт в разные таблицы может протекать параллельно с помощью футур и pmap.

Проблема ключей

Редко бывает так, что таблицы существуют сами по себе. Чаще всего они логически связаны: пользователь ссылается на профиль, заказ на пользователя и так далее. В классических базах для этого служат первичный и внешний ключи. Первичный ключ (PK, Primary key) это поле, которое однозначно определяет запись в таблице. Внешний ключ (FK, Foreign key) это другое поле, которое ссылается на один из PK из другой таблицы. Все вместе это называют отношением таблиц.

Как правило, первичные ключи это целые числа с автоматической нумерацией. Чтобы добавить запись в таблицу, не нужно подбирать новый PK. Если ключ с автонумерацией, база данных ведет для него уникальный счетчик. Для очередной записи мы получим новый PK, увеличив счетчик на единицу. Имя счетчика строится по шаблону <table>_<pk>_seq, например, users_id_seq.

При вставке связанных данных мы должны убедиться, что их PK и FK равны. Проще всего задать им одинаковые значения, например, 3. Тогда в примере ниже пользователь действительно ссылается на группу:

(jdbc/insert! *db* :groups {:id 3 :name "Clojure fans"})
(jdbc/insert! *db* :users {:group_id 3 :name "Ivan"})

Проблема в том, что случайно взятый PK может вступить в конфликт со счетчиком. Предположим, что в момент вставки groups ее счетчик был равен нулю. Поскольку мы задали id явно, счетчик не увеличился. Но в одном из тестов мы создали еще несколько групп, чтобы проверить какой-то сценарий (например, максимальное число групп, в которых можно состоять без платной подписки). С первыми двумя группами проблем не возникнет: для них база вычислит ключи 1 и 2 из счетчика. Для третьей группы получим ошибку:

ERROR:  duplicate key value violates unique constraint 'groups_pkey'
DETAIL:  Key (id)=(3) already exists

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

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

(def id-user-admin 1)

(def db-data
  [[:users [{:id id-user-admin :name "Ivan"}]]
   [:profiles [{:id 1 :user_id id-user-admin :avatar "..."}]
    :posts [{:id 1 :user_id id-user-admin :title "New book"}
            {:id 2 :user_id id-user-admin :title "Some post"}]]])

После вставки данных поправьте счетчики так, чтобы они перескочили значения, которые мы использовали. Предположим, если в тестовый набор входит семь пользователей, и мы назначили им ключи от 1 до 7. Тогда установка счетчика в значение 100 решит проблему. Все дальнейшие записи в таблице users получат ключи 100, 101 и так далее.

Чтобы сбросить счетчик, выполните запрос ниже:

(jdbc/execute! *db* "ALTER SEQUENCE users_id_seq RESTART WITH 100")

Если все первичные ключи называются id (что рекомендуется), достаточно знать только имя таблицы. Их даже не обязательно перечислять: таблицы указаны в db-data. Добавьте в конец функции load-data код:

(let [value 100
      tables (set (map first db-data))
      query "ALTER SEQUENCE %s_id_seq RESTART WITH %s"]
  (doseq [table tables]
    (jdbc/execute! *db* (format query (name table) value))))

Он подхватит все таблицы, указанные в db-data и выставит счетчик в точное значение. На больших данных, например, из CSV, цифру 100 придется увеличить на порядок или два. Технически мы можем отличить исходные записи от добавленных в процессе, если это потребуется (id < 100 и наоборот).

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

Для начала удалим первичные ключи из тестовых данных. Ни в одно словаре не должно быть поля :id с конкретным значением. Сущностям, на которые нужно ссылаться, присвоим псевдонимы. Проще всего это сделать через метаданные (поле :entity, :alias и т.д). В связных сущностях в поле-ссылке укажем псевдоним.

;; user
^{:entity :users/admin} {:name "Ivan" :email "ivan@test.com"}
;; profile
{:user_id :users/admin :avatar "/images/ivan.png"}
;; posts
{:user_id :users/admin :title "New book"}
{:user_id :users/admin :title "Some post"}

Загрузчик перебирает записи и делает следующие проверки. Если у записи псевдоним, мы связываем его с ключом, который вернула база. На старте загрузчик объявляет атом с пустым словарем и наполняет его в процессе, например, {:users/admin 42}. Ключ мы получим из ответа функции jdbc/insert!. В зависимости от типа базы ответ будет разным:

(jdbc/insert! *db* :users {:name "Ivan"})
({:id 42 :name "Ivan}) ;; for PostgreSQL
({:generated_key 42})  ;; for MariaDB

Перед вставкой загрузчик проверяет поля записи. Если это ключ, например, {:user_id :users/admin}, мы считаем, что это ссылка. Заменяем ссылку на значение из атома по ключу. Если в атоме нет такого ключа, кидаем исключение. Предлагаем читателю написать такой загрузчик самостоятельно.

Удаление данных

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

Наивный способ очистить базу — послать запрос DELETE FROM <table> для всех таблиц, с которыми мы работаем. Проблема DELETE в том, что он учитывает зависимость ключей. Например, если запись из другой таблицы ссылается на users, удалить пользователей не получится. Придется вызвать DELETE в правильном порядке: сначала для связной таблицы, затем users. Чем больше у вас таблиц, тем сложнее будет заполнить порядок.

Такое поведение не относится к недостаткам: наоборот, без контроля за ссылками база станет неконсистентной. Параметры контроля можно задать в объявлении внешнего ключа (см. справку по foreign key). Но вообще, нас интересует как очистить данные независимо от того, как объявлены ссылки.

Команда TRUNCATE (анг. “усечь, подрезать”) нужна для быстрой очистки таблицы. В отличии от DELETE она не вызывает триггеры удаления и не сканирует всю таблицу. TRUNCATE принимает несколько несколько таблиц за раз. Еще одно ее преимущество в каскадном режиме. Если передан флаг CASCADE, все таблицы, которые так или иначе входят в граф связей, тоже очищаются. На практике выходит, что каскадная очистка пары таблиц вызывает цепную реакцию по всей базе.

Напишем функцию очистки. Она посылает запрос, в котором через запятую перечислены все таблицы из данных для тестов. Добавьте ее в фикстуру with-db-data сразу после вызова (t).

(defn delete-data []
  (let [tables (set (map first db-data))
        tables-comma (clojure.string/join "," (map name tables))
        query (format "TRUNCATE %s CASCADE" tables-comma)]
    (jdbc/execute! *db* query)))

Продолжение следует