Created
January 4, 2018 13:35
-
-
Save pithyless/c7d6954c055ad45b6dfd2fca6ddfcce9 to your computer and use it in GitHub Desktop.
Test executable docstrings à la Python's doctest.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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