Skip to content

Instantly share code, notes, and snippets.

@pithyless
Created January 4, 2018 13:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pithyless/c7d6954c055ad45b6dfd2fca6ddfcce9 to your computer and use it in GitHub Desktop.
Save pithyless/c7d6954c055ad45b6dfd2fca6ddfcce9 to your computer and use it in GitHub Desktop.
Test executable docstrings à la Python's doctest.
(ns sursolid.fribble.doctest
"Test executable docstrings à la Python's doctest.
Inspired by Python's doctest, this namespace provides tools to
turn your regular docstrings into REPL sessions that will be
run and checked via your regular testing suite.
## Formatting the docstring
Most of the docstring is treated as plain-text and will not be
evaluated in the REPL, although I do recommend using Markdown-style
formatting. The exception to the rule is any line prefixed with
`>>`, `=>`, or `:>`.
### `>>`
Any line starting with `>>` will be evaluated:
>> (+ 1 2)
Whitespace is ignored, so feel free to indent to your heart's content.
### `=>`
Any line starting with `=>` will be converted into an `clojure.test/is`
assertion, that will check via `=` the last return value.
So this:
>> (+ 1 2)
=> 3
Turns roughly into this:
(is (= 3 (+ 1 2)))
Later these assertions get converted into `deftest` to be run as part
of your test suite. This way, your examples are always up-to-date with
your code, and your documentation doesn't lie.
### `:>`
Inspired by `cognitect.transcriptor/check!`, this prefix will check
that the last return value matches a `clojure.spec` specification.
>> (+ 1 2)
:> integer?
>> (+ 1 2)
:> (s/and integer? odd?)
>> (s/def ::odd-integer (s/and integer? oddd?))
>> (+ 1 2)
:> ::odd-integer
## Namespace sandbox
The code will run in a temporary namespace, but not in a security
sandbox, so avoid doing anything you wouldn't want to eval every
time you re-run your test suite.
For your convenience, several namespaces are required by default:
[clojure.string :as str]
[clojure.spec.alpha :as s]
And the current var's namespace is aliased:
[current.namespace :as sut]
Feel free to `require` any other namespaces you may need.
## Running tests
This generates a `deftest` to be picked up by your test runner:
(doctest #'your.ns/foo-baz)
"
(:import [clojure.lang LineNumberingPushbackReader]
[java.io StringReader])
(:require [clojure.string :as str]
[clojure.core.server :as server]
[clojure.main :as main]
[clojure.pprint :as pp]
[clojure.spec.alpha :as s]
[clojure.test :as test]
[clojure.string :as str]))
(defn- repl
"REPL for running test code."
{:attribution "Taken from com.cognitect/transcriptor and modified."}
[]
(let [cl (.getContextClassLoader (Thread/currentThread))
_ (.setContextClassLoader (Thread/currentThread)
(clojure.lang.DynamicClassLoader. cl))
request-prompt (Object.)
request-exit (Object.)
read-eval-print
(fn []
(let [read-eval *read-eval*
input (main/with-read-known
(server/repl-read request-prompt request-exit))]
(if (#{request-prompt request-exit} input)
input
(let [value (binding [*read-eval* read-eval] (eval input))]
(set! *2 input) (set! *1 value)))))]
(main/with-bindings
(try
(loop []
(let [value (read-eval-print)]
(when-not (identical? value request-exit)
(recur))))))))
(defn- run-script
"Run script in a throwaway REPL and namespace."
{:attribution "Taken from com.cognitect/transcriptor and modified."}
[var-ns var-name script]
(let [ns (symbol (str "sursolid.fribble.doctest." (gensym "t_")))]
(binding [*ns* *ns*]
(in-ns ns)
(clojure.core/use 'clojure.core)
(clojure.core/require '[clojure.test :as test])
(clojure.core/require '[clojure.string :as str])
(clojure.core/require '[clojure.spec.alpha :as s])
(clojure.core/require [var-ns :as 'sut :refer [var-name]])
(with-open [rdr (LineNumberingPushbackReader.
(java.io.StringReader. script))]
(binding [*source-path* "garbage-path" *in* rdr]
(repl))))))
(defn- make-script
"Given a docstring, return executable lines only."
[docstring]
;; TODO: This is a super-lame hack at the moment and deserves a real parser.
(->> (str docstring)
(str/split-lines)
(map str/trim)
(filter #(or (str/starts-with? % ">>")
(str/starts-with? % "=>")
(str/starts-with? % ":>")))
(map #(str/replace-first % #"(>>\s*)(.+)" "$2"))
(map #(str/replace-first % #"(=>\s*)(.+)" "(test/is (= $2 *1) *2)"))
(map #(str/replace-first % #"(:>\s*)(.+)" "(test/is (s/valid? $2 *1) (str *2 \"\n\" (s/explain-str $2 *1)))"))
(str/join "\n")))
(defn doctest* [sym]
(let [v (find-var sym)
var-ns (symbol (namespace sym))
var-name (symbol (name sym))
doc (:doc (meta v))
script (make-script doc)]
(run-script var-ns var-name script)))
(defmacro doctest
"Generate a `clojure.test/deftest` for symbol `sym`.
The test will evaluate code found in the docstring
and fail if any assertions are false."
[sym]
(let [test-name (gensym (str "test-doctest-"
(str/replace (namespace sym) "." "_")
"-"
(name sym)
"-"))]
`(clojure.test/deftest ~test-name
(doctest* '~sym))))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment