use std::fmt;
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceLocation {
pub file: String,
pub line: u32,
pub column: Option<u32>,
}
impl fmt::Display for SourceLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.column {
Some(col) => write!(f, "{}:{}:{}", self.file, self.line, col),
None => write!(f, "{}:{}", self.file, self.line),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestStatus {
Passed,
Failed {
reason: String,
location: Option<SourceLocation>,
},
Skipped {
reason: Option<String>,
},
TimedOut {
duration: Duration,
location: Option<SourceLocation>,
},
}
impl TestStatus {
pub fn is_passed(&self) -> bool {
matches!(self, TestStatus::Passed)
}
pub fn is_failed(&self) -> bool {
matches!(self, TestStatus::Failed { .. } | TestStatus::TimedOut { .. })
}
pub fn is_skipped(&self) -> bool {
matches!(self, TestStatus::Skipped { .. })
}
}
impl fmt::Display for TestStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TestStatus::Passed => write!(f, "PASSED"),
TestStatus::Failed { reason, .. } => write!(f, "FAILED: {reason}"),
TestStatus::Skipped { reason: Some(r) } => write!(f, "SKIPPED: {r}"),
TestStatus::Skipped { reason: None } => write!(f, "SKIPPED"),
TestStatus::TimedOut { duration, .. } => {
write!(f, "TIMED OUT after {duration:?}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct TestCase {
pub name: String,
pub suite: Option<String>,
pub tags: Vec<String>,
pub status: TestStatus,
pub duration: Duration,
pub assertions: u64,
pub location: Option<SourceLocation>,
pub parameters: Vec<(String, String)>,
pub captured_output: Option<String>,
}
impl TestCase {
pub fn new(name: impl Into<String>) -> Self {
TestCase {
name: name.into(),
suite: None,
tags: Vec::new(),
status: TestStatus::Passed,
duration: Duration::ZERO,
assertions: 0,
location: None,
parameters: Vec::new(),
captured_output: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestKind {
Unit,
Integration,
Doc,
}
#[derive(Debug, Clone)]
pub struct TestSuite {
pub name: String,
pub description: Option<String>,
pub tests: Vec<TestCase>,
pub duration: Duration,
pub kind: TestKind,
pub source_path: String,
}
impl TestSuite {
pub fn new(name: impl Into<String>) -> Self {
TestSuite {
name: name.into(),
description: None,
tests: Vec::new(),
duration: Duration::ZERO,
kind: TestKind::Unit,
source_path: String::new(),
}
}
pub fn is_doc(&self) -> bool {
self.kind == TestKind::Doc
}
pub fn len(&self) -> usize {
self.tests.len()
}
pub fn is_empty(&self) -> bool {
self.tests.is_empty()
}
pub fn passed(&self) -> impl Iterator<Item = &TestCase> {
self.tests.iter().filter(|t| t.status.is_passed())
}
pub fn failed(&self) -> impl Iterator<Item = &TestCase> {
self.tests.iter().filter(|t| t.status.is_failed())
}
pub fn skipped(&self) -> impl Iterator<Item = &TestCase> {
self.tests.iter().filter(|t| t.status.is_skipped())
}
pub fn success(&self) -> bool {
self.failed().count() == 0
}
pub fn assert_all_pass(&self) {
let failed: Vec<&TestCase> = self.failed().collect();
if !failed.is_empty() {
let mut msg = format!(
"{} test(s) failed in suite '{}':\n",
failed.len(),
self.name,
);
for t in &failed {
let dur_ms = t.duration.as_secs_f64() * 1000.0;
let reason = match &t.status {
TestStatus::Failed { reason, .. } => reason.as_str(),
TestStatus::TimedOut { .. } => "timed out",
_ => "unknown",
};
msg.push_str(&format!(" ✗ {} [{dur_ms:.1}ms] — {reason}\n", t.name));
}
panic!("{msg}");
}
}
}
#[derive(Debug, Clone)]
pub struct TestRun {
pub suites: Vec<TestSuite>,
pub start_time: SystemTime,
pub end_time: SystemTime,
pub duration: Duration,
}
impl TestRun {
pub fn new() -> Self {
TestRun {
suites: Vec::new(),
start_time: SystemTime::now(),
end_time: SystemTime::now(),
duration: Duration::ZERO,
}
}
pub fn total(&self) -> usize {
self.suites.iter().map(|s| s.tests.len()).sum()
}
pub fn total_passed(&self) -> usize {
self.suites.iter().flat_map(|s| s.tests.iter()).filter(|t| t.status.is_passed()).count()
}
pub fn total_failed(&self) -> usize {
self.suites.iter().flat_map(|s| s.tests.iter()).filter(|t| t.status.is_failed()).count()
}
pub fn total_skipped(&self) -> usize {
self.suites.iter().flat_map(|s| s.tests.iter()).filter(|t| t.status.is_skipped()).count()
}
pub fn success(&self) -> bool {
self.total_failed() == 0
}
pub fn all_failed(&self) -> impl Iterator<Item = &TestCase> {
self.suites.iter().flat_map(|s| s.failed())
}
pub fn slowest(&self, n: usize) -> Vec<&TestCase> {
let mut all: Vec<&TestCase> = self.suites.iter().flat_map(|s| s.tests.iter()).collect();
all.sort_by(|a, b| b.duration.cmp(&a.duration));
all.truncate(n);
all
}
}
impl Default for TestRun {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReportFormat {
#[default]
Pretty,
Tap,
Junit,
Json,
Compact,
Github,
}
impl std::str::FromStr for ReportFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"pretty" | "human" => Ok(Self::Pretty),
"tap" => Ok(Self::Tap),
"junit" | "xml" => Ok(Self::Junit),
"json" => Ok(Self::Json),
"compact" => Ok(Self::Compact),
"github" | "gh" => Ok(Self::Github),
_ => Err(format!("unknown report format: {s}")),
}
}
}
#[derive(Debug, Clone)]
pub struct RunnerConfig {
pub filter: Option<String>,
pub include_tags: Vec<String>,
pub exclude_tags: Vec<String>,
pub default_retries: u32,
pub default_timeout: Option<Duration>,
pub parallel: bool,
pub max_threads: usize,
pub format: ReportFormat,
pub fail_fast: bool,
pub seed: Option<u64>,
pub verbose: bool,
pub output_capture: bool,
}
impl Default for RunnerConfig {
fn default() -> Self {
RunnerConfig {
filter: None,
include_tags: Vec::new(),
exclude_tags: Vec::new(),
default_retries: 0,
default_timeout: None,
parallel: true,
max_threads: num_cpus(),
format: ReportFormat::Pretty,
fail_fast: false,
seed: None,
verbose: false,
output_capture: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CoverageFormat {
#[default]
Summary,
Html,
Lcov,
Json,
Cobertura,
}
impl std::str::FromStr for CoverageFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"summary" | "text" => Ok(Self::Summary),
"html" => Ok(Self::Html),
"lcov" | "tracefile" => Ok(Self::Lcov),
"json" => Ok(Self::Json),
"cobertura" | "xml" => Ok(Self::Cobertura),
_ => Err(format!("unknown coverage format: {s}")),
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub line_coverage: f64,
pub function_coverage: f64,
pub region_coverage: f64,
pub format: CoverageFormat,
pub report_path: Option<std::path::PathBuf>,
}
fn num_cpus() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
mod test_status {
use super::*;
#[test]
fn passed_is_passed_true() {
assert!(TestStatus::Passed.is_passed());
}
#[test]
fn passed_is_failed_false() {
assert!(!TestStatus::Passed.is_failed());
}
#[test]
fn passed_is_skipped_false() {
assert!(!TestStatus::Passed.is_skipped());
}
#[test]
fn failed_is_failed_true() {
let s = TestStatus::Failed { reason: "x".into(), location: None };
assert!(s.is_failed());
}
#[test]
fn failed_is_passed_false() {
let s = TestStatus::Failed { reason: "x".into(), location: None };
assert!(!s.is_passed());
}
#[test]
fn skipped_is_skipped_true() {
let s = TestStatus::Skipped { reason: None };
assert!(s.is_skipped());
}
#[test]
fn timed_out_is_failed() {
let s = TestStatus::TimedOut { duration: Duration::from_secs(1), location: None };
assert!(s.is_failed());
}
#[test]
fn display_passed() {
assert_eq!(format!("{}", TestStatus::Passed), "PASSED");
}
#[test]
fn display_failed() {
let s = TestStatus::Failed { reason: "boom".into(), location: None };
assert_eq!(format!("{}", s), "FAILED: boom");
}
#[test]
fn display_skipped_no_reason() {
let s = TestStatus::Skipped { reason: None };
assert_eq!(format!("{}", s), "SKIPPED");
}
#[test]
fn display_skipped_with_reason() {
let s = TestStatus::Skipped { reason: Some("slow".into()) };
assert_eq!(format!("{}", s), "SKIPPED: slow");
}
#[test]
fn display_timed_out() {
let s = TestStatus::TimedOut { duration: Duration::from_secs(5), location: None };
let text = format!("{}", s);
assert!(text.contains("TIMED OUT"));
assert!(text.contains("5s"));
}
}
mod source_location {
use super::*;
#[test]
fn display_with_column() {
let loc = SourceLocation { file: "src/lib.rs".into(), line: 42, column: Some(7) };
assert_eq!(format!("{}", loc), "src/lib.rs:42:7");
}
#[test]
fn display_without_column() {
let loc = SourceLocation { file: "src/lib.rs".into(), line: 42, column: None };
assert_eq!(format!("{}", loc), "src/lib.rs:42");
}
}
mod test_case {
use super::*;
#[test]
fn new_creates_passed() {
let tc = TestCase::new("my test");
assert_eq!(tc.name, "my test");
assert!(tc.status.is_passed());
assert_eq!(tc.duration, Duration::ZERO);
}
#[test]
fn new_has_no_suite() {
let tc = TestCase::new("x");
assert!(tc.suite.is_none());
}
#[test]
fn new_empty_tags() {
let tc = TestCase::new("x");
assert!(tc.tags.is_empty());
}
}
mod test_suite {
use super::*;
fn sample_suite() -> TestSuite {
let mut suite = TestSuite::new("Math");
suite.tests.push(TestCase {
name: "add".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Passed, duration: Duration::from_millis(5),
assertions: 0, location: None, parameters: vec![], captured_output: None,
});
suite.tests.push(TestCase {
name: "sub".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Failed { reason: "expected 2 got 3".into(), location: None },
duration: Duration::from_millis(3), assertions: 0, location: None, parameters: vec![],
captured_output: None,
});
suite.tests.push(TestCase {
name: "skip".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Skipped { reason: None },
duration: Duration::ZERO, assertions: 0, location: None, parameters: vec![],
captured_output: None,
});
suite
}
#[test]
fn len_counts_tests() {
assert_eq!(sample_suite().len(), 3);
}
#[test]
fn passed_returns_only_passed() {
assert_eq!(sample_suite().passed().count(), 1);
}
#[test]
fn failed_returns_only_failed() {
assert_eq!(sample_suite().failed().count(), 1);
}
#[test]
fn skipped_returns_only_skipped() {
assert_eq!(sample_suite().skipped().count(), 1);
}
#[test]
fn success_false_when_failures() {
assert!(!sample_suite().success());
}
#[test]
fn success_true_when_all_pass() {
let mut suite = TestSuite::new("AllGood");
suite.tests.push(TestCase::new("t1"));
assert!(suite.success());
}
#[test]
fn empty_suite_is_empty() {
let suite = TestSuite::new("Empty");
assert!(suite.is_empty());
}
#[test]
fn is_doc_false_by_default() {
let suite = TestSuite::new("x");
assert!(!suite.is_doc());
}
#[test]
fn is_doc_true_when_kind_doc() {
let mut suite = TestSuite::new("x");
suite.kind = TestKind::Doc;
assert!(suite.is_doc());
}
}
mod test_run {
use super::*;
fn sample_run() -> TestRun {
let mut suite = TestSuite::new("A");
suite.tests.push(TestCase { name: "t1".into(), suite: None, tags: vec![],
status: TestStatus::Passed, duration: Duration::from_millis(1),
assertions: 0, location: None, parameters: vec![], captured_output: None });
suite.tests.push(TestCase { name: "t2".into(), suite: None, tags: vec![],
status: TestStatus::Failed { reason: "fail".into(), location: None },
duration: Duration::from_millis(2), assertions: 0, location: None, parameters: vec![], captured_output: None });
suite.tests.push(TestCase { name: "t3".into(), suite: None, tags: vec![],
status: TestStatus::Skipped { reason: None },
duration: Duration::ZERO, assertions: 0, location: None, parameters: vec![], captured_output: None });
TestRun { suites: vec![suite], start_time: SystemTime::now(),
end_time: SystemTime::now(), duration: Duration::from_millis(10) }
}
#[test]
fn total_counts_all() {
assert_eq!(sample_run().total(), 3);
}
#[test]
fn total_passed() {
assert_eq!(sample_run().total_passed(), 1);
}
#[test]
fn total_failed() {
assert_eq!(sample_run().total_failed(), 1);
}
#[test]
fn total_skipped() {
assert_eq!(sample_run().total_skipped(), 1);
}
#[test]
fn success_false_with_failures() {
assert!(!sample_run().success());
}
#[test]
fn success_true_all_pass() {
let run = TestRun::new();
assert!(run.success());
}
#[test]
fn slowest_returns_n_longest() {
let run = sample_run();
let slow = run.slowest(2);
assert_eq!(slow.len(), 2);
assert_eq!(slow[0].name, "t2");
}
#[test]
fn slowest_returns_all_when_n_larger() {
let run = sample_run();
let slow = run.slowest(10);
assert_eq!(slow.len(), 3);
}
#[test]
fn all_failed_iter() {
let run = sample_run();
let failed: Vec<_> = run.all_failed().collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].name, "t2");
}
#[test]
fn default_run_empty() {
let run = TestRun::default();
assert!(run.suites.is_empty());
assert!(run.success());
}
}
mod report_format {
use super::*;
#[test]
fn parse_pretty() {
assert_eq!("pretty".parse::<ReportFormat>().unwrap(), ReportFormat::Pretty);
assert_eq!("human".parse::<ReportFormat>().unwrap(), ReportFormat::Pretty);
}
#[test]
fn parse_tap() {
assert_eq!("tap".parse::<ReportFormat>().unwrap(), ReportFormat::Tap);
}
#[test]
fn parse_junit() {
assert_eq!("junit".parse::<ReportFormat>().unwrap(), ReportFormat::Junit);
assert_eq!("xml".parse::<ReportFormat>().unwrap(), ReportFormat::Junit);
}
#[test]
fn parse_json() {
assert_eq!("json".parse::<ReportFormat>().unwrap(), ReportFormat::Json);
}
#[test]
fn parse_compact() {
assert_eq!("compact".parse::<ReportFormat>().unwrap(), ReportFormat::Compact);
}
#[test]
fn parse_github() {
assert_eq!("github".parse::<ReportFormat>().unwrap(), ReportFormat::Github);
assert_eq!("gh".parse::<ReportFormat>().unwrap(), ReportFormat::Github);
}
#[test]
fn parse_unknown_error() {
assert!("wtf".parse::<ReportFormat>().is_err());
}
#[test]
fn parse_is_case_insensitive() {
assert_eq!("JSON".parse::<ReportFormat>().unwrap(), ReportFormat::Json);
assert_eq!("Pretty".parse::<ReportFormat>().unwrap(), ReportFormat::Pretty);
}
#[test]
fn default_is_pretty() {
assert_eq!(ReportFormat::default(), ReportFormat::Pretty);
}
}
mod coverage_format {
use super::*;
#[test]
fn parse_summary() {
assert_eq!("summary".parse::<CoverageFormat>().unwrap(), CoverageFormat::Summary);
assert_eq!("text".parse::<CoverageFormat>().unwrap(), CoverageFormat::Summary);
}
#[test]
fn parse_html() {
assert_eq!("html".parse::<CoverageFormat>().unwrap(), CoverageFormat::Html);
}
#[test]
fn parse_lcov() {
assert_eq!("lcov".parse::<CoverageFormat>().unwrap(), CoverageFormat::Lcov);
assert_eq!("tracefile".parse::<CoverageFormat>().unwrap(), CoverageFormat::Lcov);
}
#[test]
fn parse_json() {
assert_eq!("json".parse::<CoverageFormat>().unwrap(), CoverageFormat::Json);
}
#[test]
fn parse_cobertura() {
assert_eq!("cobertura".parse::<CoverageFormat>().unwrap(), CoverageFormat::Cobertura);
assert_eq!("xml".parse::<CoverageFormat>().unwrap(), CoverageFormat::Cobertura);
}
#[test]
fn parse_unknown_error() {
assert!("bogus".parse::<CoverageFormat>().is_err());
}
#[test]
fn default_is_summary() {
assert_eq!(CoverageFormat::default(), CoverageFormat::Summary);
}
}
mod runner_config {
use super::*;
#[test]
fn default_parallel_true() {
assert!(RunnerConfig::default().parallel);
}
#[test]
fn default_format_pretty() {
assert_eq!(RunnerConfig::default().format, ReportFormat::Pretty);
}
#[test]
fn default_no_filter() {
assert!(RunnerConfig::default().filter.is_none());
}
#[test]
fn default_no_tags() {
let cfg = RunnerConfig::default();
assert!(cfg.include_tags.is_empty());
assert!(cfg.exclude_tags.is_empty());
}
#[test]
fn default_zero_retries() {
assert_eq!(RunnerConfig::default().default_retries, 0);
}
#[test]
fn coverage_report_with_path() {
let report = CoverageReport {
line_coverage: 80.0,
function_coverage: 90.0,
region_coverage: 80.0,
format: CoverageFormat::Html,
report_path: Some(std::path::PathBuf::from("report.html")),
};
assert_eq!(report.format, CoverageFormat::Html);
assert_eq!(report.report_path.unwrap().to_str().unwrap(), "report.html");
}
}
}