use crate::code_lint::ast::{self, ParsedFile};
use crate::code_lint::contract::{CodeRule, RuleTarget};
use crate::diagnostic::{Diagnostic, Language, RuleName, ViolationTemplate, violation_template};
use crate::rule_declaration::{
Classification, Consensus, CountOption, Declaration, Example, ImpactedQuality,
LanguageDefaults, Precision, Reference, RuleDoc, RuleOptions, Topic,
};
use std::path::Path;
const MAX_ASSERTIONS: CountOption = CountOption {
key: "max-assertions",
doc: "Maximum assertions allowed in one test function.",
default: LanguageDefaults::new(4, &[]),
};
const TEMPLATE: ViolationTemplate = violation_template! {
summary: "Test `{function}` contains {count} assertions; the limit is {max_assertions}.",
rationale: "A test with many assertions checks several unrelated behaviors at once and stops at the first failure, hiding the others and blurring what the test is about.",
suggestion: {
Python => "Assert on one expected value or object, split distinct scenarios into separate `test_*` functions, or parameterize the variations with `@pytest.mark.parametrize`.",
Rust => "Assert on one expected value or struct, split distinct scenarios into separate `#[test]` functions, or parameterize the cases with `#[rstest]`.",
},
};
pub const RULE: CodeRule<CountOption> = CodeRule {
declaration: Declaration {
name: RuleName("too-many-assertions"),
template: &TEMPLATE,
languages: &[Language::Python, Language::Rust],
options: RuleOptions::code_rule(MAX_ASSERTIONS),
classification: Classification {
topics: &[Topic::TEST_ASSERTIONS],
precision: Precision::Heuristic,
consensus: Consensus::Opinionated,
impacted_quality: ImpactedQuality::Maintainability,
},
doc: RuleDoc {
summary: "Flags test functions with more assertions than the limit.",
what_it_does: indoc::indoc! {r"
Flags a test function with more assertions than `max-assertions`. Each `assert`
statement, `assert*` method call or `pytest.raises` call counts as one in Python,
and each `assert*!` or `debug_assert*!` macro call in Rust."},
why_is_this_bad: indoc::indoc! {r"
A test with many assertions usually checks several behaviours at once. It stops at
the first failing assertion, so the later ones are never reported, and its name
cannot say which behaviour broke.
Split independent scenarios into separate tests, parameterize variations
(`@pytest.mark.parametrize`, `#[rstest]`), or compare the result against one
expected value."},
known_problems: Some(indoc::indoc! {r"
- Assertions in a helper or nested function the test calls are not counted, so
moving checks into one hides them.
- An assertion inside a loop counts once, however many times it runs."}),
references: &[Reference {
title: "Software Engineering at Google, ch. 12: Test behaviors, not methods",
url: "https://abseil.io/resources/swe-book/html/ch12.html",
}],
examples: &[
Example {
language: Language::Python,
flagged: indoc::indoc! {r#"
def test_parse_version():
version = parse_version("2.7.1-rc1+20261002")
assert version.major == 2
assert version.minor == 7
assert version.patch == 1
assert version.pre_release == "rc1"
assert version.build_metadata == "20261002"
"#},
flagged_span: "test_parse_version",
fixed: indoc::indoc! {r#"
def test_parse_version():
version = parse_version("2.7.1-rc1+20261002")
assert version == Version(
major=2,
minor=7,
patch=1,
pre_release="rc1",
build_metadata="20261002",
)
"#},
},
Example {
language: Language::Rust,
flagged: indoc::indoc! {r#"
#[test]
fn test_parse_version() {
let version = parse_version("2.7.1-rc1+20261002");
assert_eq!(version.major, 2);
assert_eq!(version.minor, 7);
assert_eq!(version.patch, 1);
assert_eq!(version.pre_release, "rc1");
assert_eq!(version.build_metadata, "20261002");
}
"#},
flagged_span: "test_parse_version",
fixed: indoc::indoc! {r#"
#[test]
fn test_parse_version() {
let version = parse_version("2.7.1-rc1+20261002");
assert_eq!(
version,
Version {
major: 2,
minor: 7,
patch: 1,
pre_release: "rc1",
build_metadata: "20261002",
}
);
}
"#},
},
],
},
},
target: RuleTarget::TestsOnly,
check: check_file,
};
fn check_file(
rule: &CodeRule<CountOption>,
path: &Path,
file: &ParsedFile,
max_allowed: usize,
) -> Vec<Diagnostic> {
ast::collect_test_function_assertion_counts(file)
.into_iter()
.filter(|(_, _, assertion_count)| *assertion_count > max_allowed)
.map(|(name_node, func_name, assertion_count)| {
let formatted_count = assertion_count.to_string();
let formatted_max = max_allowed.to_string();
rule.diagnostic_at_node(
path,
&name_node,
&[
("function", &func_name),
("count", &formatted_count),
("max_assertions", &formatted_max),
],
)
})
.collect()
}
#[cfg(test)]
crate::test_utils::rule_test!(
RULE,
{
Python => {
pass: [
helper_function_exempt => r#"
def assert_helper(response):
assert response.status == 200
assert response.body is not None
assert response.headers
assert response.cookies
assert response.ok
"#,
exact_threshold_of_four_allowed => r#"
import pytest
def test_focused():
assert 1 + 1 == 2
assert 2 + 2 == 4
with pytest.raises(ValueError):
int("bad")
assert True
"#,
nested_function_assertions_not_counted => r#"
def test_with_nested_function():
def inner_verifier(item):
assert item > 0
assert item < 100
assert 1 == 1
assert 2 == 2
assert 3 == 3
assert 4 == 4
"#,
nested_class_assertions_not_counted => r#"
def test_with_nested_class():
class LocalCheck:
def verify(self):
assert True
assert 1 == 1
assert 2 == 2
assert 3 == 3
assert 4 == 4
"#,
],
fail: [
assert_statements_exceeding_threshold => r#"
def test_ok():
assert 1 == 1
def test_too_many():
assert 1 == 1
assert 2 == 2
assert 3 == 3
assert 4 == 4
assert 5 == 5
"# => "test_too_many",
unittest_assertions_exceeding_threshold => r#"
class OrderTest:
def test_order_lifecycle(self):
self.assertEqual(1, 1)
self.assertTrue(True)
self.assertIsNotNone("ok")
self.assertIn("a", "abc")
self.fail("unreachable")
"# => "test_order_lifecycle",
pytest_raises_calls_exceeding_threshold => r#"
import pytest
def test_raises_lifecycle():
with pytest.raises(ValueError):
int("1")
with pytest.raises(ValueError):
int("2")
with pytest.raises(ValueError):
int("3")
with pytest.raises(ValueError):
int("4")
with pytest.raises(ValueError):
int("5")
"# => "test_raises_lifecycle",
bare_raises_calls_exceeding_threshold => r#"
from pytest import raises
def test_bare_raises():
with raises(ValueError):
int("1")
with raises(ValueError):
int("2")
with raises(ValueError):
int("3")
with raises(ValueError):
int("4")
with raises(ValueError):
int("5")
"# => "test_bare_raises",
pytest_warns_calls_exceeding_threshold => r#"
import pytest
def test_warns_lifecycle():
with pytest.warns(UserWarning):
pass
with pytest.warns(UserWarning):
pass
with pytest.warns(UserWarning):
pass
with pytest.warns(UserWarning):
pass
with pytest.warns(UserWarning):
pass
"# => "test_warns_lifecycle",
],
},
Rust => {
pass: [
helper_function_exempt => r#"
fn assert_helper() {
assert_eq!(1, 1);
assert_eq!(2, 2);
assert_eq!(3, 3);
assert_eq!(4, 4);
assert_eq!(5, 5);
}
"#,
exact_threshold_of_four_allowed => r#"
#[test]
fn parses_valid_header() {
assert_eq!(1, 1);
assert_ne!(1, 2);
assert!(true);
debug_assert_eq!(3, 3);
}
"#,
nested_function_assertions_not_counted => r#"
#[test]
fn test_with_nested_function() {
fn inner_check() {
assert_eq!(10, 10);
assert_eq!(20, 20);
}
assert_eq!(1, 1);
assert_eq!(2, 2);
assert_eq!(3, 3);
assert_eq!(4, 4);
}
"#,
],
fail: [
attributed_test_function_exceeding_threshold => r#"
#[tokio::test]
async fn attributed_check() {
assert_eq!(1, 1);
assert_eq!(2, 2);
assert_eq!(3, 3);
assert_eq!(4, 4);
assert_eq!(5, 5);
}
"# => "attributed_check",
unattributed_test_function_exceeding_threshold => r#"
fn test_unattributed() {
assert_eq!(1, 1);
assert_eq!(2, 2);
assert_eq!(3, 3);
assert_eq!(4, 4);
assert_eq!(5, 5);
}
"# => "test_unattributed",
],
},
}
);