Use when adding new error messages to React, or seeing "unknown error code" warnings.
npx skills add jgarrison929/openclaw-skills --skill "test-automator"
Install specific skill from multi-skill repository
# Description
Use when designing test strategies, writing unit/integration/e2e tests, setting up test infrastructure, or improving coverage. Invoke for TDD workflows, mocking patterns, test architecture, CI test pipelines, or flaky test debugging.
# SKILL.md
name: test-automator
description: Use when designing test strategies, writing unit/integration/e2e tests, setting up test infrastructure, or improving coverage. Invoke for TDD workflows, mocking patterns, test architecture, CI test pipelines, or flaky test debugging.
triggers:
- test
- testing
- unit test
- integration test
- e2e
- end-to-end
- Jest
- pytest
- JUnit
- Vitest
- Playwright
- Cypress
- mock
- stub
- fixture
- TDD
- coverage
- test-driven
- Testcontainers
role: specialist
scope: quality
output-format: code
Test Automator
Senior test automation specialist covering unit, integration, and end-to-end testing strategies with practical patterns across multiple languages and frameworks.
Role Definition
You are a senior quality engineer who designs comprehensive testing strategies. You follow the test pyramid, write deterministic tests, choose appropriate test boundaries, and build CI pipelines that give fast, reliable feedback. You test behavior, not implementation.
Core Principles
- Test pyramid — many unit tests, fewer integration, minimal e2e
- Test behavior, not implementation — assert outcomes, not internal mechanics
- Deterministic always — no flaky tests, no random failures, no timing dependencies
- Fast feedback — unit tests < 10s, full suite < 5 min in CI
- Arrange-Act-Assert — clear structure in every test
- One assertion focus per test — test one concept, name it descriptively
Test Pyramid Strategy
╱╲
╱ ╲ E2E / UI Tests (5-10%)
╱ ╲ - Critical user journeys only
╱──────╲ - Slow, expensive, fragile
╱ ╲
╱ ╲ Integration Tests (15-25%)
╱ ╲ - API boundaries, DB queries, external services
╱──────────────╲ - Medium speed, real dependencies
╱ ╲
╱ ╲ Unit Tests (65-80%)
╱ ╲ - Business logic, utilities, pure functions
╱────────────────────╲ - Fast, isolated, no external dependencies
What to Test at Each Level
| Level | What | How | Speed |
|---|---|---|---|
| Unit | Pure functions, business logic, validators, transformers | Mock external deps | < 10ms each |
| Integration | API endpoints, DB queries, message handlers, service interactions | Real DB (Testcontainers), mock external APIs | < 1s each |
| E2E | Critical user flows (login, purchase, onboarding) | Real browser, real backend | < 30s each |
Unit Testing Patterns
JavaScript/TypeScript (Vitest/Jest)
// Arrange-Act-Assert pattern
import { describe, it, expect, vi } from "vitest";
import { calculateDiscount, applyPromoCode } from "./pricing";
describe("calculateDiscount", () => {
it("applies percentage discount to subtotal", () => {
// Arrange
const subtotal = 100;
const discountPercent = 15;
// Act
const result = calculateDiscount(subtotal, discountPercent);
// Assert
expect(result).toBe(85);
});
it("never returns negative total", () => {
expect(calculateDiscount(50, 100)).toBe(0);
expect(calculateDiscount(50, 150)).toBe(0);
});
it("handles zero subtotal", () => {
expect(calculateDiscount(0, 20)).toBe(0);
});
it("rounds to 2 decimal places", () => {
expect(calculateDiscount(10, 33)).toBe(6.7);
});
});
// Testing async code
describe("applyPromoCode", () => {
it("returns discount when promo code is valid", async () => {
const mockRepo = {
findPromoCode: vi.fn().mockResolvedValue({
code: "SAVE20",
discountPercent: 20,
active: true,
}),
};
const result = await applyPromoCode(mockRepo, "SAVE20", 100);
expect(result).toEqual({ total: 80, discount: 20 });
expect(mockRepo.findPromoCode).toHaveBeenCalledWith("SAVE20");
});
it("throws when promo code is expired", async () => {
const mockRepo = {
findPromoCode: vi.fn().mockResolvedValue({
code: "OLD10",
discountPercent: 10,
active: false,
}),
};
await expect(applyPromoCode(mockRepo, "OLD10", 100)).rejects.toThrow(
"Promo code expired"
);
});
it("throws when promo code not found", async () => {
const mockRepo = {
findPromoCode: vi.fn().mockResolvedValue(null),
};
await expect(applyPromoCode(mockRepo, "FAKE", 100)).rejects.toThrow(
"Invalid promo code"
);
});
});
Python (pytest)
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from myapp.services import OrderService
from myapp.models import Order, OrderStatus
# Fixtures for reusable test data
@pytest.fixture
def sample_order() -> Order:
return Order(
id="order-1",
user_id="user-1",
items=["item-a", "item-b"],
total=99.99,
status=OrderStatus.PENDING,
created_at=datetime.utcnow(),
)
@pytest.fixture
def mock_repo() -> MagicMock:
repo = MagicMock()
repo.find_by_id = AsyncMock()
repo.save = AsyncMock()
return repo
@pytest.fixture
def order_service(mock_repo: MagicMock) -> OrderService:
return OrderService(repo=mock_repo)
class TestOrderService:
@pytest.mark.asyncio
async def test_cancel_pending_order(
self, order_service: OrderService, mock_repo: MagicMock, sample_order: Order
) -> None:
mock_repo.find_by_id.return_value = sample_order
result = await order_service.cancel("order-1")
assert result.status == OrderStatus.CANCELLED
mock_repo.save.assert_awaited_once()
@pytest.mark.asyncio
async def test_cancel_shipped_order_raises(
self, order_service: OrderService, mock_repo: MagicMock, sample_order: Order
) -> None:
sample_order.status = OrderStatus.SHIPPED
mock_repo.find_by_id.return_value = sample_order
with pytest.raises(ValueError, match="Cannot cancel shipped order"):
await order_service.cancel("order-1")
@pytest.mark.asyncio
async def test_cancel_nonexistent_order_raises(
self, order_service: OrderService, mock_repo: MagicMock
) -> None:
mock_repo.find_by_id.return_value = None
with pytest.raises(KeyError, match="Order not found"):
await order_service.cancel("missing-id")
# Parametrized tests for validation
@pytest.mark.parametrize(
"total,expected_valid",
[
(0, False),
(-1, False),
(0.01, True),
(99999.99, True),
(100000, False),
],
)
def test_order_total_validation(self, total: float, expected_valid: bool) -> None:
assert Order.is_valid_total(total) == expected_valid
Go (Table-Driven Tests)
func TestParsePrice(t *testing.T) {
tests := []struct {
name string
input string
want int64
wantErr string
}{
{name: "whole dollars", input: "42", want: 4200},
{name: "with cents", input: "42.50", want: 4250},
{name: "zero", input: "0.00", want: 0},
{name: "large amount", input: "999999.99", want: 99999999},
{name: "empty string", input: "", wantErr: "empty input"},
{name: "negative", input: "-5.00", wantErr: "negative price"},
{name: "too many decimals", input: "1.234", wantErr: "invalid format"},
{name: "not a number", input: "abc", wantErr: "invalid format"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePrice(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
Mocking Strategies
When to Mock vs. Use Real Dependencies
| Approach | Use When | Examples |
|---|---|---|
| Mock | External services, third-party APIs, slow I/O | Payment gateways, email, SMS |
| Fake | Need realistic behavior, complex interactions | In-memory database, fake filesystem |
| Stub | Simple return values, no behavior verification | Config values, feature flags |
| Spy | Need to verify calls while keeping real behavior | Logging, analytics |
| Real | Fast, deterministic, and you own it | Pure functions, utilities |
| Testcontainers | Database queries, message queues | PostgreSQL, Redis, Kafka |
Mock Anti-Patterns
// ❌ BAD: Mocking everything (testing the mocks, not the code)
it("creates user", async () => {
const mockValidator = vi.fn().mockReturnValue(true);
const mockHasher = vi.fn().mockReturnValue("hashed");
const mockRepo = { save: vi.fn().mockResolvedValue({ id: "1" }) };
const mockLogger = { info: vi.fn() };
const mockEvents = { emit: vi.fn() };
// You're testing that your mocks return what you told them to
// This test proves nothing
const result = await createUser(
mockValidator, mockHasher, mockRepo, mockLogger, mockEvents,
{ name: "Alice", email: "[email protected]" }
);
expect(result.id).toBe("1"); // Obviously!
});
// ✅ GOOD: Mock the boundary, test the behavior
it("creates user with hashed password and returns id", async () => {
const db = new InMemoryUserRepo();
const result = await createUser(db, {
name: "Alice",
email: "[email protected]",
password: "secret123",
});
expect(result.id).toBeDefined();
const saved = await db.findByEmail("[email protected]");
expect(saved?.name).toBe("Alice");
expect(saved?.passwordHash).not.toBe("secret123"); // Was hashed
});
// ❌ BAD: Testing implementation details
it("calls repo.save with correct args", async () => {
const mockRepo = { save: vi.fn() };
await service.createUser(mockRepo, userData);
expect(mockRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ name: "Alice" })
);
});
// ✅ GOOD: Testing behavior/outcome
it("persists user and returns created user", async () => {
const user = await service.createUser(testRepo, userData);
const found = await testRepo.findById(user.id);
expect(found?.name).toBe("Alice");
});
Integration Testing
API Integration Tests (Node.js + Supertest)
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { setupTestDB, teardownTestDB, resetDB } from "./helpers/db";
describe("POST /api/users", () => {
let app: Express;
beforeAll(async () => {
await setupTestDB();
app = createApp({ dbUrl: process.env.TEST_DATABASE_URL! });
});
afterAll(async () => {
await teardownTestDB();
});
beforeEach(async () => {
await resetDB(); // Clean slate for each test
});
it("creates user and returns 201", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "[email protected]" })
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: "Alice",
email: "[email protected]",
});
expect(response.body.createdAt).toBeDefined();
});
it("returns 400 for invalid email", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Alice", email: "not-an-email" })
.expect(400);
expect(response.body.errors).toContainEqual(
expect.objectContaining({ field: "email" })
);
});
it("returns 409 for duplicate email", async () => {
await request(app)
.post("/api/users")
.send({ name: "Alice", email: "[email protected]" })
.expect(201);
await request(app)
.post("/api/users")
.send({ name: "Bob", email: "[email protected]" })
.expect(409);
});
});
Testcontainers (Java)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withInitScript("schema.sql");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
var order = new Order("user-1", List.of("item-a"), BigDecimal.valueOf(99.99));
var saved = orderRepository.save(order);
var found = orderRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getUserId()).isEqualTo("user-1");
assertThat(found.get().getTotal()).isEqualByComparingTo("99.99");
}
@Test
void shouldFindOrdersByUserId() {
orderRepository.save(new Order("user-1", List.of("a"), BigDecimal.TEN));
orderRepository.save(new Order("user-1", List.of("b"), BigDecimal.ONE));
orderRepository.save(new Order("user-2", List.of("c"), BigDecimal.TEN));
var orders = orderRepository.findByUserId("user-1");
assertThat(orders).hasSize(2);
assertThat(orders).allMatch(o -> o.getUserId().equals("user-1"));
}
}
Testcontainers (Python with pytest)
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture
def db_session(postgres):
engine = create_engine(postgres.get_connection_url())
Base.metadata.create_all(engine)
session = Session(engine)
yield session
session.rollback()
session.close()
def test_create_and_find_user(db_session):
repo = UserRepository(db_session)
repo.create(User(name="Alice", email="[email protected]"))
found = repo.find_by_email("[email protected]")
assert found is not None
assert found.name == "Alice"
End-to-End Testing (Playwright)
import { test, expect } from "@playwright/test";
// Page Object pattern
class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
}
async expectError(message: string) {
await expect(this.page.getByRole("alert")).toContainText(message);
}
}
class DashboardPage {
constructor(private page: Page) {}
async expectWelcome(name: string) {
await expect(
this.page.getByRole("heading", { name: `Welcome, ${name}` })
).toBeVisible();
}
async expectLoaded() {
await expect(this.page.getByTestId("dashboard-content")).toBeVisible();
}
}
// Tests using Page Objects
test.describe("Authentication", () => {
test("successful login redirects to dashboard", async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboard = new DashboardPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "password123");
await dashboard.expectLoaded();
await dashboard.expectWelcome("Alice");
await expect(page).toHaveURL("/dashboard");
});
test("invalid credentials show error", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("[email protected]", "wrong-password");
await loginPage.expectError("Invalid email or password");
await expect(page).toHaveURL("/login");
});
test("locked account after 5 failed attempts", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
for (let i = 0; i < 5; i++) {
await loginPage.login("[email protected]", "wrong");
// Wait for error before retrying
await expect(page.getByRole("alert")).toBeVisible();
}
await loginPage.login("[email protected]", "wrong");
await loginPage.expectError("Account locked");
});
});
// Playwright config
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "on-first-retry",
},
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
{ name: "firefox", use: { browserName: "firefox" } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
Test Data Management
Factories / Builders
// Test data factory
class UserFactory {
private static counter = 0;
static create(overrides: Partial<User> = {}): User {
UserFactory.counter++;
return {
id: `user-${UserFactory.counter}`,
name: `Test User ${UserFactory.counter}`,
email: `user${UserFactory.counter}@test.com`,
role: "viewer",
active: true,
createdAt: new Date("2024-01-01"),
...overrides,
};
}
static createAdmin(overrides: Partial<User> = {}): User {
return UserFactory.create({ role: "admin", ...overrides });
}
static createBatch(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => UserFactory.create(overrides));
}
}
// Usage in tests
it("admin can delete other users", async () => {
const admin = UserFactory.createAdmin();
const target = UserFactory.create();
await service.deleteUser(admin, target.id);
expect(await repo.findById(target.id)).toBeNull();
});
Database Seeding
// Seed helper for integration tests
async function seedTestData(db: Database) {
const admin = await db.users.create({
name: "Admin",
email: "[email protected]",
role: "admin",
});
const users = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
db.users.create({
name: `User ${i}`,
email: `user${i}@test.com`,
role: "viewer",
})
)
);
const posts = await Promise.all(
users.flatMap((user) =>
Array.from({ length: 3 }, (_, i) =>
db.posts.create({
title: `Post ${i} by ${user.name}`,
authorId: user.id,
status: i === 0 ? "published" : "draft",
})
)
)
);
return { admin, users, posts };
}
CI Pipeline Configuration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:unit -- --coverage
- uses: actions/upload-artifact@v4
with:
name: unit-coverage
path: coverage/
integration:
runs-on: ubuntu-latest
needs: unit
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
e2e:
runs-on: ubuntu-latest
needs: integration
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-traces
path: test-results/
Coverage Strategy
What to Measure
| Metric | Target | Notes |
|---|---|---|
| Line coverage | 80%+ | Good baseline |
| Branch coverage | 75%+ | Catches missing edge cases |
| Function coverage | 90%+ | Ensures all public APIs tested |
| Critical path coverage | 100% | Auth, payments, data mutations |
What NOT to Cover
- Generated code (Prisma client, protobuf stubs)
- Configuration files
- Type definitions / interfaces
- Simple pass-through wrappers
- Third-party library internals
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
branches: 75,
functions: 90,
statements: 80,
},
exclude: [
"**/*.config.*",
"**/*.d.ts",
"**/generated/**",
"**/test/**",
"**/migrations/**",
],
},
},
});
Debugging Flaky Tests
Common Causes and Fixes
| Cause | Symptom | Fix |
|---|---|---|
| Shared state | Tests pass alone, fail together | Reset state in beforeEach |
| Timing | Intermittent failures | Use explicit waits, not sleep |
| Test order | Fails only in certain order | Randomize test order, isolate |
| Network | Fails in CI, passes locally | Mock external calls, use VCR |
| Time zones | Fails on certain machines | Use UTC, mock Date.now() |
| Race conditions | Random failures | Use waitFor, avoid shared mutable state |
// ❌ BAD: Arbitrary sleep
await page.click("#submit");
await new Promise((r) => setTimeout(r, 2000));
expect(await page.textContent("#result")).toBe("Success");
// ✅ GOOD: Explicit wait for condition
await page.click("#submit");
await expect(page.getByText("Success")).toBeVisible({ timeout: 5000 });
// ❌ BAD: Tests share state
let counter = 0;
it("increments", () => { counter++; expect(counter).toBe(1); });
it("increments again", () => { counter++; expect(counter).toBe(2); }); // Depends on order!
// ✅ GOOD: Each test is independent
it("increments from zero", () => {
let counter = 0;
counter++;
expect(counter).toBe(1);
});
TDD Workflow
1. RED — Write a failing test for the next piece of behavior
2. GREEN — Write the minimum code to make the test pass
3. REFACTOR — Clean up while keeping tests green
Repeat. Each cycle should take 1-5 minutes.
TDD Example
// Step 1: RED — Write the test first
describe("PasswordValidator", () => {
it("rejects passwords shorter than 8 characters", () => {
expect(validatePassword("short")).toEqual({
valid: false,
errors: ["Password must be at least 8 characters"],
});
});
});
// Step 2: GREEN — Minimal implementation
function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push("Password must be at least 8 characters");
}
return { valid: errors.length === 0, errors };
}
// Step 3: RED — Add next requirement
it("requires at least one uppercase letter", () => {
expect(validatePassword("alllowercase")).toEqual({
valid: false,
errors: ["Password must contain an uppercase letter"],
});
});
// Step 4: GREEN — Extend implementation
function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) errors.push("Password must be at least 8 characters");
if (!/[A-Z]/.test(password)) errors.push("Password must contain an uppercase letter");
return { valid: errors.length === 0, errors };
}
// Continue the cycle...
Test Naming Conventions
// Pattern: "should [expected behavior] when [condition]"
it("should return empty array when no users match filter")
it("should throw NotFoundError when user does not exist")
it("should send welcome email when user registers successfully")
// Or: "[unit] [action] [expected result]"
it("calculateTotal applies tax to subtotal")
it("UserService.create rejects duplicate emails")
it("OrderController.cancel returns 404 for missing order")
// Group by behavior, not by method
describe("User Registration", () => {
it("creates account with valid data")
it("rejects duplicate email addresses")
it("sends verification email")
it("hashes password before storing")
});
Adapted from buildwithclaude by Dave Poon (MIT)
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.