Generates CLR-compatible test files from Rich Comment Tests (^:rct/test) blocks.
RCT tests already run on the JVM, but .cljc code targets both platforms. Running the generated tests on the CLR catches issues that JVM-only testing misses:
- Exception handling: CLR uses
System.Exception, notjava.lang.Exception.throws=>>assertions verify the correct exception type is thrown. - Interop correctness: Method names differ between platforms (e.g.
.getMessagevs.Message). - Runtime differences: Magic/Nostrand run on Clojure 1.10 with CLR-specific runtime behavior.
Standard ^:rct/test blocks work unchanged — the generator handles the platform differences. These examples show patterns that are especially useful for cross-platform code.
;;;; Reader conditionals in test expectations
;;
;; When a function returns different values per platform, use #? in the
;; expectation.
(defn platform []
#?(:clj :jvm :cljr :clr))
^:rct/test
(comment
(platform)
;=> #?(:clj :jvm :cljr :clr)
)
;;;; Exception assertions with throws=>>
;;
;; throws=>> verifies that a function throws and pattern-matches the error.
;; The generator emits catch System.Exception for CLR, so this validates
;; CLR exception types and error data.
;;
;; The generated error->map helper extracts :error/class, :error/message,
;; and :error/data from the exception, so you can match on any combination.
(defn validate-positive! [x]
(when-not (pos? x)
(throw (ex-info "must be positive" {:value x}))))
^:rct/test
(comment
(validate-positive! -1)
;throws=>> {:error/message "must be positive"
:error/data {:value -1}}
)
;;;; Reader conditionals in test expressions
;;
;; Reader conditionals cannot be used in test expressions — use separate
;; files for each platform's interop instead. See issue #10.
;; -- examples_clr/rct_clr/sample_clr.cljc (generator scans this) --
(defn make-error [msg]
(ex-info msg {}))
^:rct/test
(comment
(.Message (make-error "boom"))
;=> "boom"
)
;; -- examples_jvm/rct_clr/sample_jvm.cljc (RCT runner tests this) --
(defn make-error [msg]
(ex-info msg {}))
^:rct/test
(comment
(.getMessage (make-error "boom"))
;=> "boom"
)See examples/, examples_clr/, and examples_jvm/ for complete working examples.
RCT depends on rewrite-clj and tools.namespace, which are JVM-only. This tool pre-extracts RCT test data into a plain .cljc test file that CLR (Magic/Nostrand) can run using only clojure.test and matcho.core.
- Extract (JVM): Run
rct-clr.genon the JVM, where rewrite-clj and tools.namespace are available. It scans.cljcsource files, loads each namespace, finds all^:rct/testcomment blocks, and writes the assertions into a plain.cljctest file. (.cljfiles are ignored.) - Test (CLR): Run the generated file on Magic/Nostrand using
clojure.test. No JVM-only dependencies are needed at test time.
- JVM Clojure (for running the generator)
- Magic/Nostrand on the target CLR platform (for running generated tests)
clojure -M:dev -m rct-clr.gen \
-o test/my_project/rct_generated_test.cljc \
-n my-project.rct-generated-test| Flag | Description | Default |
|---|---|---|
-s, --src-dir DIR |
Source directory to scan (repeatable, e.g. -s src -s src2) |
src |
-o, --output PATH |
Output file path (required) | |
-n, --namespace NS |
Output namespace (required) | |
-h, --help |
Show help |
Add as a dev dependency:
{:aliases
{:dev {:extra-deps {io.github.flybot-sg/rct-clr
{:git/url "https://github.com/flybot-sg/rct-clr"
:git/sha "..."}}}}}Since rct-clr transitively brings in rich-comment-tests, you can remove any existing direct RCT dependency from your deps.edn.
Nostrand does not resolve transitive dependencies. Add matcho directly to your project.edn dependencies, since the generated tests use matcho.core/assert for =>> patterns:
{:dependencies [[:github flybot-sg/matcho "magic"
:sha "1edae156dda891b2f1698afc4972f5456f49d039"
:paths ["src"]]]}If you use Babashka to run scripts, you can do this too:
{:tasks {gen-clr-rct
{:doc "Generate CLR-compatible RCT test file"
:task (clojure "-M:dev -m rct-clr.gen -o test/my_project/rct_generated_test.cljc -n my-project.rct-generated-test")}}}Create a test file that runs RCT blocks on the JVM using the rich-comment-tests runner:
(ns my-project.rc-test
(:require [clojure.test :refer [deftest testing]]
[com.mjdowney.rich-comment-tests.test-runner :as test-runner]))
(deftest ^:rct rich-comment-tests
(testing "Rich comment tests."
(test-runner/run-tests-in-file-tree! :dirs #{"src"})))Skip the generated CLR on JVM and split tests into :rct and :unit suites so they can be run independently:
#kaocha/v1
{:kaocha.filter/skip-meta [:clr-only]
:tests [{:id :rct
:focus-meta [:rct]}
{:id :unit
:skip-meta [:rct]}]}To run only the RCT tests on JVM without running the full test suite:
{:tasks {rct
{:doc "Run rct"
:task (clojure "-M:dev:test --focus :rct")}}}Add the generated test namespace to your test-namespaces. Also exit non-zero on test failures — clojure.test/run-all-tests returns a result map but doesn't set the exit code, so without this Nostrand exits 0 even when tests fail:
(let [{:keys [fail error]} (run-all-tests)]
(when (or (pos? fail) (pos? error))
(Environment/Exit 1)))Add the generated file to your .gitignore.
-
If your CI caches untracked files (e.g. GitLab CI
cache: untracked: true), delete the generated file before format checks to avoid stale copies causing failures:rm -f test/my_project/rct_generated_test.cljc
The generated file contains:
- A namespace with
^:clr-onlymetadata (skipped by JVM test runners that filter on this) - An
error->maphelper (replaces RCT'serror-datafywhich usesex-message, unavailable on Magic/Clojure 1.10) - One
deftestper source namespace, with assertions usingclojure.test/isfor=>,matcho.core/assertfor=>>, andtry/catchwith matcho matching forthrows=>> - Side-effect forms (e.g.
def,require) from RCT blocks that have no assertion are emitted as bareevalcalls
Example output (abbreviated):
(ns ^:clr-only my-project.rct-generated-test
"Auto-generated from ^:rct/test blocks. Do not edit manually."
(:require [clojure.test :refer [deftest is testing]]
[matcho.core]
[my-project.core]))
(defn error->map [e]
{:error/class (type e)
:error/message #?(:clj (.getMessage e) :cljr (.Message e))
:error/data (ex-data e)})
;; my-project.core
(defn- my-project-core-rct-block-0 []
;; core.cljc:42
(testing "core.cljc:42" (eval (quote (clojure.test/is (= 4 (+ 2 2))))))
;; core.cljc:45
(testing "core.cljc:45" (eval (quote (matcho.core/assert {:status 200} (fetch))))))
(deftest my-project-core-rct
(binding [*ns* (the-ns 'my-project.core)]
(my-project-core-rct-block-0)))