jgarrison929

test-automator

0
0
# Install this skill:
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

  1. Test pyramid β€” many unit tests, fewer integration, minimal e2e
  2. Test behavior, not implementation β€” assert outcomes, not internal mechanics
  3. Deterministic always β€” no flaky tests, no random failures, no timing dependencies
  4. Fast feedback β€” unit tests < 10s, full suite < 5 min in CI
  5. Arrange-Act-Assert β€” clear structure in every test
  6. 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.