Generative Testing in Clojure
Since I've been learning Clojure for the past few weeks, I decided to try to use clojure.spec
library to generate tests. With spec, I essentially just need to define the shape of my data, and with this meta-information the library will dynamically generate all the tests.
Below I have a pure function (no side effects) that takes exactly three arguments: the account-id (Cloudflare account ID), the gateway-id (Cloudflare AI gateway ID), and the AI provider. The goal of this function is to generate the Cloudflare gateway URL for the given provider. It's a straightforward function that just concatenates a few values together.
(ns experiments.specs)
(defn build-gateway-url
"Builds a Cloudflare AI Gateway URL for the specified provider.
Used to route API requests through Cloudflare gateway
which provides caching, rate limiting, analytics and evals."
[account-id gateway-id provider]
(let [base-url (str "https://gateway.ai.cloudflare.com/v1/" account-id "/" gateway-id)
suffix (case provider
"google" "/google-ai-studio/v1beta"
"anthropic" "/anthropic"
"mistral" "/mistral"
"openai" "/openai"
(throw (ex-info "invalid provider" {:provider provider})))]
(str base-url suffix)))
If I were to write unit tests for this function, I would probably write something like this:
(ns experiments.specs-test
(:require [clojure.test :refer [deftest testing is]]
[experiments.specs :refer [build-gateway-url]]))
(deftest build-gateway-url-test
(let [account-id "fd2a389677248c3d839524df77a9e73c"
gateway-id "gw-456"
base (str "https://gateway.ai.cloudflare.com/v1/" account-id "/" gateway-id)]
(testing "builds correct URLs for each provider"
(is (= (str base "/google-ai-studio/v1beta")
(build-gateway-url account-id gateway-id "google")))
(is (= (str base "/anthropic")
(build-gateway-url account-id gateway-id "anthropic")))
(is (= (str base "/mistral")
(build-gateway-url account-id gateway-id "mistral")))
(is (= (str base "/openai")
(build-gateway-url account-id gateway-id "openai"))))
(testing "throws exception for invalid provider"
(is (thrown? clojure.lang.ExceptionInfo
(build-gateway-url account-id gateway-id "invalid")))
(try
(build-gateway-url account-id gateway-id "aws")
(catch clojure.lang.ExceptionInfo e
(is (= "invalid provider" (ex-message e)))
(is (= {:provider "aws"} (ex-data e))))))))
There's nothing special or wrong with the test above, but it's not particularly fun to write either.
Enter clojure.spec
Writing the specs for the function:
(ns experiments.specs
(:require [clojure.spec.alpha :as s]
[clojure.spec.gen.alpha :as gen]
[clojure.spec.test.alpha :as st]))
(s/def ::non-empty-string (s/and string? #(not (clojure.string/blank? %))))
(s/def ::provider #{"google" "anthropic" "mistral" "openai"})
(s/def ::account-id
(s/with-gen
(s/and string? #(= 32 (count %)))
#(gen/return
(apply str (repeatedly 32 (fn [] (rand-nth "0123456789abcdef")))))))
(s/def ::gateway-id ::non-empty-string)
(s/def ::url-string (s/and ::non-empty-string
#(clojure.string/starts-with? % "https://gateway.ai.cloudflare.com/v1")))
(s/fdef build-gateway-url
:args (s/cat :account-id ::account-id
:gateway-id ::gateway-id
:provider ::provider)
:ret ::url-string
:fn (fn [{:keys [args ret]}]
(let [args-vals (vals args)]
(every? #(clojure.string/includes? ret %) args-vals))))
Testing the Function Using Spec
Now writing the test becomes trivial. I just need to use the function clojure.spec.test.alpha/check
(abbreviated as st/check
below), Clojure spec will then generate all the possible combination of inputs for my function.
(ns experiments.specs-test
(:require [clojure.test :refer [deftest is testing]]
[clojure.spec.test.alpha :as st]
[experiments.specs :as specs]))
(deftest generative-build-gateway-url-test
(testing "build-gateway-url with generative tests"
(let [results (st/check `specs/build-gateway-url {:clojure.spec.test.check/opts {:num-tests 100}})]
(is (every? #(-> % :clojure.spec.test.check/ret :pass?) results)))))
The test above will run 100 scenarios using different combinations of parameters to cover many different cases.
Adding a New Requirement
Now when I have a new requirement, for example, adding support for the new provider “xai”. I just need to add the new provider to the spec:
(s/def ::provider #{"google" "anthropic" "mistral" "openai" "xai"}) ;; updated spec
When we run the tests now, The output will be something like the following:
Test Summary
experiments.specs-test 74 ms
generative-build-gateway-url-test 74 ms
Tested 1 namespaces in 74 ms
Ran 1 assertions, in 1 test functions
1 failures
cider-test-fail-fast: t
Results
experiments.specs-test
1 non-passing tests:
Fail in generative-build-gateway-url-test
build-gateway-url with generative tests
expected: (every?
(fn* [p1__22116#] (-> p1__22116# :clojure.spec.test.check/ret :pass?))
results)
actual: (not
(every?
#function[experiments.specs-test/fn--22117/fn--22118]
({:failure #error {
:cause "invalid provider" -> the error.
:data {:provider "xai"} -> the value not handled by the function.
:via
[{:type clojure.lang.ExceptionInfo
:message "invalid provider"
:data {:provider "xai"}
:at [experiments.specs$build_gateway_url invokeStatic "specs.clj" 41]}]
:trace
...
The message indicates that the error was caused by an invalid provider “xai”, which triggered an “invalid provider” error. With this information, I just need to update the function and run the tests again:
(ns experiments.specs)
(defn build-gateway-url
"Builds a Cloudflare AI Gateway URL for the specified provider.
Used to route API requests through Cloudflare gateway
which provides caching, rate limiting, analytics and evals."
[account-id gateway-id provider]
(let [base-url (str "https://gateway.ai.cloudflare.com/v1/" account-id "/" gateway-id)
suffix (case provider
"google" "/google-ai-studio/v1beta"
"anthropic" "/anthropic"
"mistral" "/mistral"
"openai" "/openai"
"xai" "/xai" ;; New provider
(throw (ex-info "invalid provider" {:provider provider})))]
(str base-url suffix)))
Running the tests again, we get:
Test Summary
experiments.specs-test 8 ms
Tested 1 namespaces in 8 ms
Ran 1 assertions, in 1 test functions
1 passed
cider-test-fail-fast: t
Neat!
What I Don't Like
Sometimes the error messages are hard to read. This seems to be a common complaint in the community, and there are community projects trying to address this issue, such as Expound.
How Should I Use Specs?
It seems that specs are intended to be used in critical parts of the application and at the boundaries of the system where validation is needed.
I think they also make a lot of sense for testing purposes, especially when you want to thoroughly test functions with many possible input combinations without manually writing all the test cases.