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.
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.
Subscribe to my newsletter
Read articles from Toni Väisänen directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Toni Väisänen
Toni Väisänen
Software engineer @ Metosin Ltd Need help with a project, contact: first.last@metosin.com As a 𝐜𝐨𝐧𝐬𝐮𝐥𝐭𝐚𝐧𝐭, I help clients find technical solutions to their business problems and facilitate communication between the stakeholders and the technical team. As a 𝐟𝐮𝐥𝐥-𝐬𝐭𝐚𝐜𝐤 𝐝𝐞𝐯𝐞𝐥𝐨𝐩𝐞𝐫, I build technical solutions for client's problems from user interfaces, and backend services to infrastructure-as-code solutions. As a 𝐦𝐚𝐜𝐡𝐢𝐧𝐞 𝐥𝐞𝐚𝐫𝐧𝐢𝐧𝐠 𝐞𝐧𝐠𝐢𝐧𝐞𝐞𝐫, I create, validate and deploy predictive models.