Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion crates/karva/src/commands/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use karva_cache::AggregatedResults;
use karva_cli::{OutputFormat, TestCommand};
use karva_logging::{Printer, Stdout, set_colored_override, setup_tracing};
use karva_metadata::filter::FiltersetSet;
use karva_metadata::{ProjectMetadata, ProjectOptionsOverrides};
use karva_metadata::{NoTestsMode, ProjectMetadata, ProjectOptionsOverrides};
use karva_project::Project;
use karva_project::path::absolute;
use karva_python_semantic::current_python_version;
Expand Down Expand Up @@ -87,13 +87,50 @@ pub fn test(args: TestCommand) -> Result<ExitStatus> {
durations,
)?;

if no_tests_matched_filters(&result) {
let has_filters = !sub_command.filter_expressions.is_empty();
match project.settings().test().no_tests {
NoTestsMode::Auto => {
if has_filters {
return Ok(ExitStatus::Success);
}
let mut stdout = printer.stream_for_failure_summary().lock();
writeln!(
stdout,
"error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)"
)?;
return Ok(ExitStatus::Failure);
}
NoTestsMode::Pass => return Ok(ExitStatus::Success),
NoTestsMode::Warn => {
let mut stdout = printer.stream_for_requested_summary().lock();
writeln!(stdout, "warning: no tests matched the provided filters")?;
return Ok(ExitStatus::Success);
}
NoTestsMode::Fail => {
let mut stdout = printer.stream_for_failure_summary().lock();
writeln!(
stdout,
"error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)"
)?;
return Ok(ExitStatus::Failure);
}
}
}

if result.stats.is_success() && result.discovery_diagnostics.is_empty() {
Ok(ExitStatus::Success)
} else {
Ok(ExitStatus::Failure)
}
}

fn no_tests_matched_filters(result: &AggregatedResults) -> bool {
result.stats.total() == 0
&& result.discovery_diagnostics.is_empty()
&& result.diagnostics.is_empty()
}

/// Print test output: diagnostics, durations, and result summary.
pub fn print_test_output(
printer: Printer,
Expand Down
15 changes: 9 additions & 6 deletions crates/karva/tests/it/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ fn test_empty_file() {
let context = TestContext::with_file("test.py", "");

assert_cmd_snapshot!(context.command(), @"
success: true
exit_code: 0
success: false
exit_code: 1
----- stdout -----
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)

----- stderr -----
");
Expand All @@ -54,11 +55,12 @@ fn test_empty_directory() {
let context = TestContext::new();

assert_cmd_snapshot!(context.command(), @"
success: true
exit_code: 0
success: false
exit_code: 1
----- stdout -----
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)

----- stderr -----
");
Expand Down Expand Up @@ -140,11 +142,12 @@ fn test_no_tests_found() {
let context = TestContext::with_file("test_no_tests.py", r"");

assert_cmd_snapshot!(context.command(), @"
success: true
exit_code: 0
success: false
exit_code: 1
----- stdout -----
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)

----- stderr -----
");
Expand Down
68 changes: 61 additions & 7 deletions crates/karva/tests/it/filterset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ def test_beta():
assert True
";

const NO_TESTS: &str = r"
def helper():
pass
";

#[test]
fn filterset_test_substring() {
let context = TestContext::with_file("test.py", TWO_TESTS);
Expand Down Expand Up @@ -74,20 +79,69 @@ fn filterset_test_multiple_flags_or_semantics() {

#[test]
fn filterset_test_no_matches() {
let context = TestContext::with_file("test.py", TWO_TESTS);
assert_cmd_snapshot!(context.command_no_parallel().arg("-E").arg("test(~nonexistent)"), @"
let context = TestContext::with_file("test.py", NO_TESTS);
assert_cmd_snapshot!(context.command_no_parallel(), @"
success: false
exit_code: 1
----- stdout -----
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
error: no tests matched the provided filters (use --no-tests=pass or --no-tests=warn)

----- stderr -----
");
}

#[test]
fn filterset_test_no_matches_auto_with_filter() {
let context = TestContext::with_file("test.py", NO_TESTS);
assert_cmd_snapshot!(
context.command_no_parallel().arg("-E").arg("test(~helper)"),
@"
success: true
exit_code: 0
----- stdout -----
Starting 2 tests across 1 worker
SKIP [TIME] test::test_alpha
SKIP [TIME] test::test_beta
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped

----- stderr -----
"
);
}

#[test]
fn filterset_test_no_matches_pass() {
let context = TestContext::with_file("test.py", NO_TESTS);
assert_cmd_snapshot!(
context.command_no_parallel().arg("--no-tests").arg("pass"),
@"
success: true
exit_code: 0
----- stdout -----
────────────
Summary [TIME] 2 tests run: 0 passed, 2 skipped
Summary [TIME] 0 tests run: 0 passed, 0 skipped

----- stderr -----
");
"
);
}

#[test]
fn filterset_test_no_matches_warn() {
let context = TestContext::with_file("test.py", NO_TESTS);
assert_cmd_snapshot!(
context.command_no_parallel().arg("--no-tests").arg("warn"),
@"
success: true
exit_code: 0
----- stdout -----
────────────
Summary [TIME] 0 tests run: 0 passed, 0 skipped
warning: no tests matched the provided filters

----- stderr -----
"
);
}

#[test]
Expand Down
57 changes: 56 additions & 1 deletion crates/karva_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use clap::Parser;
use clap::builder::Styles;
use clap::builder::styling::{AnsiColor, Effects};
use karva_logging::{TerminalColor, VerbosityLevel};
use karva_metadata::{MaxFail, Options, RunIgnoredMode, SrcOptions, TerminalOptions, TestOptions};
use karva_metadata::{
MaxFail, NoTestsMode, Options, RunIgnoredMode, SrcOptions, TerminalOptions, TestOptions,
};
use ruff_db::diagnostic::DiagnosticFormat;

const STYLES: Styles = Styles::styled()
Expand Down Expand Up @@ -207,6 +209,19 @@ pub struct SubTestCommand {
#[clap(short = 'E', long = "filter", help_heading = "Filter options")]
pub filter_expressions: Vec<String>,

/// Behavior when no tests are found to run [default: auto]
///
/// `auto` fails if no filter expressions were given, and passes silently
/// if filters were given (the filter may legitimately match nothing on
/// some platforms or configurations).
#[arg(
long,
value_name = "ACTION",
env = "KARVA_NO_TESTS",
help_heading = "Filter options"
)]
pub no_tests: Option<NoTests>,

/// Run ignored tests.
#[arg(long, help_heading = "Filter options")]
pub run_ignored: Option<RunIgnored>,
Expand Down Expand Up @@ -370,6 +385,7 @@ impl SubTestCommand {
max_fail,
try_import_fixtures: self.try_import_fixtures,
retry: self.retry,
no_tests: self.no_tests.map(Into::into),
}),
}
}
Expand Down Expand Up @@ -408,3 +424,42 @@ impl From<RunIgnored> for RunIgnoredMode {
}
}
}

/// Behavior when no tests match filters.
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum NoTests {
/// Automatically determine behavior: fail if no filter expressions were
/// given, pass silently if filters were given.
Auto,

/// Silently exit with code 0.
Pass,

/// Produce a warning and exit with code 0.
Warn,

/// Produce an error message and exit with a non-zero code.
Fail,
}

impl NoTests {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Pass => "pass",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
}

impl From<NoTests> for NoTestsMode {
fn from(value: NoTests) -> Self {
match value {
NoTests::Auto => Self::Auto,
NoTests::Pass => Self::Pass,
NoTests::Warn => Self::Warn,
NoTests::Fail => Self::Fail,
}
}
}
2 changes: 1 addition & 1 deletion crates/karva_metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub use options::{
Options, OutputFormat, ProjectOptionsOverrides, SrcOptions, TerminalOptions, TestOptions,
};
pub use pyproject::{PyProject, PyProjectError};
pub use settings::{ProjectSettings, RunIgnoredMode};
pub use settings::{NoTestsMode, ProjectSettings, RunIgnoredMode};

use crate::options::KarvaTomlError;

Expand Down
23 changes: 21 additions & 2 deletions crates/karva_metadata/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use thiserror::Error;
use crate::filter::FiltersetSet;
use crate::max_fail::MaxFail;
use crate::settings::{
ProjectSettings, RunIgnoredMode, SrcSettings, TerminalSettings, TestSettings,
NoTestsMode, ProjectSettings, RunIgnoredMode, SrcSettings, TerminalSettings, TestSettings,
};

#[derive(
Expand Down Expand Up @@ -215,6 +215,21 @@ pub struct TestOptions {
"#
)]
pub retry: Option<u32>,

/// Configures behavior when no tests are found to run.
///
/// `auto` (the default) fails when no filter expressions were given, and
/// passes silently when filters were given. Use `fail` to always fail,
/// `warn` to always warn, or `pass` to always succeed silently.
#[serde(skip_serializing_if = "Option::is_none")]
#[option(
default = r#"auto"#,
value_type = "auto | pass | warn | fail",
example = r#"
no-tests = "warn"
"#
)]
pub no_tests: Option<NoTestsMode>,
}

impl TestOptions {
Expand All @@ -234,6 +249,7 @@ impl TestOptions {
retry: self.retry.unwrap_or_default(),
filter: FiltersetSet::default(),
run_ignored: RunIgnoredMode::default(),
no_tests: self.no_tests.unwrap_or_default(),
}
}
}
Expand Down Expand Up @@ -364,7 +380,7 @@ nonsense = 42
|
4 | nonsense = 42
| ^^^^^^^^
unknown field `nonsense`, expected one of `test-function-prefix`, `fail-fast`, `max-fail`, `try-import-fixtures`, `retry`
unknown field `nonsense`, expected one of `test-function-prefix`, `fail-fast`, `max-fail`, `try-import-fixtures`, `retry`, `no-tests`
"
);
}
Expand Down Expand Up @@ -444,6 +460,7 @@ max-fail = 0
retry: Some(
5,
),
no_tests: None,
}
"#);
}
Expand All @@ -470,6 +487,7 @@ max-fail = 0
retry: Some(
3,
),
no_tests: None,
}
"#);
}
Expand Down Expand Up @@ -531,6 +549,7 @@ max-fail = 0
retry: Some(
2,
),
no_tests: None,
},
)
"#);
Expand Down
24 changes: 24 additions & 0 deletions crates/karva_metadata/src/settings.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use karva_combine::Combine;
use serde::{Deserialize, Serialize};

use crate::filter::FiltersetSet;
use crate::max_fail::MaxFail;
use crate::options::OutputFormat;
Expand All @@ -10,6 +13,26 @@ pub enum RunIgnoredMode {
All,
}

#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum NoTestsMode {
#[default]
Auto,
Pass,
Warn,
Fail,
}

impl Combine for NoTestsMode {
#[inline(always)]
fn combine_with(&mut self, _other: Self) {}

#[inline]
fn combine(self, _other: Self) -> Self {
self
}
}

#[derive(Default, Debug, Clone)]
pub struct ProjectSettings {
pub(crate) terminal: TerminalSettings,
Expand Down Expand Up @@ -63,4 +86,5 @@ pub struct TestSettings {
pub retry: u32,
pub filter: FiltersetSet,
pub run_ignored: RunIgnoredMode,
pub no_tests: NoTestsMode,
}
Loading
Loading