Archived
320 lines
11 KiB
Rust
320 lines
11 KiB
Rust
//! Test report formatting — human-readable, JSON, and JUnit XML output.
|
|
|
|
use crate::types::{TestResult, TestStatus};
|
|
|
|
/// Aggregated report for a set of test runs.
|
|
pub struct TestReport {
|
|
pub total: u32,
|
|
pub passed: u32,
|
|
pub failed: u32,
|
|
pub skipped: u32,
|
|
pub errors: u32,
|
|
pub duration_ms: u64,
|
|
pub results: Vec<TestResult>,
|
|
}
|
|
|
|
impl TestReport {
|
|
/// Build a report from a slice of individual test results.
|
|
pub fn from_results(results: Vec<TestResult>) -> Self {
|
|
let total = results.len() as u32;
|
|
let passed = results.iter().filter(|r| r.status == TestStatus::Pass).count() as u32;
|
|
let failed = results.iter().filter(|r| r.status == TestStatus::Fail).count() as u32;
|
|
let skipped = results.iter().filter(|r| r.status == TestStatus::Skip).count() as u32;
|
|
let errors = results.iter().filter(|r| r.status == TestStatus::Error).count() as u32;
|
|
let duration_ms = results.iter().map(|r| r.duration_ms).sum();
|
|
Self { total, passed, failed, skipped, errors, duration_ms, results }
|
|
}
|
|
|
|
/// Print a human-readable summary to stdout.
|
|
pub fn print(&self) {
|
|
let target_label = format!("({}ms total)", self.duration_ms);
|
|
|
|
println!("\nRunning {} tests...\n", self.total);
|
|
|
|
for r in &self.results {
|
|
let icon = match r.status {
|
|
TestStatus::Pass => " ok ",
|
|
TestStatus::Fail => " FAIL ",
|
|
TestStatus::Skip => " SKIP ",
|
|
TestStatus::Error => "ERROR ",
|
|
};
|
|
println!(" [{icon}] {} ({}ms)", r.name, r.duration_ms);
|
|
|
|
// Show failing assertions
|
|
if r.status == TestStatus::Fail {
|
|
for (i, a) in r.assertions.iter().enumerate() {
|
|
if !a.passed {
|
|
println!(" assert {}", a.expression);
|
|
if let Some(actual) = &a.actual {
|
|
println!(" actual: {actual}");
|
|
}
|
|
if let Some(expected) = &a.expected {
|
|
println!(" expected: {expected}");
|
|
}
|
|
println!(" at assertion {}", i + 1);
|
|
}
|
|
}
|
|
}
|
|
if let Some(err) = &r.error {
|
|
println!(" error: {err}");
|
|
}
|
|
}
|
|
|
|
println!("\nResults: {} passed, {} failed, {} skipped {}", self.passed, self.failed, self.skipped, target_label);
|
|
if self.errors > 0 {
|
|
println!(" ({} error(s) — see above)", self.errors);
|
|
}
|
|
}
|
|
|
|
/// Serialize to JSON.
|
|
pub fn to_json(&self) -> String {
|
|
let results: Vec<serde_json::Value> = self
|
|
.results
|
|
.iter()
|
|
.map(|r| {
|
|
let assertions: Vec<serde_json::Value> = r
|
|
.assertions
|
|
.iter()
|
|
.map(|a| {
|
|
serde_json::json!({
|
|
"expression": a.expression,
|
|
"passed": a.passed,
|
|
"actual": a.actual,
|
|
"expected": a.expected,
|
|
})
|
|
})
|
|
.collect();
|
|
serde_json::json!({
|
|
"name": r.name,
|
|
"target": r.target.to_string(),
|
|
"status": r.status.to_string(),
|
|
"duration_ms": r.duration_ms,
|
|
"assertions": assertions,
|
|
"error": r.error,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let report = serde_json::json!({
|
|
"total": self.total,
|
|
"passed": self.passed,
|
|
"failed": self.failed,
|
|
"skipped": self.skipped,
|
|
"errors": self.errors,
|
|
"duration_ms": self.duration_ms,
|
|
"results": results,
|
|
});
|
|
|
|
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
|
|
/// Serialize to JUnit XML (for CI integration).
|
|
pub fn to_junit_xml(&self) -> String {
|
|
let mut xml = String::new();
|
|
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
xml.push_str(&format!(
|
|
"<testsuite name=\"el\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">\n",
|
|
self.total,
|
|
self.failed,
|
|
self.errors,
|
|
self.skipped,
|
|
self.duration_ms as f64 / 1000.0,
|
|
));
|
|
|
|
for r in &self.results {
|
|
let classname = "el_test";
|
|
let time = r.duration_ms as f64 / 1000.0;
|
|
let name_escaped = xml_escape(&r.name);
|
|
|
|
match r.status {
|
|
TestStatus::Pass => {
|
|
xml.push_str(&format!(
|
|
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\"/>\n"
|
|
));
|
|
}
|
|
TestStatus::Fail => {
|
|
xml.push_str(&format!(
|
|
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n"
|
|
));
|
|
for a in r.assertions.iter().filter(|a| !a.passed) {
|
|
let msg = xml_escape(&a.expression);
|
|
let details = match (&a.actual, &a.expected) {
|
|
(Some(act), Some(exp)) => format!("actual: {act}, expected: {exp}"),
|
|
(Some(act), None) => format!("actual: {act}"),
|
|
_ => "assertion failed".to_string(),
|
|
};
|
|
let details_esc = xml_escape(&details);
|
|
xml.push_str(&format!(
|
|
" <failure message=\"{msg}\">{details_esc}</failure>\n"
|
|
));
|
|
}
|
|
xml.push_str(" </testcase>\n");
|
|
}
|
|
TestStatus::Skip => {
|
|
xml.push_str(&format!(
|
|
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <skipped/>\n </testcase>\n"
|
|
));
|
|
}
|
|
TestStatus::Error => {
|
|
let err_msg = xml_escape(r.error.as_deref().unwrap_or("unknown error"));
|
|
xml.push_str(&format!(
|
|
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <error message=\"{err_msg}\"/>\n </testcase>\n"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
xml.push_str("</testsuite>\n");
|
|
xml
|
|
}
|
|
|
|
/// Whether the overall test run passed (no failures or errors).
|
|
pub fn is_pass(&self) -> bool {
|
|
self.failed == 0 && self.errors == 0
|
|
}
|
|
}
|
|
|
|
fn xml_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::{AssertionResult, TestTarget};
|
|
|
|
fn make_pass(name: &str) -> TestResult {
|
|
TestResult {
|
|
name: name.to_string(),
|
|
target: TestTarget::Unit,
|
|
status: TestStatus::Pass,
|
|
duration_ms: 5,
|
|
assertions: vec![AssertionResult {
|
|
expression: "x == 42".into(),
|
|
passed: true,
|
|
actual: Some("true".into()),
|
|
expected: None,
|
|
}],
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
fn make_fail(name: &str) -> TestResult {
|
|
TestResult {
|
|
name: name.to_string(),
|
|
target: TestTarget::Unit,
|
|
status: TestStatus::Fail,
|
|
duration_ms: 3,
|
|
assertions: vec![AssertionResult {
|
|
expression: "x == 99".into(),
|
|
passed: false,
|
|
actual: Some("42".into()),
|
|
expected: Some("99".into()),
|
|
}],
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
fn make_skip(name: &str) -> TestResult {
|
|
TestResult {
|
|
name: name.to_string(),
|
|
target: TestTarget::E2e,
|
|
status: TestStatus::Skip,
|
|
duration_ms: 0,
|
|
assertions: vec![],
|
|
error: Some("ENGRAM_URL not set".into()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_counts() {
|
|
let report = TestReport::from_results(vec![
|
|
make_pass("a"),
|
|
make_fail("b"),
|
|
make_skip("c"),
|
|
]);
|
|
assert_eq!(report.total, 3);
|
|
assert_eq!(report.passed, 1);
|
|
assert_eq!(report.failed, 1);
|
|
assert_eq!(report.skipped, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_is_pass() {
|
|
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
|
|
assert!(report.is_pass());
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_is_not_pass_on_fail() {
|
|
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
|
|
assert!(!report.is_pass());
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_json_valid() {
|
|
let report = TestReport::from_results(vec![make_pass("test")]);
|
|
let json = report.to_json();
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
|
|
assert_eq!(parsed["total"], 1);
|
|
assert_eq!(parsed["passed"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_json_contains_results() {
|
|
let report = TestReport::from_results(vec![make_pass("hello")]);
|
|
let json = report.to_json();
|
|
assert!(json.contains("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_junit_xml_valid() {
|
|
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
|
|
let xml = report.to_junit_xml();
|
|
assert!(xml.starts_with("<?xml"));
|
|
assert!(xml.contains("<testsuite"));
|
|
assert!(xml.contains("</testsuite>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_junit_xml_pass_testcase() {
|
|
let report = TestReport::from_results(vec![make_pass("arithmetic")]);
|
|
let xml = report.to_junit_xml();
|
|
assert!(xml.contains("arithmetic"));
|
|
assert!(!xml.contains("<failure"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_junit_xml_fail_testcase() {
|
|
let report = TestReport::from_results(vec![make_fail("failing")]);
|
|
let xml = report.to_junit_xml();
|
|
assert!(xml.contains("<failure"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_to_junit_xml_skipped() {
|
|
let report = TestReport::from_results(vec![make_skip("e2e")]);
|
|
let xml = report.to_junit_xml();
|
|
assert!(xml.contains("<skipped"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_duration_sum() {
|
|
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
|
|
assert_eq!(report.duration_ms, 10); // 5 + 5
|
|
}
|
|
|
|
#[test]
|
|
fn test_xml_escape() {
|
|
let escaped = super::xml_escape("a < b & c > d \"e\" 'f'");
|
|
assert!(escaped.contains("<"));
|
|
assert!(escaped.contains("&"));
|
|
assert!(escaped.contains(">"));
|
|
assert!(escaped.contains("""));
|
|
}
|
|
}
|