diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 3368dc49b..d34acd570 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -15,6 +15,7 @@ pub use backend::MockLLMBackend; pub use bus::MockAgentBus; pub use clock::{Clock, MockClock, SystemClock}; pub use report::{ + AllureExporter, AllureLabel, AllureParameter, AllureStatusDetails, AllureTestResult, JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder, TestStatus, TextFormatter, }; diff --git a/tests/src/report/format.rs b/tests/src/report/format.rs index 919cae59d..ee98a1feb 100644 --- a/tests/src/report/format.rs +++ b/tests/src/report/format.rs @@ -1,12 +1,56 @@ //! Formatters that render a [`TestReport`] to a string. use crate::report::types::{TestReport, TestStatus}; +use serde::Serialize; /// Converts a [`TestReport`] into a displayable string. pub trait ReportFormatter: Send + Sync { fn format(&self, report: &TestReport) -> String; } +fn allure_status(status: &TestStatus) -> &'static str { + match status { + TestStatus::Passed => "passed", + TestStatus::Failed => "failed", + TestStatus::Skipped => "skipped", + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AllureLabel { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AllureParameter { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllureStatusDetails { + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AllureTestResult { + pub uuid: String, + pub history_id: String, + pub name: String, + pub full_name: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_details: Option, + pub labels: Vec, + pub parameters: Vec, + pub start: u64, + pub stop: u64, +} + /// Renders a report as a JSON object. pub struct JsonFormatter; @@ -102,3 +146,58 @@ impl ReportFormatter for TextFormatter { buf } } + +/// Exports a [`TestReport`] as Allure-compatible test result payloads. +pub struct AllureExporter; + +impl AllureExporter { + pub fn export(&self, report: &TestReport) -> Vec { + report + .results + .iter() + .map(|result| { + let full_name = format!("{}::{}", report.suite_name, result.name); + let start = report.timestamp; + let stop = report.timestamp + result.duration.as_millis() as u64; + + AllureTestResult { + uuid: full_name.clone(), + history_id: full_name.clone(), + name: result.name.clone(), + full_name, + status: allure_status(&result.status).to_string(), + status_details: result.error.as_ref().map(|message| AllureStatusDetails { + message: Some(message.clone()), + }), + labels: vec![ + AllureLabel { + name: "suite".into(), + value: report.suite_name.clone(), + }, + AllureLabel { + name: "framework".into(), + value: "mofa-testing".into(), + }, + ], + parameters: result + .metadata + .iter() + .map(|(key, value)| AllureParameter { + name: key.clone(), + value: value.clone(), + }) + .collect(), + start, + stop, + } + }) + .collect() + } + + pub fn export_json(&self, report: &TestReport) -> Result, serde_json::Error> { + self.export(report) + .into_iter() + .map(|result| serde_json::to_string_pretty(&result)) + .collect() + } +} diff --git a/tests/src/report/mod.rs b/tests/src/report/mod.rs index c869a86fd..6c0f84880 100644 --- a/tests/src/report/mod.rs +++ b/tests/src/report/mod.rs @@ -5,5 +5,8 @@ mod format; mod types; pub use builder::TestReportBuilder; -pub use format::{JsonFormatter, ReportFormatter, TextFormatter}; +pub use format::{ + AllureExporter, AllureLabel, AllureParameter, AllureStatusDetails, AllureTestResult, + 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..a8b8f55d1 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, + AllureExporter, JsonFormatter, MockClock, ReportFormatter, TestCaseResult, TestReport, + TestReportBuilder, TestStatus, TextFormatter, }; // --------------------------------------------------------------------------- @@ -319,3 +319,102 @@ fn text_formatter_empty_report() { assert!(output.contains("Total: 0")); assert!(output.contains("Pass rate: 100.0%")); } + +// =========================================================================== +// AllureExporter +// =========================================================================== + +#[test] +fn allure_exporter_simple_pass_case() { + let report = TestReport { + suite_name: "suite".into(), + results: vec![make_result("pass_case", TestStatus::Passed, 15, None)], + total_duration: Duration::from_millis(15), + timestamp: 200, + }; + + let results = AllureExporter.export(&report); + assert_eq!(results.len(), 1); + let result = &results[0]; + assert_eq!(result.status, "passed"); + assert_eq!(result.uuid, "suite::pass_case"); + assert_eq!(result.history_id, "suite::pass_case"); + assert_eq!(result.start, 200); + assert_eq!(result.stop, 215); + assert!(result.status_details.is_none()); +} + +#[test] +fn allure_exporter_failure_with_message() { + let report = TestReport { + suite_name: "suite".into(), + results: vec![make_result( + "fail_case", + TestStatus::Failed, + 10, + Some("kaboom"), + )], + total_duration: Duration::from_millis(10), + timestamp: 500, + }; + + let results = AllureExporter.export(&report); + assert_eq!(results[0].status, "failed"); + assert_eq!( + results[0] + .status_details + .as_ref() + .and_then(|d| d.message.as_deref()), + Some("kaboom") + ); +} + +#[test] +fn allure_exporter_skipped_case() { + let report = TestReport { + suite_name: "suite".into(), + results: vec![make_result("skip_case", TestStatus::Skipped, 0, None)], + total_duration: Duration::ZERO, + timestamp: 42, + }; + + let results = AllureExporter.export(&report); + assert_eq!(results[0].status, "skipped"); + assert_eq!(results[0].start, 42); + assert_eq!(results[0].stop, 42); +} + +#[test] +fn allure_exporter_metadata_as_parameters() { + 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_suite".into(), + results: vec![tc], + total_duration: Duration::from_millis(5), + timestamp: 9, + }; + + let results = AllureExporter.export(&report); + assert_eq!(results[0].parameters.len(), 2); + assert_eq!(results[0].parameters[0].name, "browser"); + assert_eq!(results[0].parameters[0].value, "webkit"); + assert_eq!(results[0].parameters[1].name, "env"); + assert_eq!(results[0].parameters[1].value, "ci"); + assert_eq!(results[0].labels[0].name, "suite"); + assert_eq!(results[0].labels[0].value, "meta_suite"); +} + +#[test] +fn allure_exporter_json_output_is_deterministic() { + let report = mixed_report(); + let first = AllureExporter.export_json(&report).expect("json export"); + let second = AllureExporter.export_json(&report).expect("json export"); + assert_eq!(first, second); + + let parsed: serde_json::Value = serde_json::from_str(&first[1]).expect("valid JSON"); + assert_eq!(parsed["status"], "failed"); + assert_eq!(parsed["fullName"], "mixed::b"); + assert_eq!(parsed["statusDetails"]["message"], "boom"); +}