diff --git a/tests/src/lib.rs b/tests/src/lib.rs
index 3368dc49b..024ac8cc7 100644
--- a/tests/src/lib.rs
+++ b/tests/src/lib.rs
@@ -15,7 +15,7 @@ pub use backend::MockLLMBackend;
pub use bus::MockAgentBus;
pub use clock::{Clock, MockClock, SystemClock};
pub use report::{
- JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder, TestStatus,
- TextFormatter,
+ JUnitFormatter, JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder,
+ TestStatus, TextFormatter,
};
pub use tools::MockTool;
diff --git a/tests/src/report/format.rs b/tests/src/report/format.rs
index 919cae59d..640ab41dd 100644
--- a/tests/src/report/format.rs
+++ b/tests/src/report/format.rs
@@ -7,6 +7,15 @@ pub trait ReportFormatter: Send + Sync {
fn format(&self, report: &TestReport) -> String;
}
+fn escape_xml(input: &str) -> String {
+ input
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
/// Renders a report as a JSON object.
pub struct JsonFormatter;
@@ -102,3 +111,95 @@ impl ReportFormatter for TextFormatter {
buf
}
}
+
+/// Renders a report as a JUnit XML testsuite.
+pub struct JUnitFormatter;
+
+impl ReportFormatter for JUnitFormatter {
+ fn format(&self, report: &TestReport) -> String {
+ let mut buf = String::new();
+ let tests = report.total();
+ let failures = report.failed();
+ let skipped = report.skipped();
+ let time_secs = report.total_duration.as_secs_f64();
+
+ buf.push_str("\n");
+ buf.push_str(&format!(
+ "\n",
+ escape_xml(&report.suite_name),
+ tests,
+ failures,
+ skipped,
+ time_secs,
+ report.timestamp
+ ));
+
+ for result in &report.results {
+ buf.push_str(&format!(
+ " ",
+ escape_xml(&result.name),
+ result.duration.as_secs_f64()
+ ));
+
+ match result.status {
+ TestStatus::Passed => {
+ if !result.metadata.is_empty() {
+ buf.push('\n');
+ buf.push_str(" \n");
+ for (key, value) in &result.metadata {
+ buf.push_str(&format!(
+ " \n",
+ escape_xml(key),
+ escape_xml(value)
+ ));
+ }
+ buf.push_str(" \n");
+ buf.push_str(" \n");
+ } else {
+ buf.push_str("\n");
+ }
+ }
+ TestStatus::Failed => {
+ buf.push('\n');
+ if !result.metadata.is_empty() {
+ buf.push_str(" \n");
+ for (key, value) in &result.metadata {
+ buf.push_str(&format!(
+ " \n",
+ escape_xml(key),
+ escape_xml(value)
+ ));
+ }
+ buf.push_str(" \n");
+ }
+ let message = result.error.as_deref().unwrap_or("test failed");
+ buf.push_str(&format!(
+ " {}\n",
+ escape_xml(message),
+ escape_xml(message)
+ ));
+ buf.push_str(" \n");
+ }
+ TestStatus::Skipped => {
+ buf.push('\n');
+ if !result.metadata.is_empty() {
+ buf.push_str(" \n");
+ for (key, value) in &result.metadata {
+ buf.push_str(&format!(
+ " \n",
+ escape_xml(key),
+ escape_xml(value)
+ ));
+ }
+ buf.push_str(" \n");
+ }
+ buf.push_str(" \n");
+ buf.push_str(" \n");
+ }
+ }
+ }
+
+ buf.push_str("\n");
+ buf
+ }
+}
diff --git a/tests/src/report/mod.rs b/tests/src/report/mod.rs
index c869a86fd..444dbbdde 100644
--- a/tests/src/report/mod.rs
+++ b/tests/src/report/mod.rs
@@ -5,5 +5,5 @@ mod format;
mod types;
pub use builder::TestReportBuilder;
-pub use format::{JsonFormatter, ReportFormatter, TextFormatter};
+pub use format::{JUnitFormatter, JsonFormatter, ReportFormatter, TextFormatter};
pub use types::{TestCaseResult, TestReport, TestStatus};
diff --git a/tests/tests/report_tests.rs b/tests/tests/report_tests.rs
index d52a9e9fa..4f46e16fc 100644
--- a/tests/tests/report_tests.rs
+++ b/tests/tests/report_tests.rs
@@ -2,8 +2,8 @@ use std::sync::Arc;
use std::time::Duration;
use mofa_testing::{
- JsonFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder,
- TestStatus, TextFormatter,
+ JUnitFormatter, JsonFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport,
+ TestReportBuilder, TestStatus, TextFormatter,
};
// ---------------------------------------------------------------------------
@@ -319,3 +319,83 @@ fn text_formatter_empty_report() {
assert!(output.contains("Total: 0"));
assert!(output.contains("Pass rate: 100.0%"));
}
+
+// ===========================================================================
+// JUnitFormatter
+// ===========================================================================
+
+#[test]
+fn junit_formatter_all_passing_suite() {
+ let report = TestReport {
+ suite_name: "passing".into(),
+ results: vec![
+ make_result("alpha", TestStatus::Passed, 10, None),
+ make_result("beta", TestStatus::Passed, 20, None),
+ ],
+ total_duration: Duration::from_millis(30),
+ timestamp: 123,
+ };
+
+ let output = JUnitFormatter.format(&report);
+ assert!(output.contains(""));
+ assert!(output.contains(""));
+ assert!(output.contains(""));
+ assert!(output.contains(""));
+}
+
+#[test]
+fn junit_formatter_mixed_status_suite() {
+ let report = mixed_report();
+ let output = JUnitFormatter.format(&report);
+
+ assert!(output.contains(""));
+ assert!(output.contains("boom"));
+ assert!(output.contains("oops"));
+ assert!(output.contains(""));
+}
+
+#[test]
+fn junit_formatter_escapes_error_payload() {
+ let report = TestReport {
+ suite_name: "xml".into(),
+ results: vec![make_result(
+ "needs",
+ TestStatus::Failed,
+ 1,
+ Some("bad & \"quotes\""),
+ )],
+ total_duration: Duration::from_millis(1),
+ timestamp: 1,
+ };
+
+ let output = JUnitFormatter.format(&report);
+ assert!(output.contains("name=\"needs<escape>\""));
+ assert!(output.contains("message=\"bad <xml> & "quotes"\""));
+ assert!(output.contains(">bad <xml> & "quotes""));
+}
+
+#[test]
+fn junit_formatter_includes_metadata_as_properties() {
+ let mut tc = make_result("meta_case", TestStatus::Passed, 5, None);
+ tc.metadata.push(("browser".into(), "webkit".into()));
+ tc.metadata.push(("env".into(), "ci".into()));
+ let report = TestReport {
+ suite_name: "meta".into(),
+ results: vec![tc],
+ total_duration: Duration::from_millis(5),
+ timestamp: 9,
+ };
+
+ let output = JUnitFormatter.format(&report);
+ assert!(output.contains(""));
+ assert!(output.contains(""));
+ assert!(output.contains(""));
+}
+
+#[test]
+fn junit_formatter_is_deterministic() {
+ let report = mixed_report();
+ let first = JUnitFormatter.format(&report);
+ let second = JUnitFormatter.format(&report);
+ assert_eq!(first, second);
+}