A precise technical reference, not a tutorial. For a task-oriented introduction see the user guide.
Set under the :junit-xml key in project.clj. Unknown keys are rejected rather than ignored.
| Key | Type | Default | Description |
|---|---|---|---|
:output-dir | string | "target/junit-xml" | Directory report files are written to. Relative paths resolve against the project root, not the working directory. |
:junit-xml {:output-dir "target/junit-xml"}
| Component | Version | How it was established |
|---|---|---|
| Leiningen | 2.10.0 or newer | Lowest release the plugin is exercised on |
| Clojure, in the project under test | 1.9 or newer | Hard floor — 1.8 fails to compile |
| Java, in the project under test | 8 or newer | Full run verified on 8u482 |
| Runtime dependencies added to your project | none |
These are measured, not assumed. Each was established by running the plugin, not by reading release notes, and the evidence is recorded below so the numbers can be rechecked rather than trusted.
Two Clojure versions are in play and only one is the plugin's own. The task half (leiningen.junit-xml
and impl.config) runs on Leiningen's bundled Clojure and needs only 1.5. The reporting half is
loaded into your project's JVM and runs on your Clojure, so it declares no Clojure dependency
and stays syntax-compatible with 1.9, with no 1.12-only reader syntax anywhere under src/com/github/robinlahtinen/.
CI loads those namespaces against 1.9, 1.10, 1.11 and 1.12 on every push.
| Task | lein junit-xml |
| Arguments | identical to lein test: namespaces, test file paths, :selector keywords |
| Exit code | 0 when the suite passes, 1 otherwise |
| Side effects | writes one file per test namespace under :output-dir; nothing else |
One file per test namespace, named TEST-<namespace>.xml. Characters outside [A-Za-z0-9._-] in
the namespace become _ so the name is valid on Windows; the unmodified name stays in the name
attribute. The root element is always <testsuite>; no <testsuites> wrapper is emitted, matching
JUnit.
<testsuite>Attributes, in the order JUnit writes them:
| Attribute | Value |
|---|---|
name | the test namespace |
tests | total cases, including skipped ones |
skipped | cases that did not run |
failures | cases with at least one failed assertion |
errors | cases that threw |
time | wall-clock seconds, three decimals, always . as the separator |
hostname | the reporting host, or <unknown host> if lookup fails |
timestamp | suite start as an ISO-8601 UTC instant, e.g. 2026-09-20T16:18:05Z |
Child order is (properties?, testcase*, system-out?, system-err?).
<testcase>| Attribute | Value |
|---|---|
name | the deftest var name |
classname | the fully-qualified namespace |
time | wall-clock seconds, three decimals |
Child order is (skipped?, error*, failure*).
classname drives the whole Package → Class → Test tree in Jenkins, which splits it at the last
dot. A single-segment namespace therefore lands under Jenkins' (root) package; that is an accurate
reflection of your namespace, and no package is invented to avoid it.
| Element | Attributes | Body |
|---|---|---|
<failure> | message | expected form, actual form, and file:line |
<error> | message, type (the exception class) | the full stack trace |
<skipped> | none | the reason |
<skipped> carries no message attribute: the schema types it as a simple type, so an attribute
there is invalid. Jenkins falls back to the element's text, which is also what JUnit writes.
A deftest containing three failing is forms is one failure carrying three <failure>
children. This matches JUnit, where a test method is a single result regardless of how many
assertions it makes.
<properties>A curated three: clojure.version, java.version, os.name. JUnit dumps the entire
System.getProperties(), which leaks the build environment into every file; Jenkins discards them
altogether unless the job opts in with keepProperties.
Every string entering a document passes through one filter.
Char production, defined as #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF], are replaced with U+FFFD. This covers ESC from ANSI colour output, NUL,
other control bytes, non-characters, and unpaired surrogates.& < > are escaped in text; & < > " in attribute values. , ,
	) so multi-line failure messages survive attribute-value normalisation.]]> inside a CDATA payload is split across two adjacent sections so it cannot terminate the
section early.This matters because Jenkins parses with a strict SAX reader and replaces an entire unparseable
suite with a synthetic red [failed-to-read] case. One bad byte does not degrade a report; it
destroys it.
| # | JUnit writes | We write | Why |
|---|---|---|---|
| 1 | timestamp as a zone-less local date-time | an explicit UTC instant ending in Z | Jenkins appends Z to a zone-less value and reads it as UTC, shifting every suite start by the agent's offset |
| 2 | several <system-out> elements per case | exactly one | Jenkins reads only the first and silently discards the rest |
| 3 | <failure> before <error> | <error> before <failure> | the schema sequence is ordered; JUnit's order is invalid when a case has both, and Jenkins is order-insensitive either way |
| 4 | every system property | three | size and environment leakage, for data Jenkins discards by default |
| 5 | time via NumberFormat(Locale.US), grouping enabled | %.3f with Locale/ROOT | a grouping comma above 1000 seconds is invalid against the Surefire schema and breaks non-Jenkins consumers |
Reports are validated in CI against dev-resources/jenkins-junit.xsd, the schema
junit-framework's own XmlReportAssertions validates every legacy report against.
dev-resources/surefire-test-report-3.0.2.xsd is not a second gate, and cannot be: it permits
no timestamp and no hostname on <testsuite> and declares no anyAttribute, so JUnit 6.2.0's
own output is invalid against it. Dropping those attributes to satisfy it would lose data Jenkins
actually uses. The common subset is asserted instead, and the divergence is itself covered by a test
so it is not later "fixed" in the wrong direction.
This is also why no assertions, file or line attributes appear on <testcase> even though
they are common conventions elsewhere and the data is available: neither schema permits them.
| Situation | Behaviour |
|---|---|
| A namespace with no tests | A file is still written, with tests="0". Jenkins discards suites with no cases, so it will not appear there. |
A :once fixture throws | The error is recorded as an initializationError case and the suite is written. Tests after the fixture never ran and are absent. |
:monkeypatch-clojure-test false | Still supported; the plugin owns the failure path rather than relying on Leiningen's catcher. |
| A compile error in any test namespace | No files at all. Leiningen requires every test namespace before running anything, so the run aborts before a result exists. |
| The same namespace run twice in one JVM | Each run reports independently; per-run state resets on each invocation. |
Output written to System/out directly | Not captured. See below. |
Leiningen's own lein test :only … failure hints | Captured into <system-out>. Leiningen prints them with println, so they go to the same stream as your test output. Filtering them would mean matching on their exact wording, which is more fragile than reporting honestly what was printed. |
| Output from other threads | Not captured; *out* rebinding is thread-local. |
| Duplicate test names | Emitted as-is. Jenkins keeps both and disambiguates only their URLs. |
A namespace defining test-ns-hook | Degraded. clojure.test bypasses test-var entirely there, so no per-test boundaries are reported: passing tests do not appear, and each failure becomes its own initializationError case. The suite is still written and still valid. test-ns-hook is also incompatible with fixtures, so this is rare. |
System/out is not capturedSystem/setOut does not redirect Clojure's *out*, whose root writer is constructed over
System.out when the runtime initialises. Capturing both would require System/setOut and
alter-var-root on #'*out*, which is process-global and unsafe under :eval-in :leiningen or any
parallel runner. The plugin takes the safe side.
Configuration errors throw ex-info before the project JVM starts, carrying:
{:com.github.robinlahtinen.lein-junit-xml/error <category>
:com.github.robinlahtinen.lein-junit-xml/context :com.github.robinlahtinen.lein-junit-xml/junit-xml
...}
| Category | Raised when |
|---|---|
:com.github.robinlahtinen.lein-junit-xml/invalid-type | :junit-xml is not a map, or :output-dir is not a string |
:com.github.robinlahtinen.lein-junit-xml/invalid-value | :output-dir is blank |
:com.github.robinlahtinen.lein-junit-xml/unsupported | an unrecognised key is present under :junit-xml |
Errors inside the reporting path never propagate. They are printed to System/err prefixed with
com.github.robinlahtinen/lein-junit-xml: and swallowed, because a bug in a reporter must not change whether your tests
pass.
The suite value, produced by com.github.robinlahtinen.lein-junit-xml.impl.collect
and consumed by com.github.robinlahtinen.lein-junit-xml.impl.xml:
{:name "my.app.core-test" ; namespace, as a string
:timestamp 1758394685000 ; epoch millis, suite start
:duration 1234 ; millis
:hostname "build-07"
:properties {"clojure.version" "1.12.6"}
:out "captured stdout" ; absent when empty
:err "captured stderr" ; absent when empty
:cases [{:name "adds-test"
:classname "my.app.core-test"
:duration 12
:results [{:type :fail ; :fail | :error | :skipped
:message "expected: (= 1 2)"
:class "java.lang.RuntimeException" ; :error only
:detail "expected/actual, or a stack trace"}]}]}
An empty :results vector means the case passed.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |