How to Use Test Mocks and Fixtures In Clojure

How to Use Test Mocks and Fixtures In Clojure

After we've learned unit testing, at some point, the complexity of the software grows enough for us to reach out to mock functions and set up test fixtures. Mocks are often needed when you have external services that are unavailable under your control during the testing, or they are not practical to run locally for the tests. One example could be an authentication service that returns a user profile.

This post is a follow-up to an earlier post where we set up a project for testing. Follow up on the project setup from here if you haven't already done so. Once again, this post is not about what you should test but to show you the bare minimum of creating mocks and fixtures to get you started.

We'll cover some simple examples to get you started with-redefs to mock functions and variables and use-fixtures to configure test fixtures. Let's start with the first one.

Mocking Functions and Variables

Clojure has a core feature macro with-redefs that can temporarily override a var (function or variable). Within the with-redefs closure, the program will interpret the var as we've defined locally, and this is useful when mocking some functions or variables in tests.

(with-redefs [var-to-override mock]
    ;; var-to-override is replaced with the mock
    )
;; var-to-override is interpreted normally again

Let's see how to use this in a test setup by mocking the multiply function from the previous post as an example. Let's return the operation as a string instead of computing the value.

(require '[clojure.test :as t])

(defn multiply-mock [a b]
  (str a " * "  b " = ?"))

(t/deftest using-with-redefs
  (t/testing "we can override multiply implementation"
    (with-redefs [sut/multiply multiply-mock]
      (t/is (= "2 * 2 = ?" (sut/multiply 2 2))))))

The same approach works with other namespaces, not just with functions we have defined. For example, we could temporarily change the implementation of clojure.edn/read-string.

(defn read-string-mock []
  "are you trying to read EDN?")

(t/deftest mock-edn
  (t/testing "we can override multiply implementation"
    (with-redefs [clojure.edn/read-string read-string-mock]
      (t/is (= {:a 1} (clojure.edn/read-string "{:a 1}"))))))
Fail in mock-edn
we can override multiply implementation

expected: {:a 1}
  actual: "are you trying to read EDN?"

In a real-world scenario, we might want to replace a function in a test that calls to an external service that we don't have a test environment or a process that takes time to complete. And that's pretty much what we need to know to get started with mocking using with-redefs. Let's take a look at setting up fixtures next.

Test Fixtures

What are text fixtures? Wikipedia defines them as follows.

A test fixture is a device used to consistently test some item, device, or piece of software. Test fixtures are used in the testing of electronics, software and physical devices.

In software, this usually means setting up an API, a database, or both so that the tests have a consistent environment in which to run.

Fixtures allow you to run code before and after tests, to set up the context in which tests should be run.

ClojureDocs

In Clojure, a fixture is just a function that we "connect" to the test execution with clojure.test/use-fixtures macro. We can wrap setup and teardown logic in this fixture function.

(defn logger-fixture [test-fn]
  (prn "start test")
  (test-fn)
  (prn "end test"))

; run once per namespace
(t/use-fixtures :once logger-fixture)
; run once pre deftest, with-test
(t/use-fixtures :each logger-fixture)

Let's see how this works with an HTTP API.

We'll continue the setup from the last post and create a new namespace api for the API code to start testing with the tools we just learned.

(ns api
  (:require [ring.adapter.jetty :as jetty]))

(def api-state (atom {}))

(defn read-database []
  @api-state)

(defn ring-handler [{:keys [method] :as req}]
  {:status  200
   :headers {"Content-Type" "application/edn"}
   :body    (str (read-database))})

(defn start! [opts]
  (jetty/run-jetty #'ring-handler opts))

To test the API over HTTP, we need an HTTP client. Add Hato to your deps.edn as the client, we are ready to start testing. Let's write the first test without setting up a fixture.

(ns api-test
  (:require [clojure.test :as t]
            [api :as sut]
            [hato.client :as http]))

(t/deftest test-api
  (t/testing "API works"
    (t/is (= 200 (:status (http/get "localhost:8080"))))))

If we run the test before setting up the fixture, we should get an error since we are trying to call an HTTP server that is not running.

Error in test-api
API works

expected: (= 200 (:status (http/get "http://localhost:8080")))
   error: java.net.ConnectException

To fix the problem, let's create a fixture around the tests that ensures the server is running and ready to serve requests when we run our tests.

(ns api-test
  (:require [clojure.test :as t]
            [api :as sut]
            [hato.client :as http]))

(defn api-fixture [test-fn]
  (let [;; store the handle to the server to be able
        ;; to stop the server after the test is run
        server (atom nil)]
    ;; setup test fixture by starting the server
    (reset! server (sut/start! {:port 8080 :join? false}))

    ;; run the test 
    (test-fn)

    ;; teardown test fixture by stopping the server
    (.stop @server)))

(t/use-fixtures :once api-fixture)

(t/deftest test-api
  (t/testing "API works"
    (t/is (= 200 (:status (http/get "localhost:8080"))))))

And now we should have a passing test!

Tested 1 namespaces in 61 ms
Ran 1 assertions, in 1 test functions
1 passed
cider-test-fail-fast: t

And that's it. Now, we have a working API fixture. The same logic applies to databases and other services.

Combine Both Mocks and Fixtures

We can mix and match these tools to meet our needs. Let's continue on the API tests by validating that the API returns the expected value, which is an empty map.

(t/deftest test-api
  (t/testing "API works"
    (t/is (= 200 (:status (http/get "http://localhost:8080")))))

  (t/testing "API state is initalized as an empty map"
    (let [response (http/get "http://localhost:8080"
                             {:as :clojure})]
      (t/is (= {} (get response :body ))))))
Tested 1 namespaces in 13 ms
Ran 2 assertions, in 1 test functions
2 passed
cider-test-fail-fast: t

Let's say we wanted to replace the "database state." We could mock the value by using with-redefs by referring to it with sut/api-state.

(t/testing "API response is altered by with-redefs"
    (with-redefs [sut/api-state (atom {:new "state"})]
      (let [response (http/get "http://localhost:8080"
                               {:as :clojure})]
        (t/is (= {} (get response :body))))))

This time, running the test, we can see that the returning value is indeed the one defined in the with-redefs closure.

api-test
1 non-passing tests:

Fail in test-api
API response is altered by with-redefs

expected: {}
  actual: {:new "state"}          

    diff: - nil          
          + {:new "state"}

Or, in case we wanted to mock the function reading the database, we could do the same for the read-database function.

(t/testing "read-database handler is mocked by with-redefs"
    (with-redefs [sut/read-database (constantly {:mocked "value"})]
      (let [response (http/get api-url {:as :clojure})]
        (t/is (= {} (get response :body ))))))

This time, we've successfully mocked the function that returns the state.

Fail in test-api
read-database handler is mocked by with-redefs

expected: {}            
  actual: {:mocked "value"}          

    diff: - nil          
          + {:mocked "value"}

Conclusion

Testing in Clojure is simple and relatively painless. This is because the language provides the essential tools without the need to write boilerplate code to set up mocks and fixtures. The examples here are just toy examples. Still, they show how these tools work and how to get started.

I hope you found this helpful, and thank you for reading.

Feel free to reach out and let me know what you think—social links in the menu.