Testing Best Practices¶
Guidelines for writing meaningful, maintainable tests in metaseed.
Assertion Quality¶
Avoid OR Conditions in Assertions¶
OR conditions can mask failures by passing when either condition matches.
# Bad - passes if either matches, hiding specific failures
assert "error" in output or "warning" in output
# Good - be specific about expected output
assert "validation error" in output
# Good - if both should be present, use AND
assert "error" in output and "field: title" in output
Use Exact Counts When Deterministic¶
Use >= only when the count can legitimately vary.
# Bad - passes with any count >= 1
assert len(result["nodes"]) >= 1
# Good - verify exact expected count
assert len(result["nodes"]) == 3
# Good - use >= only when count varies (e.g., profiles may add entities)
assert len(profiles) >= 5 # At least the core profiles
Avoid Trivial Type Checks¶
Type checks alone don't verify behavior.
# Bad - trivial, always passes if object exists
assert hasattr(result, "total_entities")
assert isinstance(stats.total_entities, int)
# Good - verify meaningful values
assert hasattr(result, "total_entities")
assert isinstance(stats.total_entities, int)
assert stats.total_entities >= 0 # Meaningful constraint
Verify Structure, Not Just String Presence¶
String presence checks are fragile and can match unintended content.
# Bad - "table" could appear anywhere
assert "table" in response.text
# Good - check actual structure
assert response.text.count("<table") == 1
soup = BeautifulSoup(response.text, "html.parser")
assert soup.find("table", class_="data-table") is not None
# Good - for JSON, parse and verify
data = response.json()
assert "entities" in data
assert len(data["entities"]) == 3
Test Organization¶
Document Edge Case Behavior¶
Tests should document how code handles edge cases.
def test_zero_value_is_valid(self):
"""Zero is treated as valid, not missing.
RequiredFieldsRule only checks for None or empty string.
Integer 0 and boolean False are valid values.
"""
rule = RequiredFieldsRule(["count"])
errors = rule.validate({"count": 0})
assert len(errors) == 0
Use Fixtures for Setup, Not Conditional Skips¶
Avoid pytest.skip() in error paths - use fixtures that guarantee setup.
# Bad - silently skips if profile not available
def test_something(self):
data = load_profile("miappe")
if "error" in data:
pytest.skip("Profile not available")
# ... test code
# Good - fixture ensures profile is available
@pytest.fixture
def miappe_profile():
"""Load MIAPPE profile, fail if unavailable."""
data = load_profile("miappe")
assert "error" not in data, f"Profile unavailable: {data.get('error')}"
return data
def test_something(self, miappe_profile):
# Profile guaranteed to be available
assert miappe_profile["name"] == "miappe"
Use Decorators for Expected Failures¶
Use @pytest.mark.xfail decorator instead of conditional xfail.
# Bad - conditional xfail inside test
def test_known_bug(self):
result = do_something()
if result.has_bug:
pytest.xfail("Known issue")
# Good - decorator documents expected failure
@pytest.mark.xfail(reason="Known issue: orphan references remain after deletion")
def test_orphan_cleanup(self):
"""Deleting parent should clean up child references."""
delete_parent(parent_id)
# Assert the expected (correct) behavior
assert child.parent_ref is None
Verify Mock Calls¶
When using mocks, verify the code actually made the expected calls.
# Bad - mock exists but never verified
def test_api_call(self):
with patch("httpx.post") as mock_post:
mock_post.return_value = Mock(json=lambda: {"ok": True})
result = my_function()
assert result["ok"] is True
# Never verified mock was called!
# Good - verify mock was called correctly
def test_api_call(self):
with patch("httpx.post") as mock_post:
mock_post.return_value = Mock(json=lambda: {"ok": True})
result = my_function()
assert result["ok"] is True
mock_post.assert_called_once_with(
"https://api.example.com/endpoint",
json={"key": "value"}
)
Test Coverage¶
Test Both Valid and Invalid Cases¶
class TestEmailValidation:
def test_valid_email_accepted(self):
"""Valid email format passes validation."""
assert validate_email("user@example.com") is True
def test_invalid_email_rejected(self):
"""Invalid email format fails validation."""
assert validate_email("not-an-email") is False
def test_empty_email_rejected(self):
"""Empty string fails validation."""
assert validate_email("") is False
def test_none_email_rejected(self):
"""None value fails validation."""
assert validate_email(None) is False
Test Error Messages, Not Just Error Existence¶
# Bad - only checks error exists
def test_validation_error(self):
with pytest.raises(ValidationError):
validate(invalid_data)
# Good - verify error message is helpful
def test_validation_error(self):
with pytest.raises(ValidationError) as exc_info:
validate({"title": ""})
assert "title" in str(exc_info.value)
assert "required" in str(exc_info.value).lower()
Test Boundary Conditions¶
class TestListCardinality:
def test_exactly_min_items(self):
"""List with exactly min_items is valid."""
rule = ListCardinalityRule("items", min_items=2)
assert rule.validate({"items": ["a", "b"]}) == []
def test_one_below_min_items(self):
"""List with min_items - 1 is invalid."""
rule = ListCardinalityRule("items", min_items=2)
errors = rule.validate({"items": ["a"]})
assert len(errors) == 1
def test_exactly_max_items(self):
"""List with exactly max_items is valid."""
rule = ListCardinalityRule("items", max_items=3)
assert rule.validate({"items": ["a", "b", "c"]}) == []
def test_one_above_max_items(self):
"""List with max_items + 1 is invalid."""
rule = ListCardinalityRule("items", max_items=3)
errors = rule.validate({"items": ["a", "b", "c", "d"]})
assert len(errors) == 1
Naming Conventions¶
Test Names Should Describe Behavior¶
# Bad - vague names
def test_validation(self): ...
def test_error(self): ...
def test_success(self): ...
# Good - describes what is being tested and expected outcome
def test_missing_required_field_returns_validation_error(self): ...
def test_valid_email_format_passes_validation(self): ...
def test_empty_list_treated_as_missing_for_required_field(self): ...
Use Consistent Class Organization¶
class TestInvestigationValidation:
"""Tests for Investigation entity validation."""
# Setup fixtures first
@pytest.fixture
def valid_investigation(self):
return {"unique_id": "INV-001", "title": "Test"}
# Test valid cases
def test_valid_investigation_passes(self, valid_investigation): ...
# Test invalid cases
def test_missing_unique_id_fails(self): ...
def test_missing_title_fails(self): ...
# Test edge cases
def test_empty_string_title_fails(self): ...
def test_whitespace_only_title_fails(self): ...
Running Tests¶
# Run all tests
uv run pytest
# Run with coverage
uv run pytest --cov=src/metaseed --cov-report=term-missing
# Run specific test file
uv run pytest tests/test_validators/test_rules.py -v
# Run tests matching pattern
uv run pytest -k "test_validation" -v
# Run and stop on first failure
uv run pytest -x
# Run with parallel execution
uv run pytest -n auto