Skip to main content

api_testing_core/suite/
junit.rs

1use std::path::Path;
2
3use anyhow::Context;
4
5use crate::Result;
6use crate::suite::results::SuiteRunResults;
7
8fn xml_escape(s: &str) -> String {
9    s.replace('&', "&")
10        .replace('<', "&lt;")
11        .replace('>', "&gt;")
12        .replace('"', "&quot;")
13        .replace('\'', "&apos;")
14}
15
16pub fn render_junit_xml(results: &SuiteRunResults) -> String {
17    let suite_name = if results.suite.trim().is_empty() {
18        "suite".to_string()
19    } else {
20        results.suite.clone()
21    };
22
23    let mut out = String::new();
24    out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
25    out.push_str(&format!(
26        "<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">\n",
27        xml_escape(&suite_name),
28        results.summary.total,
29        results.summary.failed,
30        results.summary.skipped
31    ));
32
33    for case in &results.cases {
34        let seconds = (case.duration_ms as f64) / 1000.0;
35        out.push_str(&format!(
36            "  <testcase name=\"{}\" classname=\"{}\" time=\"{:.3}\">",
37            xml_escape(&case.id),
38            xml_escape(&case.case_type),
39            seconds
40        ));
41
42        let message = case.message.clone().unwrap_or_default();
43        let message_trim = message.trim();
44        match case.status.as_str() {
45            "skipped" => {
46                out.push_str(&format!(
47                    "<skipped message=\"{}\"/>",
48                    xml_escape(message_trim)
49                ));
50            }
51            "failed" => {
52                let failure_message = if message_trim.is_empty() {
53                    "failed"
54                } else {
55                    message_trim
56                };
57                out.push_str(&format!(
58                    "<failure message=\"{}\">",
59                    xml_escape(failure_message)
60                ));
61                let mut detail = String::new();
62                if let Some(cmd) = &case.command {
63                    detail.push_str(&format!("command: {cmd}\n"));
64                }
65                if let Some(p) = &case.stdout_file {
66                    detail.push_str(&format!("stdoutFile: {p}\n"));
67                }
68                if let Some(p) = &case.stderr_file {
69                    detail.push_str(&format!("stderrFile: {p}\n"));
70                }
71                out.push_str(&xml_escape(&detail));
72                out.push_str("</failure>");
73            }
74            _ => {}
75        }
76
77        out.push_str("</testcase>\n");
78    }
79
80    out.push_str("</testsuite>\n");
81    out
82}
83
84pub fn write_junit_file(results: &SuiteRunResults, path: &Path) -> Result<()> {
85    let xml = render_junit_xml(results);
86    let Some(parent) = path.parent() else {
87        anyhow::bail!("invalid junit path: {}", path.display());
88    };
89    std::fs::create_dir_all(parent)
90        .with_context(|| format!("create directory: {}", parent.display()))?;
91    std::fs::write(path, xml).with_context(|| format!("write junit file: {}", path.display()))?;
92    Ok(())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn junit_emits_basic_structure() {
101        let results = SuiteRunResults {
102            version: 1,
103            suite: "smoke".to_string(),
104            suite_file: "tests/api/suites/smoke.suite.json".to_string(),
105            run_id: "20260131-000000Z".to_string(),
106            started_at: "2026-01-31T00:00:00Z".to_string(),
107            finished_at: "2026-01-31T00:00:01Z".to_string(),
108            output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
109            summary: crate::suite::results::SuiteRunSummary {
110                total: 1,
111                passed: 0,
112                failed: 1,
113                skipped: 0,
114            },
115            cases: vec![crate::suite::results::SuiteCaseResult {
116                id: "case".to_string(),
117                case_type: "rest".to_string(),
118                status: "failed".to_string(),
119                duration_ms: 10,
120                tags: vec![],
121                command: Some("api-rest call".to_string()),
122                message: Some("rest_runner_failed".to_string()),
123                assertions: None,
124                stdout_file: Some("out/x.response.json".to_string()),
125                stderr_file: Some("out/x.stderr.log".to_string()),
126            }],
127        };
128
129        let xml = render_junit_xml(&results);
130        assert!(xml.contains("<testsuite"));
131        assert!(xml.contains("<testcase"));
132        assert!(xml.contains("<failure"));
133    }
134}