---
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.
