diff --git a/apps/coordinator/tests/gates/__init__.py b/apps/coordinator/tests/gates/__init__.py new file mode 100644 index 0000000..0a01e8a --- /dev/null +++ b/apps/coordinator/tests/gates/__init__.py @@ -0,0 +1 @@ +"""Tests for quality gates.""" diff --git a/apps/coordinator/tests/gates/test_build_gate.py b/apps/coordinator/tests/gates/test_build_gate.py new file mode 100644 index 0000000..542db01 --- /dev/null +++ b/apps/coordinator/tests/gates/test_build_gate.py @@ -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 diff --git a/apps/coordinator/tests/gates/test_coverage_gate.py b/apps/coordinator/tests/gates/test_coverage_gate.py new file mode 100644 index 0000000..1957ba5 --- /dev/null +++ b/apps/coordinator/tests/gates/test_coverage_gate.py @@ -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 diff --git a/apps/coordinator/tests/gates/test_lint_gate.py b/apps/coordinator/tests/gates/test_lint_gate.py new file mode 100644 index 0000000..7bee00c --- /dev/null +++ b/apps/coordinator/tests/gates/test_lint_gate.py @@ -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 diff --git a/apps/coordinator/tests/gates/test_test_gate.py b/apps/coordinator/tests/gates/test_test_gate.py new file mode 100644 index 0000000..26495bb --- /dev/null +++ b/apps/coordinator/tests/gates/test_test_gate.py @@ -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