Agent Skills: Test Writing Skill

Write tests that verify atomic spec acceptance criteria and requirements. Maps each AC to specific test cases, follows AAA pattern, ensures deterministic isolated tests. Use in parallel with implementation or after.

UncategorizedID: matthew-plusprogramming/monorepo/test

Install this agent skill to your local

pnpm dlx add-skill https://github.com/matthew-plusprogramming/monorepo/tree/HEAD/.claude/skills/test

Skill Files

Browse the full folder contents for test.

Download Skill

Loading file tree…

.claude/skills/test/SKILL.md

Skill Metadata

Name
test
Description
Write tests that verify atomic spec acceptance criteria and requirements. Maps each AC to specific test cases, follows AAA pattern, ensures deterministic isolated tests. Use in parallel with implementation or after.

Test Writing Skill

Required Context

Pre-Flight Challenge

Before beginning work, address these operational feasibility questions:

  1. Is the test infrastructure (test runner, assertion library) installed and functional?
  2. Are required test fixtures and seed data available or creatable?
  3. Is the test execution environment ready (env vars, mock services, ports)?
  4. Does the test command (npm test) run successfully on existing tests?

If any question cannot be answered from available context, surface it as a finding -- do not skip.

Purpose

Write tests that verify atomic spec requirements with full traceability to acceptance criteria. Tests serve as executable validation of the spec contract.

Key Input: Atomic specs from .claude/specs/groups/<spec-group-id>/atomic/

Usage

/test <spec-group-id>                    # Write tests for all atomic specs
/test <spec-group-id> <atomic-spec-id>   # Write tests for specific atomic spec
/test <spec-group-id> --parallel         # Dispatch parallel test writers per atomic spec

Prerequisites

Before using this skill, verify:

  1. Spec group exists at .claude/specs/groups/<spec-group-id>/
  2. review_state is APPROVED in manifest.json
  3. Atomic specs exist in atomic/ directory
  4. Enforcement passed (orchestrator workflows only): When atomic_specs exists in manifest, verify atomic_specs.enforcement_status: "passing". For oneoff-spec workflows (no atomic specs), enforcement is not required — skip this check.

If prerequisites not met → STOP and resolve before writing tests.

Testing Philosophy

Tests Verify Atomic Spec, Not Implementation

  • Tests should validate what the system does (behavior)
  • Not how it does it (implementation details)
  • If implementation changes but behavior stays the same, tests should still pass

One Test Per Acceptance Criterion (Minimum)

  • Each AC in each atomic spec gets at least one test
  • Complex ACs may need multiple tests
  • Test name references atomic spec ID and AC for traceability

AAA Pattern (Arrange-Act-Assert)

Always structure tests with comments:

it('should clear token on logout (as-002 AC1)', () => {
  // Arrange
  const authService = new AuthService();
  authService.setToken('test-token');

  // Act
  authService.logout();

  // Assert
  expect(localStorage.getItem('auth_token')).toBeNull();
});

Test Writing Process

Step 1: Load and Verify Spec Group

# Load manifest
cat .claude/specs/groups/<spec-group-id>/manifest.json

# List atomic specs
ls .claude/specs/groups/<spec-group-id>/atomic/

Verify:

  • review_state is APPROVED
  • If atomic_specs exists in manifest (orchestrator workflow): atomic_specs.enforcement_status is passing
  • If atomic_specs is absent (oneoff-spec workflow): skip enforcement check
  • No blocking open questions

Step 2: Map Atomic Specs to Test Files

For each atomic spec:

# Read atomic spec
cat .claude/specs/groups/<spec-group-id>/atomic/as-001-logout-button-ui.md

Extract from each atomic spec:

  • Acceptance criteria (AC1, AC2, etc.)
  • Test strategy section
  • Edge cases
  • Error conditions

Create test mapping:

## Test Plan

| Atomic Spec | AC  | Test File            | Test Case                             |
| ----------- | --- | -------------------- | ------------------------------------- |
| as-001      | AC1 | user-menu.test.ts    | "should render logout button"         |
| as-002      | AC1 | auth-service.test.ts | "should clear token on logout"        |
| as-003      | AC1 | auth-router.test.ts  | "should redirect to /login"           |
| as-004      | AC1 | auth-service.test.ts | "should show error on failure"        |
| as-004      | AC2 | auth-service.test.ts | "should keep user logged in on error" |

Step 3: Identify Test Locations

Determine where tests should live:

# Find existing test patterns
glob "**/*.test.ts"

# Check for related tests
grep -r "describe.*Auth" --include="*.test.ts"

Follow project conventions:

  • Unit tests: src/**/__tests__/*.test.ts or co-located *.test.ts
  • Integration tests: tests/integration/*.test.ts
  • E2E tests: tests/e2e/*.test.ts

Step 4: Review Existing Test Patterns

Study how tests are written in this codebase:

# Read a representative test file
cat src/services/__tests__/auth.test.ts

Note:

  • Test framework (Jest, Vitest, Mocha)
  • Assertion library (expect, assert)
  • Mocking approach (jest.mock, vi.mock)
  • Builder patterns or factories
  • Setup/teardown patterns

Step 5: Write Tests Per Atomic Spec

For each atomic spec, create tests for all ACs:

// Reference atomic spec in describe block
describe('AuthService - as-002: Token Clearing', () => {
  describe('logout', () => {
    it('should clear authentication token (as-002 AC1)', () => {
      // Arrange - Set up test state
      const authService = new AuthService();
      localStorage.setItem('auth_token', 'test-token');

      // Act - Execute the behavior
      await authService.logout();

      // Assert - Verify the outcome
      expect(localStorage.getItem('auth_token')).toBeNull();
    });
  });
});

Step 6: Use Builders and Fakes

Prefer in-memory fakes over deep mocking:

Builder pattern:

// test/builders/user.builder.ts
export class UserBuilder {
  private user: User = {
    id: 'test-id',
    name: 'Test User',
    email: 'test@example.com',
  };

  withId(id: string): this {
    this.user.id = id;
    return this;
  }

  withEmail(email: string): this {
    this.user.email = email;
    return this;
  }

  build(): User {
    return { ...this.user };
  }
}

// In tests
const user = new UserBuilder().withEmail('custom@example.com').build();

Fake implementation:

// test/fakes/fake-auth-api.ts
export class FakeAuthApi implements AuthApi {
  private shouldFail = false;

  async logout(): Promise<void> {
    if (this.shouldFail) {
      throw new Error('Network error');
    }
    // Success - no-op for fake
  }

  setFailure(shouldFail: boolean): void {
    this.shouldFail = shouldFail;
  }
}

// In tests
const fakeApi = new FakeAuthApi();
const authService = new AuthService(fakeApi);

Step 7: Control External Boundaries

Make tests deterministic by controlling:

Time:

beforeEach(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2026-01-14T12:00:00Z'));
});

afterEach(() => {
  jest.useRealTimers();
});

Randomness:

const mockRandom = jest.spyOn(Math, 'random').mockReturnValue(0.5);

Network:

// Use fakes or mock API responses
const mockFetch = jest.fn().mockResolvedValue({
  ok: true,
  json: async () => ({ success: true }),
});
global.fetch = mockFetch;

Step 8: Run Tests

# Run tests
npm test

# Run with coverage
npm test -- --coverage

# Run specific test file
npm test -- auth-service.test.ts

Verify:

  • All tests passing
  • Coverage meets project standards (typically 80%+)
  • No flaky tests (run multiple times to confirm)

Step 9: Fill Test Evidence in Atomic Specs

For each atomic spec, update its Test Evidence section:

## Test Evidence

| AC  | Test File                                   | Line | Test Name                          | Status  |
| --- | ------------------------------------------- | ---- | ---------------------------------- | ------- |
| AC1 | src/services/**tests**/auth-service.test.ts | 24   | "should clear token on logout"     | ✅ Pass |
| AC2 | src/services/**tests**/auth-service.test.ts | 35   | "should invalidate server session" | ✅ Pass |

Also add to the atomic spec's Decision Log:

## Decision Log

- `2026-01-14T10:30:00Z`: Created from spec.md decomposition
- `2026-01-14T15:00:00Z`: Tests written - 2 tests covering AC1, AC2

Step 10: Update Manifest

Update manifest.json with test completion:

{
  "convergence": {
    "all_tests_passing": true
  },
  "decision_log": [
    // ... existing entries ...
    {
      "timestamp": "<ISO timestamp>",
      "actor": "agent",
      "action": "tests_complete",
      "details": "8 tests written for 4 atomic specs, 100% AC coverage"
    }
  ]
}

Step 11: Report Completion

## Tests Complete ✅

**Spec Group**: <spec-group-id>
**Atomic Specs**: 4/4 tested

**Test Coverage by Atomic Spec**:

- as-001: 2 tests (AC1, AC2)
- as-002: 2 tests (AC1, AC2)
- as-003: 2 tests (AC1, AC2)
- as-004: 2 tests (AC1, AC2)

**Total Tests**: 8
**AC Coverage**: 100% (8/8 ACs tested)
**Line Coverage**: 94%

**Test Files**:

- src/services/**tests**/auth-service.test.ts (4 tests)
- src/components/**tests**/user-menu.test.ts (2 tests)
- src/router/**tests**/auth-router.test.ts (2 tests)

**Next Steps**:

1. Run `/unify <spec-group-id>` to validate convergence

Testing Guidelines

Unit Test Guidelines

Test individual units in isolation:

// Good - Unit test for AuthService
it('should clear token on logout (as-002 AC1)', () => {
  const authService = new AuthService(fakeApi);
  authService.logout();
  expect(authService.getToken()).toBeNull();
});

// Bad - Integration test disguised as unit test
it('should clear token on logout', () => {
  const authService = new AuthService(); // Uses real API
  authService.logout(); // Makes real network call
  expect(authService.getToken()).toBeNull(); // Flaky!
});

Integration Test Guidelines

Test interactions between units:

// Integration test for full logout flow
it('should complete full logout flow (as-001 to as-004)', async () => {
  // Arrange - Real dependencies
  const api = new AuthApi(testConfig);
  const authService = new AuthService(api);
  const router = new Router();

  // Act
  await authService.logout();

  // Assert
  expect(router.currentRoute).toBe('/login');
  expect(authService.isAuthenticated()).toBe(false);
});

Edge Case Testing

Cover edge cases from atomic spec:

describe('edge cases', () => {
  it('should handle logout when already logged out (as-002 edge)', () => {
    // Arrange
    const authService = new AuthService();
    // User not logged in

    // Act & Assert - Should not throw
    expect(() => authService.logout()).not.toThrow();
  });

  it('should handle concurrent logout calls (as-002 edge)', async () => {
    // Arrange
    const authService = new AuthService();

    // Act - Multiple simultaneous logouts
    await Promise.all([
      authService.logout(),
      authService.logout(),
      authService.logout(),
    ]);

    // Assert - Only one API call made
    expect(mockApi.post).toHaveBeenCalledTimes(1);
  });
});

Error Path Testing

Test all error conditions per atomic spec:

// Tests for as-004: Error Handling
describe('error handling (as-004)', () => {
  it('should handle network error (as-004 AC1)', async () => {
    // Arrange
    mockApi.post.mockRejectedValue({ code: 'NETWORK_ERROR' });

    // Act & Assert
    await expect(authService.logout()).rejects.toThrow('Logout failed');
  });

  it('should keep user logged in on error (as-004 AC2)', async () => {
    // Arrange
    authService.setToken('test-token');
    mockApi.post.mockRejectedValue({ code: 'NETWORK_ERROR' });

    // Act
    try {
      await authService.logout();
    } catch (e) {
      // Expected
    }

    // Assert - Token still present
    expect(authService.getToken()).toBe('test-token');
  });
});

Parallel Execution with Implementation

Tests can be written in parallel with implementation. When a spec has cross-boundary contracts, the e2e-test-writer also runs in parallel as a third stream (3-way parallel dispatch: implementer + test-writer + e2e-test-writer). The test-writer produces unit/integration tests; the e2e-test-writer produces E2E tests from contracts only. Neither sees the implementation.

Approach: TDD-Style

  1. Write tests first (they will fail)
  2. Run implementer in parallel (and e2e-test-writer for cross-boundary specs)
  3. Tests pass as implementation completes
// Main agent dispatches both
Task({
  description: 'Write tests for logout',
  prompt: `Write tests for all atomic specs in spec group sg-logout-button.

  Location: .claude/specs/groups/sg-logout-button/atomic/

  Do NOT implement - test-writer only writes tests.`,
  subagent_type: 'test-writer',
});

Task({
  description: 'Implement logout',
  prompt: `Implement all atomic specs in spec group sg-logout-button.

  Location: .claude/specs/groups/sg-logout-button/atomic/

  Do NOT write tests - implementer only writes implementation.`,
  subagent_type: 'implementer',
});

Synchronization Point

After both complete:

  • Run tests to verify implementation
  • Use /unify to validate alignment

Test Anti-Patterns to Avoid

Don't Test Implementation Details

// Bad - Tests implementation
it('should call localStorage.removeItem', () => {
  authService.logout();
  expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token');
});

// Good - Tests behavior
it('should clear token on logout (as-002 AC1)', () => {
  authService.logout();
  expect(authService.getToken()).toBeNull();
});

Don't Use Deep Mocking

// Bad - Deep mock
jest.mock('./auth-service', () => ({
  AuthService: jest.fn().mockImplementation(() => ({
    logout: jest.fn(),
    getToken: jest.fn(),
  })),
}));

// Good - Fake implementation
class FakeAuthService implements AuthService {
  logout() {
    /* simple fake behavior */
  }
  getToken() {
    return null;
  }
}

Don't Write Brittle Tests

// Bad - Brittle (breaks if message changes)
expect(error.message).toBe('Logout failed. Please try again.');

// Good - Flexible (tests intent)
expect(error.message).toMatch(/logout failed/i);

Integration with Other Skills

After writing tests:

  • Use /implement (if not run in parallel) to implement features
  • Use /unify to validate spec-test-implementation alignment
  • Tests become evidence in convergence validation

After unify passes, the review chain is:

  1. /code-review - Code quality review
  2. /security - Security review
  3. /browser-test - UI validation (if UI changes)
  4. /docs - Documentation generation (if public API)
  5. Commit

Examples

Example 1: Unit Test for Atomic Spec as-002

Atomic spec as-002-token-clearing.md:

## Acceptance Criteria

- AC1: When logout called, clear authentication token from localStorage
- AC2: When logout called, invalidate server session

Test:

// src/services/__tests__/auth-service.test.ts
import { AuthService } from '../auth-service';
import { FakeAuthApi } from '../../test/fakes/fake-auth-api';

describe('AuthService - as-002: Token Clearing', () => {
  describe('logout', () => {
    it('should clear authentication token (as-002 AC1)', async () => {
      // Arrange
      const fakeApi = new FakeAuthApi();
      const authService = new AuthService(fakeApi);
      authService.setToken('test-token-123');

      // Act
      await authService.logout();

      // Assert
      expect(authService.getToken()).toBeNull();
    });

    it('should invalidate server session (as-002 AC2)', async () => {
      // Arrange
      const fakeApi = new FakeAuthApi();
      const authService = new AuthService(fakeApi);

      // Act
      await authService.logout();

      // Assert
      expect(fakeApi.wasLogoutCalled()).toBe(true);
    });
  });
});

Example 2: Integration Test for Multiple Atomic Specs

Test:

// tests/integration/logout-flow.test.ts
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { App } from "../../src/App";

describe("Logout flow integration (as-001 to as-003)", () => {
  it("should complete full logout flow", async () => {
    // Arrange
    render(<App initialRoute="/dashboard" />);

    // Act - as-001: Click logout button
    const logoutButton = screen.getByRole("button", { name: /logout/i });
    fireEvent.click(logoutButton);

    // Assert - as-002: Token cleared (implicit via auth state)
    // Assert - as-003: Redirect to login
    await waitFor(() => {
      expect(screen.getByRole("heading", { name: /login/i })).toBeInTheDocument();
    });
  });
});

Example 3: Error Path Test

Atomic spec as-004-error-handling.md:

## Acceptance Criteria

- AC1: When logout API call fails, display error message
- AC2: When logout fails, user remains logged in (token NOT cleared)

Test:

describe('error handling (as-004)', () => {
  it('should display error message on network failure (as-004 AC1)', async () => {
    // Arrange
    const fakeApi = new FakeAuthApi();
    fakeApi.setFailure(true);
    const authService = new AuthService(fakeApi);

    // Act & Assert
    await expect(authService.logout()).rejects.toThrow(/logout failed/i);
  });

  it('should keep user logged in on error (as-004 AC2)', async () => {
    // Arrange
    const fakeApi = new FakeAuthApi();
    fakeApi.setFailure(true);
    const authService = new AuthService(fakeApi);
    authService.setToken('test-token');

    // Act
    try {
      await authService.logout();
    } catch (e) {
      // Expected
    }

    // Assert - Token still present
    expect(authService.getToken()).toBe('test-token');
  });
});