Agents tdd-workflow

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.

install
source · Clone the upstream repo
git clone https://github.com/carlopezzuto/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/carlopezzuto/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/tdd-workflow" ~/.claude/skills/carlopezzuto-agents-tdd-workflow && rm -rf "$T"
manifest: .claude/skills/tdd-workflow/SKILL.md
source content

Test-Driven Development Workflow

This skill ensures all code development follows TDD principles with comprehensive test coverage.

When to Activate

  • Writing new features or functionality
  • Fixing bugs or issues
  • Refactoring existing code
  • Adding API endpoints
  • Creating new services or classes

Core Principles

1. Tests BEFORE Code

ALWAYS write tests first, then implement code to make tests pass.

2. Coverage Requirements

  • Changed-lines coverage: >= 90%
  • Global coverage: >= 85%
  • Critical paths: 100%
  • Error paths: >= 95%

3. Test Types

Unit Tests (~70%)

  • Individual functions and utilities
  • Class methods
  • Pure functions
  • Helpers and utilities

Integration Tests (~20%)

  • API endpoints
  • Database operations
  • Service interactions
  • External API calls

E2E Tests (~10% - Playwright)

  • Critical user flows
  • Complete workflows
  • Browser automation
  • UI interactions

TDD Workflow Steps

Step 1: Write User Stories

As a [role], I want to [action], so that [benefit]

Example:
As a recruiter, I want to match candidates to jobs semantically,
so that I can find relevant candidates even without exact skill matches.

Step 2: Generate Test Cases

For each user story, create comprehensive test cases:

import pytest
from unittest.mock import Mock, patch


class TestSemanticSearch:
    """Tests for semantic search functionality."""

    def test_returns_relevant_candidates_for_query(self, search_service):
        """Search returns candidates matching semantic query."""
        results = search_service.search("Python developer")

        assert len(results) > 0
        assert all(r.relevance_score > 0.7 for r in results)

    def test_handles_empty_query_gracefully(self, search_service):
        """Empty query returns empty results without error."""
        results = search_service.search("")

        assert results == []

    def test_falls_back_to_keyword_search_when_embedding_unavailable(
        self, search_service, mock_embedding_service
    ):
        """Falls back to keyword search when embedding fails."""
        mock_embedding_service.generate.side_effect = ConnectionError()

        results = search_service.search("Python")

        assert len(results) > 0  # Fallback worked

    def test_sorts_results_by_similarity_score(self, search_service):
        """Results are sorted by descending similarity score."""
        results = search_service.search("data scientist")

        scores = [r.relevance_score for r in results]
        assert scores == sorted(scores, reverse=True)

Step 3: Run Tests (They Should Fail)

pytest tests/ -v
# Tests should fail - we haven't implemented yet

Step 4: Implement Code

Write minimal code to make tests pass:

class SemanticSearchService:
    """Service for semantic candidate search."""

    def __init__(self, embedding_service: EmbeddingService, repository: CandidateRepository):
        self._embedding_service = embedding_service
        self._repository = repository

    def search(self, query: str) -> list[SearchResult]:
        """Search candidates semantically."""
        if not query.strip():
            return []

        try:
            embedding = self._embedding_service.generate(query)
            candidates = self._repository.find_by_embedding(embedding)
        except ConnectionError:
            candidates = self._repository.find_by_keyword(query)

        return sorted(candidates, key=lambda c: c.relevance_score, reverse=True)

Step 5: Run Tests Again

pytest tests/ -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

pytest --cov=src --cov-report=term-missing --cov-fail-under=85
# Verify coverage thresholds achieved

Testing Patterns

Unit Test Pattern (pytest)

import pytest
from src.services.candidate_scorer import CandidateScorer


class TestCandidateScorer:
    """Unit tests for CandidateScorer."""

    @pytest.fixture
    def scorer(self):
        """Create scorer instance for tests."""
        return CandidateScorer()

    def test_scores_perfect_match_as_1(self, scorer):
        """Perfect skill match scores 1.0."""
        candidate_skills = {"Python", "FastAPI", "PostgreSQL"}
        job_skills = {"Python", "FastAPI", "PostgreSQL"}

        score = scorer.calculate_skill_match(candidate_skills, job_skills)

        assert score == 1.0

    def test_scores_no_match_as_0(self, scorer):
        """No skill overlap scores 0.0."""
        candidate_skills = {"Java", "Spring"}
        job_skills = {"Python", "FastAPI"}

        score = scorer.calculate_skill_match(candidate_skills, job_skills)

        assert score == 0.0

    def test_scores_partial_match_proportionally(self, scorer):
        """Partial match scores proportionally."""
        candidate_skills = {"Python", "Java"}
        job_skills = {"Python", "FastAPI", "PostgreSQL"}

        score = scorer.calculate_skill_match(candidate_skills, job_skills)

        assert 0.3 <= score <= 0.4  # 1/3 match

API Integration Test Pattern (FastAPI)

import pytest
from httpx import AsyncClient
from fastapi import status

from src.main import app


@pytest.mark.asyncio
class TestCandidatesAPI:
    """Integration tests for candidates API."""

    async def test_search_candidates_returns_results(self, client: AsyncClient):
        """GET /api/candidates/search returns matching candidates."""
        response = await client.get("/api/candidates/search", params={"q": "Python"})

        assert response.status_code == status.HTTP_200_OK
        data = response.json()
        assert data["success"] is True
        assert isinstance(data["data"], list)

    async def test_search_validates_query_parameter(self, client: AsyncClient):
        """GET /api/candidates/search validates query params."""
        response = await client.get("/api/candidates/search", params={"limit": "invalid"})

        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

    async def test_search_handles_database_errors_gracefully(
        self, client: AsyncClient, mock_db_failure
    ):
        """Database errors return 500 with generic message."""
        response = await client.get("/api/candidates/search", params={"q": "test"})

        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
        data = response.json()
        assert "detail" in data
        assert "database" not in data["detail"].lower()  # No leak

E2E Test Pattern (Playwright)

import pytest
from playwright.sync_api import Page, expect


class TestCandidateSearchFlow:
    """E2E tests for candidate search user flow."""

    def test_user_can_search_and_view_candidate(self, page: Page):
        """Complete user journey: search -> view candidate."""
        # Navigate to candidates page
        page.goto("/candidates")

        # Verify page loaded
        expect(page.locator("h1")).to_contain_text("Candidates")

        # Search for candidates
        page.fill('input[placeholder="Search candidates"]', "Python developer")

        # Wait for debounce and results
        page.wait_for_response(
            lambda resp: "/api/candidates/search" in resp.url and resp.status == 200
        )

        # Verify search results displayed
        results = page.locator('[data-testid="candidate-card"]')
        expect(results.first).to_be_visible()

        # Click on first result
        results.first.click()

        # Verify candidate details page loads
        expect(page).to_have_url(r"/candidates/[a-z0-9-]+")
        expect(page.locator('[data-testid="candidate-name"]')).to_be_visible()

    def test_search_with_no_results_shows_empty_state(self, page: Page):
        """Empty search results show appropriate message."""
        page.goto("/candidates")

        page.fill('input[placeholder="Search candidates"]', "xyznonexistent123456")

        page.wait_for_load_state("networkidle")

        no_results = page.locator('[data-testid="no-results"]')
        expect(no_results).to_be_visible()
        expect(no_results).to_contain_text("No candidates found")

Test File Organization

tests/
├── unit/
│   ├── services/
│   │   ├── test_candidate_scorer.py
│   │   └── test_embedding_service.py
│   ├── repositories/
│   │   └── test_candidate_repository.py
│   └── utils/
│       └── test_skill_normalizer.py
├── integration/
│   ├── api/
│   │   ├── test_candidates_api.py
│   │   └── test_jobs_api.py
│   └── database/
│       └── test_candidate_queries.py
├── e2e/
│   ├── test_candidate_search.py
│   ├── test_job_matching.py
│   └── test_export_results.py
├── fixtures/
│   ├── candidates.py
│   └── jobs.py
└── conftest.py

Mocking External Services

Database Mock

import pytest
from unittest.mock import AsyncMock


@pytest.fixture
def mock_db_session():
    """Mock database session."""
    session = AsyncMock()
    session.execute.return_value.scalars.return_value.all.return_value = [
        Candidate(id=1, name="John Doe", skills=["Python", "FastAPI"])
    ]
    return session

Embedding Service Mock

import pytest
from unittest.mock import Mock
import numpy as np


@pytest.fixture
def mock_embedding_service():
    """Mock embedding service."""
    service = Mock()
    service.generate.return_value = np.random.rand(1536).tolist()
    return service

External API Mock

import pytest
from unittest.mock import patch


@pytest.fixture
def mock_esco_api():
    """Mock ESCO API responses."""
    with patch("src.integrations.esco_client.ESCOClient") as mock:
        mock.return_value.get_skills.return_value = [
            {"uri": "http://esco/skill/1", "label": "Python programming"}
        ]
        yield mock

Test Coverage Configuration

pyproject.toml

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = [
    "--cov=src",
    "--cov-report=term-missing",
    "--cov-report=html",
    "--cov-fail-under=85",
]

[tool.coverage.run]
branch = true
source = ["src"]
omit = ["*/__init__.py", "*/migrations/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]

Common Testing Mistakes to Avoid

Testing Implementation Details

# BAD - Testing private methods
def test_private_method(self):
    assert obj._internal_state == expected

# GOOD - Testing public behavior
def test_public_behavior(self):
    assert obj.get_result() == expected

Multiple Behaviors Per Test

# BAD - Testing multiple behaviors
def test_everything(self):
    assert validator.is_valid(data1)
    assert not validator.is_valid(data2)
    assert validator.errors == expected

# GOOD - Separate tests
def test_accepts_valid_data(self):
    assert validator.is_valid(valid_data)

def test_rejects_invalid_data(self):
    assert not validator.is_valid(invalid_data)

Excessive Mocking

# BAD - Mocking everything
def test_with_all_mocks(self):
    mock_db = Mock()
    mock_cache = Mock()
    mock_validator = Mock()
    service = Service(mock_db, mock_cache, mock_validator)

# GOOD - Mock only external dependencies
def test_with_minimal_mocks(self):
    real_validator = Validator()
    real_cache = InMemoryCache()
    mock_db = Mock()  # Only mock external dependency
    service = Service(mock_db, real_cache, real_validator)

Brittle Selectors (E2E)

# BAD - Breaks easily
await page.click(".css-class-xyz")

# GOOD - Resilient to changes
await page.click('button:has-text("Submit")')
await page.click('[data-testid="submit-button"]')

No Test Isolation

# BAD - Tests depend on each other
def test_creates_user(self):
    self.user = create_user()

def test_updates_same_user(self):
    update_user(self.user)  # Depends on previous test!

# GOOD - Independent tests
def test_creates_user(self):
    user = create_test_user()
    # Test logic

def test_updates_user(self):
    user = create_test_user()
    update_user(user)

Continuous Testing

Watch Mode During Development

pytest-watch
# Tests run automatically on file changes

Pre-Commit Hook

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest tests/unit -q
        language: system
        pass_filenames: false
        always_run: true

CI/CD Integration

# GitHub Actions
- name: Run Tests
  run: pytest --cov=src --cov-report=xml

- name: Upload Coverage
  uses: codecov/codecov-action@v4
  with:
    files: coverage.xml

Best Practices

  1. Write Tests First - Always TDD
  2. One Assert Per Test - Focus on single behavior
  3. Descriptive Test Names - Explain what's tested
  4. Arrange-Act-Assert - Clear test structure
  5. Mock External Dependencies - Isolate unit tests
  6. Test Edge Cases - None, empty, boundary values
  7. Test Error Paths - Not just happy paths
  8. Keep Tests Fast - Unit tests < 100ms each
  9. Clean Up After Tests - No side effects
  10. Review Coverage Reports - Identify gaps

Success Metrics

  • Changed-lines coverage >= 90%
  • Global coverage >= 85%
  • All tests passing (green)
  • No skipped or disabled tests
  • Fast test execution (unit < 100ms, integration < 1s, E2E < 10s)
  • E2E tests cover critical user flows
  • Tests catch bugs before production

Remember: Tests are not optional. They are the safety net that enables confident refactoring, rapid development, and production reliability. One vulnerability can compromise the entire platform.