Компоненты с отслеживанием состояния в Clojure: Часть 3 по тестированию

Автор Майкл Гааре

Ранее в Части 1 и Части 2 мы рассмотрели некоторые проблемы с использованием глобальных объектов для компонентов с отслеживанием состояния в Clojure, а также подход к решению этой проблемы с использованием локального состояния с ограниченной областью видимости, управляемого с помощью Библиотеки компонентов Стюарта Сьерры.

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

Итак, давайте посмотрим на тестирование этих вещей.

Тест простого веб-обработчика

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

(deftest homepage-handler-test
  (with-bindings {app.db/*db-config* test-config}
    (is (re-find #"Hits: [0-9]." (homepage-handler {})))))

Мы переписали наше соединение с базой данных как запись в стиле Component, реализовав протокол DBQuery, например:

(defprotocol DBQuery
  (select [db query])
  (connection [db]))
(defrecord Database
    [config conn]
  component/Lifecycle
  (start [this] ...)
  (stop [this] ...)
  DBQuery
  (select [_ query]
    (jdbc/query conn query))
  (connection [_]
    conn))
(defn database
  "Returns Database component from config, which is either a
   connection string or JDBC connection map."
  [config]
  (assert (or (string? config) (map? config)))
  (->Database config nil))

Мы также переписали наше веб-приложение как компонент, в который вводится запись базы данных, например:

(defrecord WebApp
    [database])
(defn web-app
  []
  (->WebApp nil))
(defn web-handler
  [web-app]
  (GET "/" req (homepage-handler web-app req)))

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

(def test-db (atom nil))
(defn with-test-db
  [f]
  (reset! test-db (component/start (database test-config)))
  (f)
  (component/stop @test-db))
(use-fixtures :once with-test-db)
(deftest homepage-handler-test
  (let [test-web-app (assoc (web-app) :database @test-db)]
    (is (re-find #"Hits: [0-9].") (homepage-handler test-web-app {}))))

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

(defrecord MockDatabase
    [select-fn]
  DBQuery
  (select [_ query]
    (select-fn query))
  (connection [_]
    nil))
(defn mock-database
  "Returns a mock database component that calls select-fn with the
   passed in query when DBQuery/select is called."
  [select-fn]
  (->MockDatabase select-fn))
(deftest homepage-handler-test
  (let [mock-db (mock-database (constantly {:hits 10}))
        test-web-app (assoc (web-app) :database mock-db)]
    (is (re-find #"Hits: 10" (homepage-handler test-web-app {})))))

Тестирование рабочих изображений

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

(deftest image-worker-test
  (let [... insert some tasks into the test db ...
        test-image-worker (component/start
                           (assoc (image-worker {:workers 2})
                                  :database @test-db))]
    (Thread/sleep 2000)
    (is (... check if the test-db's tasks were completed ... ))
    (component/stop test-image-worker)))

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

(defprotocol TaskQueue
  (next-task [this]
    "Returns the next task in the queue, or nil if no task is in the queue.")
  (complete-task [this task]
    "Sets task as complete."))
(defrecord DBTaskQueue
    [config db]
  TaskQueue
  (next-task [this]
    (database/select db ...))
  (complete-task [this task]
    (let [conn (database/connection db)]
      (jdbc/with-transaction ...))))

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

(defrecord MockTaskQueue
    [tasks]
  TaskQueue
  (next-task [this]
    (let [[nxt & rst :as ts] @tasks]
      (if (compare-and-set! tasks ts rst)
        nxt
        (recur))))
  (complete-task [this task]
    true))
(defn mock-task-queue
  "Takes collection of tasks, returns mock task queue."
  [tasks]
  (->MockTaskQueue (atom tasks)))
(deftest image-worker-test
  (let [tq (mock-task-queue [test-task1 test-task2])
        test-image-worker (component/start (assoc (image-worker {:workers 2})
                                                  :database tq))]
    (Thread/sleep 2000)
    (is (... check if the test task images were processed ...))
    (component/stop test-image-worker)))

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

Тестовые системы

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

В Части 2 мы построили систему, которая включает веб-сервер, веб-обработчики и базу данных. Давайте посмотрим на определение общесистемного теста.

(def test-system
  (component/system-map
   :db (database test-db-config)
   :web-app (component/using (web-app) {:database :db})
   :web-server (component/using (immutant-server {:port 8990})
                                [:web-app])))
(deftest system-test
  (let [started-system (component/start test-system)
        test-response (http/get "http://localhost:8990/")]
    (is (re-find #"Hits: [0-9].") (:body test-response))
    (component/stop started-system))

Преимущества

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

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

Резюме

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

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

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

Оригинальная статья в Инженерных лестницах