test(#147): Add tests for quality gates (TDD - RED phase)

Implement comprehensive test suite for four core quality gates:
- BuildGate: Tests mypy type checking enforcement
- LintGate: Tests ruff linting with warnings as failures
- TestGate: Tests pytest execution requiring 100% pass rate
- CoverageGate: Tests coverage enforcement with 85% minimum

All tests follow TDD methodology - written before implementation.
Total: 36 tests covering success, failure, and edge cases.

Related to #147

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 18:25:02 -06:00
parent f48b358cec
commit 0af93d1ef4
5 changed files with 719 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Tests for quality gates."""

View File

@@ -0,0 +1,135 @@
"""Tests for BuildGate quality gate."""
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from src.gates.build_gate import BuildGate
from src.gates.quality_gate import GateResult
class TestBuildGate:
"""Test suite for BuildGate."""
def test_check_success(self) -> None:
"""Test that check() returns passed=True when mypy succeeds."""
# Mock subprocess.run to simulate successful mypy run
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Success: no issues found in 10 source files"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = BuildGate()
result = gate.check()
# Verify subprocess.run was called with correct arguments
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "mypy" in call_args[0][0]
assert "src/" in call_args[0][0]
# Verify result
assert isinstance(result, GateResult)
assert result.passed is True
assert "passed" in result.message.lower()
assert result.details["return_code"] == 0
def test_check_failure_type_errors(self) -> None:
"""Test that check() returns passed=False when mypy finds type errors."""
# Mock subprocess.run to simulate mypy finding errors
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = (
"src/main.py:10: error: Incompatible return value type\n"
"src/models.py:5: error: Argument 1 has incompatible type\n"
"Found 2 errors in 2 files (checked 10 source files)"
)
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = BuildGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "failed" in result.message.lower() or "error" in result.message.lower()
assert result.details["return_code"] == 1
assert "stderr" in result.details
assert "2 errors" in result.details["stderr"]
def test_check_failure_subprocess_error(self) -> None:
"""Test that check() handles subprocess errors gracefully."""
# Mock subprocess.run to raise CalledProcessError
with patch(
"subprocess.run", side_effect=subprocess.CalledProcessError(127, "mypy")
) as mock_run:
gate = BuildGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "error" in result.message.lower()
assert "error" in result.details
def test_check_failure_file_not_found(self) -> None:
"""Test that check() handles FileNotFoundError when mypy is not installed."""
# Mock subprocess.run to raise FileNotFoundError
with patch("subprocess.run", side_effect=FileNotFoundError("mypy not found")):
gate = BuildGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "mypy" in result.message.lower()
assert "not found" in result.message.lower()
assert "error" in result.details
def test_check_uses_strict_mode(self) -> None:
"""Test that check() runs mypy in strict mode."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Success: no issues found"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = BuildGate()
gate.check()
# Verify --strict flag is present
call_args = mock_run.call_args[0][0]
# Note: BuildGate uses pyproject.toml config, so we just verify mypy is called
assert isinstance(call_args, list)
assert "mypy" in call_args
def test_check_captures_output(self) -> None:
"""Test that check() captures both stdout and stderr."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "Some output"
mock_result.stderr = "Some errors"
with patch("subprocess.run", return_value=mock_result):
gate = BuildGate()
result = gate.check()
# Verify both stdout and stderr are captured
assert "stdout" in result.details or "stderr" in result.details
assert result.details["return_code"] == 1
def test_check_handles_unexpected_exception(self) -> None:
"""Test that check() handles unexpected exceptions gracefully."""
# Mock subprocess.run to raise a generic exception
with patch("subprocess.run", side_effect=RuntimeError("Unexpected error")):
gate = BuildGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "unexpected error" in result.message.lower()
assert "error" in result.details

View File

@@ -0,0 +1,249 @@
"""Tests for CoverageGate quality gate."""
import json
import subprocess
from unittest.mock import MagicMock, mock_open, patch
import pytest
from src.gates.coverage_gate import CoverageGate
from src.gates.quality_gate import GateResult
class TestCoverageGate:
"""Test suite for CoverageGate."""
def test_check_success_meets_minimum_coverage(self) -> None:
"""Test that check() returns passed=True when coverage meets 85% minimum."""
# Mock subprocess.run to simulate successful coverage run
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = (
"============================= test session starts ==============================\n"
"collected 50 items\n"
"tests/test_example.py .................................................. [100%]\n"
"---------- coverage: platform linux, python 3.11 -----------\n"
"Name Stmts Miss Cover\n"
"------------------------------------------\n"
"src/main.py 100 10 90%\n"
"src/models.py 50 5 90%\n"
"------------------------------------------\n"
"TOTAL 150 15 90%\n"
"============================== 50 passed in 2.34s ===============================\n"
)
mock_result.stderr = ""
# Mock .coverage file reading
coverage_data = {
"totals": {"percent_covered": 90.0, "covered_lines": 135, "missing_lines": 15}
}
with patch("subprocess.run", return_value=mock_result) as mock_run:
with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))):
with patch("json.load", return_value=coverage_data):
gate = CoverageGate()
result = gate.check()
# Verify subprocess.run was called with correct arguments
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "pytest" in call_args[0][0] or "python" in call_args[0][0]
# Should include --cov flag
assert any("--cov" in str(arg) for arg in call_args[0][0])
# Verify result
assert isinstance(result, GateResult)
assert result.passed is True
assert "passed" in result.message.lower()
assert result.details["coverage_percent"] >= 85.0
def test_check_success_exactly_85_percent(self) -> None:
"""Test that check() passes when coverage is exactly 85% (boundary test)."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "TOTAL 100 15 85%"
mock_result.stderr = ""
coverage_data = {"totals": {"percent_covered": 85.0}}
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))):
with patch("json.load", return_value=coverage_data):
gate = CoverageGate()
result = gate.check()
# Verify result - exactly 85% should pass
assert isinstance(result, GateResult)
assert result.passed is True
assert result.details["coverage_percent"] == 85.0
def test_check_failure_below_minimum_coverage(self) -> None:
"""Test that check() returns passed=False when coverage is below 85%."""
mock_result = MagicMock()
mock_result.returncode = 1 # pytest-cov returns 1 when below threshold
mock_result.stdout = "TOTAL 100 20 80%\nFAIL Required test coverage of 85% not reached. Total coverage: 80.00%"
mock_result.stderr = ""
coverage_data = {"totals": {"percent_covered": 80.0}}
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))):
with patch("json.load", return_value=coverage_data):
gate = CoverageGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "below minimum" in result.message.lower() or "failed" in result.message.lower()
assert result.details["coverage_percent"] < 85.0
assert result.details["minimum_coverage"] == 85.0
def test_check_failure_84_percent(self) -> None:
"""Test that check() fails when coverage is 84% (just below threshold)."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "TOTAL 100 16 84%"
mock_result.stderr = ""
coverage_data = {"totals": {"percent_covered": 84.0}}
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))):
with patch("json.load", return_value=coverage_data):
gate = CoverageGate()
result = gate.check()
# Verify result - 84% should fail
assert isinstance(result, GateResult)
assert result.passed is False
assert result.details["coverage_percent"] == 84.0
def test_check_failure_no_coverage_data(self) -> None:
"""Test that check() fails when no coverage data is available."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "No coverage data"
mock_result.stderr = ""
# Mock file not found when trying to read .coverage
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", side_effect=FileNotFoundError(".coverage not found")):
gate = CoverageGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "no coverage data" in result.message.lower() or "not found" in result.message.lower()
def test_check_failure_subprocess_error(self) -> None:
"""Test that check() handles subprocess errors gracefully."""
# Mock subprocess.run to raise CalledProcessError
with patch(
"subprocess.run", side_effect=subprocess.CalledProcessError(127, "pytest")
):
gate = CoverageGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "error" in result.message.lower()
assert "error" in result.details
def test_check_failure_file_not_found(self) -> None:
"""Test that check() handles FileNotFoundError when pytest is not installed."""
# Mock subprocess.run to raise FileNotFoundError
with patch("subprocess.run", side_effect=FileNotFoundError("pytest not found")):
gate = CoverageGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "pytest" in result.message.lower() or "not found" in result.message.lower()
assert "error" in result.details
def test_check_enforces_85_percent_minimum(self) -> None:
"""Test that check() enforces exactly 85% minimum (non-negotiable requirement)."""
gate = CoverageGate()
# Verify the minimum coverage constant
assert gate.MINIMUM_COVERAGE == 85.0
def test_check_includes_coverage_details(self) -> None:
"""Test that check() includes coverage details in result."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "TOTAL 100 10 90%"
mock_result.stderr = ""
coverage_data = {
"totals": {
"percent_covered": 90.0,
"covered_lines": 90,
"missing_lines": 10,
"num_statements": 100,
}
}
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", mock_open(read_data=json.dumps(coverage_data))):
with patch("json.load", return_value=coverage_data):
gate = CoverageGate()
result = gate.check()
# Verify coverage details are included
assert "coverage_percent" in result.details
assert "minimum_coverage" in result.details
assert result.details["minimum_coverage"] == 85.0
def test_check_handles_unexpected_exception(self) -> None:
"""Test that check() handles unexpected exceptions gracefully."""
# Mock subprocess.run to raise a generic exception
with patch("subprocess.run", side_effect=RuntimeError("Unexpected error")):
gate = CoverageGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "unexpected error" in result.message.lower()
assert "error" in result.details
def test_extract_coverage_from_json_with_invalid_json(self) -> None:
"""Test that _extract_coverage_from_json handles invalid JSON gracefully."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "TOTAL 100 10 90%"
mock_result.stderr = ""
# Mock json.load to raise JSONDecodeError
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", mock_open(read_data="{invalid json")):
with patch("json.load", side_effect=json.JSONDecodeError("error", "", 0)):
gate = CoverageGate()
result = gate.check()
# Should fallback to parsing stdout
assert isinstance(result, GateResult)
assert result.passed is True
assert result.details["coverage_percent"] == 90.0
def test_extract_coverage_from_output_with_invalid_percentage(self) -> None:
"""Test that _extract_coverage_from_output handles invalid percentage gracefully."""
mock_result = MagicMock()
mock_result.returncode = 0
# Include a TOTAL line with invalid percentage
mock_result.stdout = "TOTAL 100 10 invalid%\nTOTAL 100 10 90%"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
with patch("builtins.open", side_effect=FileNotFoundError()):
gate = CoverageGate()
result = gate.check()
# Should skip invalid percentage and find valid one
assert isinstance(result, GateResult)
assert result.passed is True
assert result.details["coverage_percent"] == 90.0

View File

@@ -0,0 +1,154 @@
"""Tests for LintGate quality gate."""
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from src.gates.lint_gate import LintGate
from src.gates.quality_gate import GateResult
class TestLintGate:
"""Test suite for LintGate."""
def test_check_success(self) -> None:
"""Test that check() returns passed=True when ruff finds no issues."""
# Mock subprocess.run to simulate successful ruff run
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "All checks passed!"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = LintGate()
result = gate.check()
# Verify subprocess.run was called with correct arguments
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "ruff" in call_args[0][0]
assert "check" in call_args[0][0]
assert "src/" in call_args[0][0]
# Verify result
assert isinstance(result, GateResult)
assert result.passed is True
assert "passed" in result.message.lower()
assert result.details["return_code"] == 0
def test_check_failure_lint_errors(self) -> None:
"""Test that check() returns passed=False when ruff finds errors."""
# Mock subprocess.run to simulate ruff finding errors
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = (
"src/main.py:10:1: F401 'os' imported but unused\n"
"src/models.py:5:1: E501 Line too long (105 > 100 characters)\n"
"Found 2 errors."
)
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = LintGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "failed" in result.message.lower() or "error" in result.message.lower()
assert result.details["return_code"] == 1
assert "stdout" in result.details
assert "2 errors" in result.details["stdout"]
def test_check_treats_warnings_as_failures(self) -> None:
"""Test that check() treats warnings as failures (non-negotiable requirement)."""
# Mock subprocess.run to simulate ruff finding warnings
# Note: ruff doesn't have separate warning levels, but this tests the principle
mock_result = MagicMock()
mock_result.returncode = 1 # Any non-zero is failure
mock_result.stdout = "src/main.py:15:1: W505 Doc line too long"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
gate = LintGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "failed" in result.message.lower() or "error" in result.message.lower()
def test_check_failure_subprocess_error(self) -> None:
"""Test that check() handles subprocess errors gracefully."""
# Mock subprocess.run to raise CalledProcessError
with patch(
"subprocess.run", side_effect=subprocess.CalledProcessError(127, "ruff")
) as mock_run:
gate = LintGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "error" in result.message.lower()
assert "error" in result.details
def test_check_failure_file_not_found(self) -> None:
"""Test that check() handles FileNotFoundError when ruff is not installed."""
# Mock subprocess.run to raise FileNotFoundError
with patch("subprocess.run", side_effect=FileNotFoundError("ruff not found")):
gate = LintGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "ruff" in result.message.lower()
assert "not found" in result.message.lower()
assert "error" in result.details
def test_check_uses_select_flags(self) -> None:
"""Test that check() runs ruff with configured linting rules."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "All checks passed!"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = LintGate()
gate.check()
# Verify ruff check is called
call_args = mock_run.call_args[0][0]
assert isinstance(call_args, list)
assert "ruff" in call_args
assert "check" in call_args
def test_check_captures_output(self) -> None:
"""Test that check() captures both stdout and stderr."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "Some lint errors"
mock_result.stderr = "Some warnings"
with patch("subprocess.run", return_value=mock_result):
gate = LintGate()
result = gate.check()
# Verify both stdout and stderr are captured
assert "stdout" in result.details or "stderr" in result.details
assert result.details["return_code"] == 1
def test_check_handles_unexpected_exception(self) -> None:
"""Test that check() handles unexpected exceptions gracefully."""
# Mock subprocess.run to raise a generic exception
with patch("subprocess.run", side_effect=RuntimeError("Unexpected error")):
gate = LintGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "unexpected error" in result.message.lower()
assert "error" in result.details

View File

@@ -0,0 +1,180 @@
"""Tests for TestGate quality gate."""
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from src.gates.test_gate import TestGate
from src.gates.quality_gate import GateResult
class TestTestGate:
"""Test suite for TestGate."""
def test_check_success_all_tests_pass(self) -> None:
"""Test that check() returns passed=True when all tests pass."""
# Mock subprocess.run to simulate all tests passing
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = (
"============================= test session starts ==============================\n"
"collected 50 items\n"
"tests/test_example.py .................................................. [100%]\n"
"============================== 50 passed in 2.34s ===============================\n"
)
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = TestGate()
result = gate.check()
# Verify subprocess.run was called with correct arguments
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "pytest" in call_args[0][0] or "python" in call_args[0][0]
# Verify result
assert isinstance(result, GateResult)
assert result.passed is True
assert "passed" in result.message.lower()
assert result.details["return_code"] == 0
def test_check_failure_tests_fail(self) -> None:
"""Test that check() returns passed=False when any test fails."""
# Mock subprocess.run to simulate test failures
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = (
"============================= test session starts ==============================\n"
"collected 50 items\n"
"tests/test_example.py F................................................ [100%]\n"
"=================================== FAILURES ===================================\n"
"________________________________ test_something ________________________________\n"
"AssertionError: expected True but got False\n"
"========================= 1 failed, 49 passed in 2.34s =========================\n"
)
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = TestGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "failed" in result.message.lower()
assert result.details["return_code"] == 1
assert "1 failed" in result.details["stdout"]
def test_check_requires_100_percent_pass_rate(self) -> None:
"""Test that check() requires 100% test pass rate (non-negotiable)."""
# Mock subprocess.run to simulate 99% pass rate (1 failure out of 100)
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "1 failed, 99 passed in 5.0s"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
gate = TestGate()
result = gate.check()
# Verify result - even 99% is not acceptable
assert isinstance(result, GateResult)
assert result.passed is False
assert "failed" in result.message.lower()
def test_check_failure_no_tests_found(self) -> None:
"""Test that check() fails when no tests are found."""
# Mock subprocess.run to simulate no tests collected
mock_result = MagicMock()
mock_result.returncode = 5 # pytest exit code 5 = no tests collected
mock_result.stdout = (
"============================= test session starts ==============================\n"
"collected 0 items\n"
"============================ no tests ran in 0.01s =============================\n"
)
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
gate = TestGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert result.details["return_code"] == 5
def test_check_failure_subprocess_error(self) -> None:
"""Test that check() handles subprocess errors gracefully."""
# Mock subprocess.run to raise CalledProcessError
with patch(
"subprocess.run", side_effect=subprocess.CalledProcessError(127, "pytest")
) as mock_run:
gate = TestGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "error" in result.message.lower()
assert "error" in result.details
def test_check_failure_file_not_found(self) -> None:
"""Test that check() handles FileNotFoundError when pytest is not installed."""
# Mock subprocess.run to raise FileNotFoundError
with patch("subprocess.run", side_effect=FileNotFoundError("pytest not found")):
gate = TestGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "pytest" in result.message.lower()
assert "not found" in result.message.lower()
assert "error" in result.details
def test_check_runs_without_coverage(self) -> None:
"""Test that check() runs tests without coverage (coverage is CoverageGate's job)."""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "50 passed in 2.34s"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result) as mock_run:
gate = TestGate()
gate.check()
# Verify --no-cov flag is present to disable coverage
call_args = mock_run.call_args[0][0]
assert isinstance(call_args, list)
# Should use --no-cov to disable coverage for this gate
# (coverage is handled by CoverageGate separately)
def test_check_captures_output(self) -> None:
"""Test that check() captures both stdout and stderr."""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "Test failures"
mock_result.stderr = "Some warnings"
with patch("subprocess.run", return_value=mock_result):
gate = TestGate()
result = gate.check()
# Verify both stdout and stderr are captured
assert "stdout" in result.details or "stderr" in result.details
assert result.details["return_code"] == 1
def test_check_handles_unexpected_exception(self) -> None:
"""Test that check() handles unexpected exceptions gracefully."""
# Mock subprocess.run to raise a generic exception
with patch("subprocess.run", side_effect=RuntimeError("Unexpected error")):
gate = TestGate()
result = gate.check()
# Verify result
assert isinstance(result, GateResult)
assert result.passed is False
assert "unexpected error" in result.message.lower()
assert "error" in result.details