554 lines
15 KiB
Markdown
554 lines
15 KiB
Markdown
---
|
|
name: tdd-workflow
|
|
description: Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests.
|
|
---
|
|
|
|
# Test-Driven Development Workflow
|
|
|
|
TDD principles for Python/FastAPI development with pytest.
|
|
|
|
## When to Activate
|
|
|
|
- Writing new features or functionality
|
|
- Fixing bugs or issues
|
|
- Refactoring existing code
|
|
- Adding API endpoints
|
|
- Creating new field extractors or normalizers
|
|
|
|
## Core Principles
|
|
|
|
### 1. Tests BEFORE Code
|
|
ALWAYS write tests first, then implement code to make tests pass.
|
|
|
|
### 2. Coverage Requirements
|
|
- Minimum 80% coverage (unit + integration + E2E)
|
|
- All edge cases covered
|
|
- Error scenarios tested
|
|
- Boundary conditions verified
|
|
|
|
### 3. Test Types
|
|
|
|
#### Unit Tests
|
|
- Individual functions and utilities
|
|
- Normalizers and validators
|
|
- Parsers and extractors
|
|
- Pure functions
|
|
|
|
#### Integration Tests
|
|
- API endpoints
|
|
- Database operations
|
|
- OCR + YOLO pipeline
|
|
- Service interactions
|
|
|
|
#### E2E Tests
|
|
- Complete inference pipeline
|
|
- PDF → Fields workflow
|
|
- API health and inference endpoints
|
|
|
|
## TDD Workflow Steps
|
|
|
|
### Step 1: Write User Journeys
|
|
```
|
|
As a [role], I want to [action], so that [benefit]
|
|
|
|
Example:
|
|
As an invoice processor, I want to extract Bankgiro from payment_line,
|
|
so that I can cross-validate OCR results.
|
|
```
|
|
|
|
### Step 2: Generate Test Cases
|
|
For each user journey, create comprehensive test cases:
|
|
|
|
```python
|
|
import pytest
|
|
|
|
class TestPaymentLineParser:
|
|
"""Tests for payment_line parsing and field extraction."""
|
|
|
|
def test_parse_payment_line_extracts_bankgiro(self):
|
|
"""Should extract Bankgiro from valid payment line."""
|
|
# Test implementation
|
|
pass
|
|
|
|
def test_parse_payment_line_handles_missing_checksum(self):
|
|
"""Should handle payment lines without checksum."""
|
|
pass
|
|
|
|
def test_parse_payment_line_validates_checksum(self):
|
|
"""Should validate checksum when present."""
|
|
pass
|
|
|
|
def test_parse_payment_line_returns_none_for_invalid(self):
|
|
"""Should return None for invalid payment lines."""
|
|
pass
|
|
```
|
|
|
|
### Step 3: Run Tests (They Should Fail)
|
|
```bash
|
|
pytest tests/test_ocr/test_machine_code_parser.py -v
|
|
# Tests should fail - we haven't implemented yet
|
|
```
|
|
|
|
### Step 4: Implement Code
|
|
Write minimal code to make tests pass:
|
|
|
|
```python
|
|
def parse_payment_line(line: str) -> PaymentLineData | None:
|
|
"""Parse Swedish payment line and extract fields."""
|
|
# Implementation guided by tests
|
|
pass
|
|
```
|
|
|
|
### Step 5: Run Tests Again
|
|
```bash
|
|
pytest tests/test_ocr/test_machine_code_parser.py -v
|
|
# Tests should now pass
|
|
```
|
|
|
|
### Step 6: Refactor
|
|
Improve code quality while keeping tests green:
|
|
- Remove duplication
|
|
- Improve naming
|
|
- Optimize performance
|
|
- Enhance readability
|
|
|
|
### Step 7: Verify Coverage
|
|
```bash
|
|
pytest --cov=src --cov-report=term-missing
|
|
# Verify 80%+ coverage achieved
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### Unit Test Pattern (pytest)
|
|
```python
|
|
import pytest
|
|
from src.normalize.bankgiro_normalizer import normalize_bankgiro
|
|
|
|
class TestBankgiroNormalizer:
|
|
"""Tests for Bankgiro normalization."""
|
|
|
|
def test_normalize_removes_hyphens(self):
|
|
"""Should remove hyphens from Bankgiro."""
|
|
result = normalize_bankgiro("123-4567")
|
|
assert result == "1234567"
|
|
|
|
def test_normalize_removes_spaces(self):
|
|
"""Should remove spaces from Bankgiro."""
|
|
result = normalize_bankgiro("123 4567")
|
|
assert result == "1234567"
|
|
|
|
def test_normalize_validates_length(self):
|
|
"""Should validate Bankgiro is 7-8 digits."""
|
|
result = normalize_bankgiro("123456") # 6 digits
|
|
assert result is None
|
|
|
|
def test_normalize_validates_checksum(self):
|
|
"""Should validate Luhn checksum."""
|
|
result = normalize_bankgiro("1234568") # Invalid checksum
|
|
assert result is None
|
|
|
|
@pytest.mark.parametrize("input_value,expected", [
|
|
("123-4567", "1234567"),
|
|
("1234567", "1234567"),
|
|
("123 4567", "1234567"),
|
|
("BG 123-4567", "1234567"),
|
|
])
|
|
def test_normalize_various_formats(self, input_value, expected):
|
|
"""Should handle various input formats."""
|
|
result = normalize_bankgiro(input_value)
|
|
assert result == expected
|
|
```
|
|
|
|
### API Integration Test Pattern
|
|
```python
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from src.web.app import app
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(app)
|
|
|
|
class TestHealthEndpoint:
|
|
"""Tests for /api/v1/health endpoint."""
|
|
|
|
def test_health_returns_200(self, client):
|
|
"""Should return 200 OK."""
|
|
response = client.get("/api/v1/health")
|
|
assert response.status_code == 200
|
|
|
|
def test_health_returns_status(self, client):
|
|
"""Should return health status."""
|
|
response = client.get("/api/v1/health")
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert "model_loaded" in data
|
|
|
|
class TestInferEndpoint:
|
|
"""Tests for /api/v1/infer endpoint."""
|
|
|
|
def test_infer_requires_file(self, client):
|
|
"""Should require file upload."""
|
|
response = client.post("/api/v1/infer")
|
|
assert response.status_code == 422
|
|
|
|
def test_infer_rejects_non_pdf(self, client):
|
|
"""Should reject non-PDF files."""
|
|
response = client.post(
|
|
"/api/v1/infer",
|
|
files={"file": ("test.txt", b"not a pdf", "text/plain")}
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_infer_returns_fields(self, client, sample_invoice_pdf):
|
|
"""Should return extracted fields."""
|
|
with open(sample_invoice_pdf, "rb") as f:
|
|
response = client.post(
|
|
"/api/v1/infer",
|
|
files={"file": ("invoice.pdf", f, "application/pdf")}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert "fields" in data
|
|
```
|
|
|
|
### E2E Test Pattern
|
|
```python
|
|
import pytest
|
|
import httpx
|
|
from pathlib import Path
|
|
|
|
@pytest.fixture(scope="module")
|
|
def running_server():
|
|
"""Ensure server is running for E2E tests."""
|
|
# Server should be started before running E2E tests
|
|
base_url = "http://localhost:8000"
|
|
yield base_url
|
|
|
|
class TestInferencePipeline:
|
|
"""E2E tests for complete inference pipeline."""
|
|
|
|
def test_health_check(self, running_server):
|
|
"""Should pass health check."""
|
|
response = httpx.get(f"{running_server}/api/v1/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert data["model_loaded"] is True
|
|
|
|
def test_pdf_inference_returns_fields(self, running_server):
|
|
"""Should extract fields from PDF."""
|
|
pdf_path = Path("tests/fixtures/sample_invoice.pdf")
|
|
with open(pdf_path, "rb") as f:
|
|
response = httpx.post(
|
|
f"{running_server}/api/v1/infer",
|
|
files={"file": ("invoice.pdf", f, "application/pdf")}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] is True
|
|
assert "fields" in data
|
|
assert len(data["fields"]) > 0
|
|
|
|
def test_cross_validation_included(self, running_server):
|
|
"""Should include cross-validation for invoices with payment_line."""
|
|
pdf_path = Path("tests/fixtures/invoice_with_payment_line.pdf")
|
|
with open(pdf_path, "rb") as f:
|
|
response = httpx.post(
|
|
f"{running_server}/api/v1/infer",
|
|
files={"file": ("invoice.pdf", f, "application/pdf")}
|
|
)
|
|
|
|
data = response.json()
|
|
if data["fields"].get("payment_line"):
|
|
assert "cross_validation" in data
|
|
```
|
|
|
|
## Test File Organization
|
|
|
|
```
|
|
tests/
|
|
├── conftest.py # Shared fixtures
|
|
├── fixtures/ # Test data files
|
|
│ ├── sample_invoice.pdf
|
|
│ └── invoice_with_payment_line.pdf
|
|
├── test_cli/
|
|
│ └── test_infer.py
|
|
├── test_pdf/
|
|
│ ├── test_extractor.py
|
|
│ └── test_renderer.py
|
|
├── test_ocr/
|
|
│ ├── test_paddle_ocr.py
|
|
│ └── test_machine_code_parser.py
|
|
├── test_inference/
|
|
│ ├── test_pipeline.py
|
|
│ ├── test_yolo_detector.py
|
|
│ └── test_field_extractor.py
|
|
├── test_normalize/
|
|
│ ├── test_bankgiro_normalizer.py
|
|
│ ├── test_date_normalizer.py
|
|
│ └── test_amount_normalizer.py
|
|
├── test_web/
|
|
│ ├── test_routes.py
|
|
│ └── test_services.py
|
|
└── e2e/
|
|
└── test_inference_e2e.py
|
|
```
|
|
|
|
## Mocking External Services
|
|
|
|
### Mock PaddleOCR
|
|
```python
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
|
|
@pytest.fixture
|
|
def mock_paddle_ocr():
|
|
"""Mock PaddleOCR for unit tests."""
|
|
with patch("src.ocr.paddle_ocr.PaddleOCR") as mock:
|
|
instance = Mock()
|
|
instance.ocr.return_value = [
|
|
[
|
|
[[[0, 0], [100, 0], [100, 20], [0, 20]], ("Invoice Number", 0.95)],
|
|
[[[0, 30], [100, 30], [100, 50], [0, 50]], ("INV-2024-001", 0.98)]
|
|
]
|
|
]
|
|
mock.return_value = instance
|
|
yield instance
|
|
```
|
|
|
|
### Mock YOLO Model
|
|
```python
|
|
@pytest.fixture
|
|
def mock_yolo_model():
|
|
"""Mock YOLO model for unit tests."""
|
|
with patch("src.inference.yolo_detector.YOLO") as mock:
|
|
instance = Mock()
|
|
# Mock detection results
|
|
instance.return_value = Mock(
|
|
boxes=Mock(
|
|
xyxy=[[10, 20, 100, 50]],
|
|
conf=[0.95],
|
|
cls=[0] # invoice_number class
|
|
)
|
|
)
|
|
mock.return_value = instance
|
|
yield instance
|
|
```
|
|
|
|
### Mock Database
|
|
```python
|
|
@pytest.fixture
|
|
def mock_db_connection():
|
|
"""Mock database connection for unit tests."""
|
|
with patch("src.data.db.get_db_connection") as mock:
|
|
conn = Mock()
|
|
cursor = Mock()
|
|
cursor.fetchall.return_value = [
|
|
("doc-123", "processed", {"invoice_number": "INV-001"})
|
|
]
|
|
cursor.fetchone.return_value = ("doc-123",)
|
|
conn.cursor.return_value.__enter__ = Mock(return_value=cursor)
|
|
conn.cursor.return_value.__exit__ = Mock(return_value=False)
|
|
mock.return_value.__enter__ = Mock(return_value=conn)
|
|
mock.return_value.__exit__ = Mock(return_value=False)
|
|
yield conn
|
|
```
|
|
|
|
## Test Coverage Verification
|
|
|
|
### Run Coverage Report
|
|
```bash
|
|
# Run with coverage
|
|
pytest --cov=src --cov-report=term-missing
|
|
|
|
# Generate HTML report
|
|
pytest --cov=src --cov-report=html
|
|
# Open htmlcov/index.html in browser
|
|
```
|
|
|
|
### Coverage Configuration (pyproject.toml)
|
|
```toml
|
|
[tool.coverage.run]
|
|
source = ["src"]
|
|
omit = ["*/__init__.py", "*/test_*.py"]
|
|
|
|
[tool.coverage.report]
|
|
fail_under = 80
|
|
show_missing = true
|
|
exclude_lines = [
|
|
"pragma: no cover",
|
|
"if TYPE_CHECKING:",
|
|
"raise NotImplementedError",
|
|
]
|
|
```
|
|
|
|
## Common Testing Mistakes to Avoid
|
|
|
|
### WRONG: Testing Implementation Details
|
|
```python
|
|
# Don't test internal state
|
|
def test_parser_internal_state():
|
|
parser = PaymentLineParser()
|
|
parser._parse("...")
|
|
assert parser._groups == [...] # Internal state
|
|
```
|
|
|
|
### CORRECT: Test Public Interface
|
|
```python
|
|
# Test what users see
|
|
def test_parser_extracts_bankgiro():
|
|
result = parse_payment_line("...")
|
|
assert result.bankgiro == "1234567"
|
|
```
|
|
|
|
### WRONG: No Test Isolation
|
|
```python
|
|
# Tests depend on each other
|
|
class TestDocuments:
|
|
def test_creates_document(self):
|
|
create_document(...) # Creates in DB
|
|
|
|
def test_updates_document(self):
|
|
update_document(...) # Depends on previous test
|
|
```
|
|
|
|
### CORRECT: Independent Tests
|
|
```python
|
|
# Each test sets up its own data
|
|
class TestDocuments:
|
|
def test_creates_document(self, mock_db):
|
|
result = create_document(...)
|
|
assert result.id is not None
|
|
|
|
def test_updates_document(self, mock_db):
|
|
# Create own test data
|
|
doc = create_document(...)
|
|
result = update_document(doc.id, ...)
|
|
assert result.status == "updated"
|
|
```
|
|
|
|
### WRONG: Testing Too Much
|
|
```python
|
|
# One test doing everything
|
|
def test_full_invoice_processing():
|
|
# Load PDF
|
|
# Extract images
|
|
# Run YOLO
|
|
# Run OCR
|
|
# Normalize fields
|
|
# Save to DB
|
|
# Return response
|
|
```
|
|
|
|
### CORRECT: Focused Tests
|
|
```python
|
|
def test_yolo_detects_invoice_number():
|
|
"""Test only YOLO detection."""
|
|
result = detector.detect(image)
|
|
assert any(d.label == "invoice_number" for d in result)
|
|
|
|
def test_ocr_extracts_text():
|
|
"""Test only OCR extraction."""
|
|
result = ocr.extract(image, bbox)
|
|
assert result == "INV-2024-001"
|
|
|
|
def test_normalizer_formats_date():
|
|
"""Test only date normalization."""
|
|
result = normalize_date("2024-01-15")
|
|
assert result == "2024-01-15"
|
|
```
|
|
|
|
## Fixtures (conftest.py)
|
|
|
|
```python
|
|
import pytest
|
|
from pathlib import Path
|
|
from fastapi.testclient import TestClient
|
|
|
|
@pytest.fixture
|
|
def sample_invoice_pdf(tmp_path: Path) -> Path:
|
|
"""Create sample invoice PDF for testing."""
|
|
pdf_path = tmp_path / "invoice.pdf"
|
|
# Copy from fixtures or create minimal PDF
|
|
src = Path("tests/fixtures/sample_invoice.pdf")
|
|
if src.exists():
|
|
pdf_path.write_bytes(src.read_bytes())
|
|
return pdf_path
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""FastAPI test client."""
|
|
from src.web.app import app
|
|
return TestClient(app)
|
|
|
|
@pytest.fixture
|
|
def sample_payment_line() -> str:
|
|
"""Sample Swedish payment line for testing."""
|
|
return "1234567#0000000012345#230115#00012345678901234567#1"
|
|
```
|
|
|
|
## Continuous Testing
|
|
|
|
### Watch Mode During Development
|
|
```bash
|
|
# Using pytest-watch
|
|
ptw -- tests/test_ocr/
|
|
# Tests run automatically on file changes
|
|
```
|
|
|
|
### Pre-Commit Hook
|
|
```bash
|
|
# .pre-commit-config.yaml
|
|
repos:
|
|
- repo: local
|
|
hooks:
|
|
- id: pytest
|
|
name: pytest
|
|
entry: pytest --tb=short -q
|
|
language: system
|
|
pass_filenames: false
|
|
always_run: true
|
|
```
|
|
|
|
### CI/CD Integration (GitHub Actions)
|
|
```yaml
|
|
- name: Run Tests
|
|
run: |
|
|
pytest --cov=src --cov-report=xml
|
|
|
|
- name: Upload Coverage
|
|
uses: codecov/codecov-action@v3
|
|
with:
|
|
file: coverage.xml
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Write Tests First** - Always TDD
|
|
2. **One Assert Per Test** - Focus on single behavior
|
|
3. **Descriptive Test Names** - `test_<what>_<condition>_<expected>`
|
|
4. **Arrange-Act-Assert** - Clear test structure
|
|
5. **Mock External Dependencies** - Isolate unit tests
|
|
6. **Test Edge Cases** - None, empty, invalid, boundary
|
|
7. **Test Error Paths** - Not just happy paths
|
|
8. **Keep Tests Fast** - Unit tests < 50ms each
|
|
9. **Clean Up After Tests** - Use fixtures with cleanup
|
|
10. **Review Coverage Reports** - Identify gaps
|
|
|
|
## Success Metrics
|
|
|
|
- 80%+ code coverage achieved
|
|
- All tests passing (green)
|
|
- No skipped or disabled tests
|
|
- Fast test execution (< 60s for unit tests)
|
|
- E2E tests cover critical inference flow
|
|
- Tests catch bugs before production
|
|
|
|
---
|
|
|
|
**Remember**: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability.
|