From c5fb203dd372edc4041a96f8fba2f3a3bd95a5fa Mon Sep 17 00:00:00 2001 From: Nemanja <31325167+nemanjaglumac@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:20:27 +0200 Subject: [PATCH 1/3] Add message attribute to JUnit failure/error elements Surface a test result's :message as a dedicated `message` attribute on and elements, instead of only embedding it in the CDATA body. elements keep their existing `type` attribute alongside the new `message`. The attribute is omitted entirely when there is no message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mb/hawk/junit/write.clj | 12 ++++--- test/mb/hawk/junit/write_test.clj | 58 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 test/mb/hawk/junit/write_test.clj diff --git a/src/mb/hawk/junit/write.clj b/src/mb/hawk/junit/write.clj index decff45..0ffaff2 100644 --- a/src/mb/hawk/junit/write.clj +++ b/src/mb/hawk/junit/write.clj @@ -101,19 +101,21 @@ nil) (defmethod write-assertion-result!* :fail - [w result] + [w {:keys [message], :as result}] (write-element! w "failure" - nil + (when message + {:message (decolorize-and-escape message)}) (fn [] (write-result-output! w result)))) (defmethod write-assertion-result!* :error - [w {:keys [actual], :as result}] + [w {:keys [actual message], :as result}] (write-element! w "error" - (when (instance? Throwable actual) - {:type (.getCanonicalName (class actual))}) + (cond-> nil + (instance? Throwable actual) (assoc :type (.getCanonicalName (class actual))) + message (assoc :message (decolorize-and-escape message))) (fn [] (write-result-output! w result)))) diff --git a/test/mb/hawk/junit/write_test.clj b/test/mb/hawk/junit/write_test.clj new file mode 100644 index 0000000..84bbc4f --- /dev/null +++ b/test/mb/hawk/junit/write_test.clj @@ -0,0 +1,58 @@ +(ns mb.hawk.junit.write-test + (:require + [clojure.test :refer :all] + [clojure.xml :as xml] + [mb.hawk.junit.write :as write]) + (:import + (java.io ByteArrayInputStream StringWriter) + (javax.xml.stream XMLOutputFactory))) + +(defn- result->element + "Run `result` through the (private) assertion writer and parse the single element it produces, returning an XML + map of the shape `{:tag ..., :attrs {...}, :content [...]}`." + [result] + (let [sw (StringWriter.) + w (.createXMLStreamWriter (XMLOutputFactory/newInstance) sw)] + (.writeStartDocument w) + (#'write/write-assertion-result!* w result) + (.writeEndDocument w) + (.flush w) + (xml/parse (ByteArrayInputStream. (.getBytes (str sw) "UTF-8"))))) + +(def ^:private base-result + {:file "write_test.clj" + :line 42 + :testing-contexts [] + :expected 1 + :actual 2 + :diffs nil}) + +(deftest failure-message-attribute-test + (testing "a :fail with a :message gets a `message` attribute on the element" + (let [{:keys [tag attrs]} (result->element (assoc base-result :type :fail :message "should be equal"))] + (is (= :failure tag)) + (is (= "should be equal" (:message attrs))))) + + (testing "a :fail without a :message has no `message` attribute" + (let [{:keys [tag attrs]} (result->element (assoc base-result :type :fail :message nil))] + (is (= :failure tag)) + (is (not (contains? attrs :message)))))) + +(deftest error-message-attribute-test + (testing "an :error carries both `type` and `message` attributes on the element" + (let [{:keys [tag attrs]} (result->element (assoc base-result + :type :error + :message "boom" + :actual (ex-info "kaboom" {})))] + (is (= :error tag)) + (is (= "clojure.lang.ExceptionInfo" (:type attrs))) + (is (= "boom" (:message attrs))))) + + (testing "an :error without a :message still has its `type` attribute and no `message`" + (let [{:keys [tag attrs]} (result->element (assoc base-result + :type :error + :message nil + :actual (ex-info "kaboom" {})))] + (is (= :error tag)) + (is (= "clojure.lang.ExceptionInfo" (:type attrs))) + (is (not (contains? attrs :message)))))) From 45cd90d937a5253b04e9ce77b433fed9fd2990d5 Mon Sep 17 00:00:00 2001 From: Nemanja <31325167+nemanjaglumac@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:24:33 +0200 Subject: [PATCH 2/3] Set *warn-on-reflection* and hint writer in write_test Satisfies clj-kondo's warn-on-reflection lint and avoids reflection on the XMLStreamWriter interop calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/mb/hawk/junit/write_test.clj | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/mb/hawk/junit/write_test.clj b/test/mb/hawk/junit/write_test.clj index 84bbc4f..068730e 100644 --- a/test/mb/hawk/junit/write_test.clj +++ b/test/mb/hawk/junit/write_test.clj @@ -5,14 +5,16 @@ [mb.hawk.junit.write :as write]) (:import (java.io ByteArrayInputStream StringWriter) - (javax.xml.stream XMLOutputFactory))) + (javax.xml.stream XMLOutputFactory XMLStreamWriter))) + +(set! *warn-on-reflection* true) (defn- result->element "Run `result` through the (private) assertion writer and parse the single element it produces, returning an XML map of the shape `{:tag ..., :attrs {...}, :content [...]}`." [result] - (let [sw (StringWriter.) - w (.createXMLStreamWriter (XMLOutputFactory/newInstance) sw)] + (let [sw (StringWriter.) + ^XMLStreamWriter w (.createXMLStreamWriter (XMLOutputFactory/newInstance) sw)] (.writeStartDocument w) (#'write/write-assertion-result!* w result) (.writeEndDocument w) From 03290176f1cc32b19d21b7129fc1783b1e53b539 Mon Sep 17 00:00:00 2001 From: Nemanja <31325167+nemanjaglumac@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:12:02 +0200 Subject: [PATCH 3/3] Use exception root-cause message for :error message attribute For :error results, clojure.test sets a generic ":message" of "Uncaught exception, not in assertion.". The exception's own root-cause message is far more useful as the JUnit `message` attribute (e.g. for grouping/display in test analytics tools), so prefer it, falling back to clojure.test's message when `actual` is not a Throwable or the exception has no message. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mb/hawk/junit/write.clj | 26 +++++++++++++------ test/mb/hawk/junit/write_test.clj | 42 ++++++++++++++++++++++--------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/mb/hawk/junit/write.clj b/src/mb/hawk/junit/write.clj index 0ffaff2..6aff4a9 100644 --- a/src/mb/hawk/junit/write.clj +++ b/src/mb/hawk/junit/write.clj @@ -92,6 +92,13 @@ (.writeCharacters w "\n") (.writeEndElement w)) +(defn- root-cause-message + "Message of the root cause of `e` (the deepest exception in its cause chain). `clojure.test` reports an uncaught + exception with the generic message \"Uncaught exception, not in assertion.\", so for `:error` results the + exception's own message is far more useful as the `message` attribute." + [^Throwable e] + (ex-message (last (take-while some? (iterate ex-cause e))))) + (defmulti ^:private write-assertion-result!* {:arglists '([^XMLStreamWriter w result])} (fn [_ result] (:type result))) @@ -111,13 +118,18 @@ (defmethod write-assertion-result!* :error [w {:keys [actual message], :as result}] - (write-element! - w "error" - (cond-> nil - (instance? Throwable actual) (assoc :type (.getCanonicalName (class actual))) - message (assoc :message (decolorize-and-escape message))) - (fn [] - (write-result-output! w result)))) + ;; prefer the exception's own (root cause) message over `clojure.test`'s generic "Uncaught exception..." message, + ;; falling back to the latter when `actual` is not a Throwable or the exception has no message. + (let [message (or (when (instance? Throwable actual) + (root-cause-message actual)) + message)] + (write-element! + w "error" + (cond-> nil + (instance? Throwable actual) (assoc :type (.getCanonicalName (class actual))) + message (assoc :message (decolorize-and-escape message))) + (fn [] + (write-result-output! w result))))) (defn- write-assertion-result! [w result] (try diff --git a/test/mb/hawk/junit/write_test.clj b/test/mb/hawk/junit/write_test.clj index 068730e..3222039 100644 --- a/test/mb/hawk/junit/write_test.clj +++ b/test/mb/hawk/junit/write_test.clj @@ -40,21 +40,39 @@ (is (= :failure tag)) (is (not (contains? attrs :message)))))) +(def ^:private uncaught-message + "The generic message `clojure.test` attaches to any exception that escapes a test var." + "Uncaught exception, not in assertion.") + (deftest error-message-attribute-test - (testing "an :error carries both `type` and `message` attributes on the element" + (testing "an :error uses the exception's own message (not clojure.test's generic one) alongside `type`" (let [{:keys [tag attrs]} (result->element (assoc base-result - :type :error - :message "boom" - :actual (ex-info "kaboom" {})))] + :type :error + :message uncaught-message + :actual (ex-info "kaboom" {})))] (is (= :error tag)) (is (= "clojure.lang.ExceptionInfo" (:type attrs))) - (is (= "boom" (:message attrs))))) + (is (= "kaboom" (:message attrs))))) - (testing "an :error without a :message still has its `type` attribute and no `message`" - (let [{:keys [tag attrs]} (result->element (assoc base-result - :type :error - :message nil - :actual (ex-info "kaboom" {})))] - (is (= :error tag)) - (is (= "clojure.lang.ExceptionInfo" (:type attrs))) + (testing "an :error uses the *root cause* message when the exception has a cause chain" + (let [{:keys [attrs]} (result->element (assoc base-result + :type :error + :message uncaught-message + :actual (ex-info "outer" {} (ex-info "deadlock - the real cause" {}))))] + (is (= "deadlock - the real cause" (:message attrs))))) + + (testing "an :error falls back to the clojure.test :message when :actual is not a Throwable" + (let [{:keys [attrs]} (result->element (assoc base-result + :type :error + :message "some non-exception message" + :actual :not-a-throwable))] + (is (not (contains? attrs :type))) + (is (= "some non-exception message" (:message attrs))))) + + (testing "an :error with no exception message and no :message has `type` but no `message`" + (let [{:keys [attrs]} (result->element (assoc base-result + :type :error + :message nil + :actual (Throwable.)))] + (is (= "java.lang.Throwable" (:type attrs))) (is (not (contains? attrs :message))))))