diff --git a/tests/src/lib.rs b/tests/src/lib.rs
index 2c83bd2b6..42199bee0 100644
--- a/tests/src/lib.rs
+++ b/tests/src/lib.rs
@@ -14,7 +14,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,
+ JsonFormatter, MarkdownFormatter, 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..c1c375d71 100644
--- a/tests/src/report/format.rs
+++ b/tests/src/report/format.rs
@@ -102,3 +102,56 @@ impl ReportFormatter for TextFormatter {
buf
}
}
+
+/// Renders a report as a Markdown summary.
+pub struct MarkdownFormatter;
+
+impl ReportFormatter for MarkdownFormatter {
+ fn format(&self, report: &TestReport) -> String {
+ let mut buf = String::new();
+ buf.push_str(&format!("# Test Report: {}\n\n", report.suite_name));
+ buf.push_str("## Summary\n\n");
+ buf.push_str("| Total | Passed | Failed | Skipped | Pass Rate | Duration (ms) |\n");
+ buf.push_str("| --- | --- | --- | --- | --- | --- |\n");
+ buf.push_str(&format!(
+ "| {} | {} | {} | {} | {:.1}% | {} |\n\n",
+ report.total(),
+ report.passed(),
+ report.failed(),
+ report.skipped(),
+ report.pass_rate() * 100.0,
+ report.total_duration.as_millis()
+ ));
+
+ buf.push_str("## Results\n\n");
+ buf.push_str("| Status | Test Case | Duration (ms) | Error |\n");
+ buf.push_str("| --- | --- | --- | --- |\n");
+
+ for result in &report.results {
+ let status = match result.status {
+ TestStatus::Passed => "passed",
+ TestStatus::Failed => "failed",
+ TestStatus::Skipped => "skipped",
+ };
+ let error = result
+ .error
+ .as_deref()
+ .map(escape_markdown_cell)
+ .unwrap_or_else(|| "-".to_string());
+
+ buf.push_str(&format!(
+ "| {} | {} | {} | {} |\n",
+ status,
+ escape_markdown_cell(&result.name),
+ result.duration.as_millis(),
+ error
+ ));
+ }
+
+ buf
+ }
+}
+
+fn escape_markdown_cell(input: &str) -> String {
+ input.replace('|', "\\|").replace('\n', "
")
+}
diff --git a/tests/src/report/mod.rs b/tests/src/report/mod.rs
index c869a86fd..17f910747 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::{JsonFormatter, MarkdownFormatter, ReportFormatter, TextFormatter};
pub use types::{TestCaseResult, TestReport, TestStatus};
diff --git a/tests/tests/report_tests.rs b/tests/tests/report_tests.rs
index d52a9e9fa..bdd9dc953 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,
+ JsonFormatter, MarkdownFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport,
+ TestReportBuilder, TestStatus, TextFormatter,
};
// ---------------------------------------------------------------------------
@@ -273,6 +273,58 @@ fn json_formatter_includes_metadata() {
assert_eq!(parsed["results"][0]["metadata"]["key"], "val");
}
+// ===========================================================================
+// MarkdownFormatter
+// ===========================================================================
+
+#[test]
+fn markdown_formatter_contains_summary_and_results_table() {
+ let r = mixed_report();
+ let output = MarkdownFormatter.format(&r);
+
+ assert!(output.contains("# Test Report: mixed"));
+ assert!(output.contains("## Summary"));
+ assert!(output.contains("| Total | Passed | Failed | Skipped | Pass Rate | Duration (ms) |"));
+ assert!(output.contains("| 5 | 2 | 2 | 1 | 40.0% | 110 |"));
+ assert!(output.contains("## Results"));
+ assert!(output.contains("| Status | Test Case | Duration (ms) | Error |"));
+ assert!(output.contains("| failed | b | 50 | boom |"));
+ assert!(output.contains("| skipped | d | 0 | - |"));
+}
+
+#[test]
+fn markdown_formatter_escapes_special_cells() {
+ let report = TestReport {
+ suite_name: "pipes".into(),
+ results: vec![make_result(
+ "test | case",
+ TestStatus::Failed,
+ 12,
+ Some("line1\nline2 | detail"),
+ )],
+ total_duration: Duration::from_millis(12),
+ timestamp: 0,
+ };
+
+ let output = MarkdownFormatter.format(&report);
+ assert!(output.contains("test \\| case"));
+ assert!(output.contains("line1
line2 \\| detail"));
+}
+
+#[test]
+fn markdown_formatter_empty_report_still_renders_tables() {
+ let r = TestReport {
+ suite_name: "empty".into(),
+ results: vec![],
+ total_duration: Duration::ZERO,
+ timestamp: 0,
+ };
+ let output = MarkdownFormatter.format(&r);
+ assert!(output.contains("# Test Report: empty"));
+ assert!(output.contains("| 0 | 0 | 0 | 0 | 100.0% | 0 |"));
+ assert!(output.contains("## Results"));
+}
+
// ===========================================================================
// TextFormatter
// ===========================================================================