Skip to main content

speccoder_runtime/verify/
junit.rs

1//! JUnit XML → per-test results: the common report format of
2//! nextest, pytest `--junitxml`, jest-junit and Surefire.
3
4use quick_xml::events::{BytesStart, Event};
5use quick_xml::{Reader, XmlVersion};
6use speccoder_core::verify::{TestOutcome, TestResult};
7
8/// Parses a JUnit report.
9pub fn parse(xml: &str) -> Result<Vec<TestResult>, String> {
10    let mut reader = Reader::from_str(xml);
11    let mut tests = Vec::new();
12    // The open <testcase>, if inside one.
13    let mut open: Option<TestResult> = None;
14    loop {
15        match reader
16            .read_event()
17            .map_err(|e| format!("invalid JUnit XML: {e}"))?
18        {
19            Event::Start(e) if e.name().as_ref() == "testcase" => {
20                open = Some(TestResult {
21                    id: test_id(&e)?,
22                    outcome: TestOutcome::Passed,
23                });
24            }
25            Event::Empty(e) if e.name().as_ref() == "testcase" => tests.push(TestResult {
26                id: test_id(&e)?,
27                outcome: TestOutcome::Passed,
28            }),
29            Event::Start(e) | Event::Empty(e) => {
30                if let Some(t) = open.as_mut() {
31                    match e.name().as_ref() {
32                        "failure" => t.outcome = TestOutcome::Failed,
33                        // An assertion failure says more than an error beside it.
34                        "error" if t.outcome != TestOutcome::Failed => {
35                            t.outcome = TestOutcome::Error;
36                        }
37                        "skipped" if !t.outcome.is_failing() => {
38                            t.outcome = TestOutcome::Skipped;
39                        }
40                        _ => {}
41                    }
42                }
43            }
44            Event::End(e) if e.name().as_ref() == "testcase" => {
45                tests.extend(open.take());
46            }
47            Event::Eof => break,
48            _ => {}
49        }
50    }
51    Ok(tests)
52}
53
54fn test_id(e: &BytesStart<'_>) -> Result<String, String> {
55    let attr = |key: &str| -> Result<Option<String>, String> {
56        match e
57            .try_get_attribute(key)
58            .map_err(|err| format!("invalid JUnit attribute: {err}"))?
59        {
60            Some(a) => a
61                .normalized_value(XmlVersion::default())
62                .map(|v| Some(v.into_owned()))
63                .map_err(|err| format!("invalid JUnit attribute: {err}")),
64            None => Ok(None),
65        }
66    };
67    let name = attr("name")?.ok_or("a <testcase> has no name")?;
68    Ok(match attr("classname")? {
69        Some(class) if !class.is_empty() => format!("{class}::{name}"),
70        _ => name,
71    })
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn reads_nextest_style_reports() {
80        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
81<testsuites name="nextest-run" tests="3" failures="1" errors="0">
82  <testsuite name="speccoder-core" tests="3" failures="1">
83    <testcase name="graph::tests::ok" classname="speccoder-core" timestamp="x" time="0.01"/>
84    <testcase name="graph::tests::bad" classname="speccoder-core" time="0.01">
85      <failure type="test failure">assertion failed &amp; more</failure>
86      <system-out>out</system-out>
87    </testcase>
88    <testcase name="graph::tests::later" classname="speccoder-core"><skipped/></testcase>
89  </testsuite>
90</testsuites>"#;
91        let tests = parse(xml).unwrap();
92        let got: Vec<_> = tests.iter().map(|t| (t.id.as_str(), t.outcome)).collect();
93        assert_eq!(
94            got,
95            [
96                ("speccoder-core::graph::tests::ok", TestOutcome::Passed),
97                ("speccoder-core::graph::tests::bad", TestOutcome::Failed),
98                ("speccoder-core::graph::tests::later", TestOutcome::Skipped),
99            ]
100        );
101    }
102
103    #[test]
104    fn reads_pytest_style_reports_with_errors() {
105        let xml = r#"<testsuites><testsuite name="pytest"><testcase classname="tests.test_api" name="test_login"><error message="boom"/></testcase><testcase name="test_bare"/></testsuite></testsuites>"#;
106        let tests = parse(xml).unwrap();
107        assert_eq!(tests[0].id, "tests.test_api::test_login");
108        assert_eq!(tests[0].outcome, TestOutcome::Error);
109        assert_eq!(tests[1].id, "test_bare");
110    }
111
112    #[test]
113    fn a_failure_beside_an_error_is_a_failure() {
114        let xml = r#"<testsuite><testcase name="a"><error/><failure/></testcase><testcase name="b"><failure/><error/></testcase></testsuite>"#;
115        let tests = parse(xml).unwrap();
116        assert!(tests.iter().all(|t| t.outcome == TestOutcome::Failed));
117    }
118
119    #[test]
120    fn rejects_malformed_xml() {
121        assert!(parse("<testsuite><testcase name=\"a\"></testsuite>").is_err());
122    }
123}