I came across Generative Testing in Clojure with spec
notion and would like to learn about it.
Also providing some examples would be very useful.
In a generative test the engineer describes the shape of the data, and then defines the properties that the results should have, and the test runner provides randomized data to check against.
Running Tests You can run tests by using the cljs. test/run-tests macro. This may be done in your REPL or at the end of your file. If you have many test namespaces it's idiomatic to create a test runner namespace which imports all of your test namespaces and then invokes run-tests .
What is property-based testing? Property-based tests are designed to test the aspects of a property that should always be true. They allow for a range of inputs to be programmed and tested within a single test, rather than having to write a different test for every value that you want to test.
spec is a Clojure library to describe the structure of data and functions. Specs can be used to validate data, conform (destructure) data, explain invalid data, generate examples that conform to the specs, and automatically use generative testing to test functions.
As introductory reading we've got the Rationale and Overview along with the Guide which should provide you with information both about the why and the how.
If you'd like a somewhat complex example, we can take the string->semantic-version
function of leiningen.release
:
(defn string->semantic-version [version-string]
"Create map representing the given version string. Returns nil if the
string does not follow guidelines setforth by Semantic Versioning 2.0.0,
http://semver.org/"
;; <MajorVersion>.<MinorVersion>.<PatchVersion>[-<Qualifier>][-SNAPSHOT]
(if-let [[_ major minor patch qualifier snapshot]
(re-matches
#"(\d+)\.(\d+)\.(\d+)(?:-(?!SNAPSHOT)([^\-]+))?(?:-(SNAPSHOT))?"
version-string)]
(->> [major minor patch]
(map #(Integer/parseInt %))
(zipmap [:major :minor :patch])
(merge {:qualifier qualifier
:snapshot snapshot}))))
It takes a string and tries to parse it into a program-readable map representing the version number of some artifact. A spec for it could look like:
First some dependencies
(ns leiningen.core.spec.util
(:require
[clojure.spec :as spec]
[clojure.spec.gen :as gen]
[miner.strgen :as strgen]
[clojure.spec.test :as test]
[leiningen.release :as release]))
then a helper macro
(defmacro stregex
"Defines a spec which matches a string based on a given string
regular expression. This the classical type of regex as in the
clojure regex literal #\"\""
[string-regex]
`(spec/with-gen
(spec/and string? #(re-matches ~string-regex %))
#(strgen/string-generator ~string-regex)))
followed by a definition of a semantic version
(spec/def ::semantic-version-string
(stregex #"(\d+)\.(\d+)\.(\d+)(-\w+)?(-SNAPSHOT)?"))
and some helper-specs
(spec/def ::non-blank-string
(spec/and string? #(not (str/blank? %))))
(spec/def ::natural-number
(spec/int-in 0 Integer/MAX_VALUE))
for the definition of the keys in the resulting map
(spec/def ::release/major ::natural-number)
(spec/def ::release/minor ::natural-number)
(spec/def ::release/patch ::natural-number)
(spec/def ::release/qualifier ::non-blank-string)
(spec/def ::release/snapshot #{"SNAPSHOT"})
and the map itself
(spec/def ::release/semantic-version-map
(spec/keys :req-un [::release/major ::release/minor ::release/patch
::release/qualifier ::release/snapshot]))
followed by the function spec:
(spec/fdef release/string->semantic-version
:args (spec/cat :version-str ::release/semantic-version-string)
:ret ::release/semantic-version-map)
By now we can let Clojure Spec generate test data and feed it into the function itself in order to test whether it meets the constraints we've put up for it:
(test/check `release/version-map->string)
=> ({:spec #object[clojure.spec$fspec_impl$reify__14248 0x16c2555 "clojure.spec$fspec_impl$reify__14248@16c2555"],
:clojure.spec.test.check/ret {:result true,
:num-tests 1000,
:seed 1491922864713},
:sym leiningen.release/version-map->string})
This tells us that out of the 1000 test cases spec generated for us the function passed every single one.
You may find it easiest to get started looking at clojure/test.check
before you dive into Clojure Spec
. From the project page:
(require '[clojure.test.check :as tc])
(require '[clojure.test.check.generators :as gen])
(require '[clojure.test.check.properties :as prop])
(def sort-idempotent-prop
(prop/for-all [v (gen/vector gen/int)]
(= (sort v) (sort (sort v)))))
(tc/quick-check 100 sort-idempotent-prop)
;; => {:result true, :num-tests 100, :seed 1382488326530}
In prose, this test reads: for all vectors of integers, v, sorting v is equal to sorting v twice.
What happens if our test fails? test.check will try and find 'smaller' inputs that still fail. This process is called shrinking. Let's see it in action:
(def prop-sorted-first-less-than-last
(prop/for-all [v (gen/not-empty (gen/vector gen/int))]
(let [s (sort v)]
(< (first s) (last s)))))
(tc/quick-check 100 prop-sorted-first-less-than-last)
;; => {:result false, :failing-size 0, :num-tests 1, :fail [[3]],
:shrunk {:total-nodes-visited 5, :depth 2, :result false,
:smallest [[0]]}}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With