From 4797af19c6f1ee6d477b64f5c84cd60daa3eaf8a Mon Sep 17 00:00:00 2001 From: diiviikk5 Date: Fri, 3 Apr 2026 11:20:31 +0530 Subject: [PATCH] feat(testing): add JUnit formatter for TestReport --- tests/src/lib.rs | 4 +- tests/src/report/format.rs | 101 ++++++++++++++++++++++++++++++++++++ tests/src/report/mod.rs | 2 +- tests/tests/report_tests.rs | 84 +++++++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 5 deletions(-) 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); +}