testing-strategy
Comprehensive testing expertise across unit, integration, and e2e tests. Covers pytest, Vitest, Jest, Go testing, Playwright, Cypress. Test pyramids, TDD workflow, mocking patterns, coverage targets, property-based testing, snapshot testing, parameterized tests, fixtures, CI integration. Use when writing tests, designing test architecture, improving coverage, or setting up testing infrastructure.
You are a senior test engineer who designs bulletproof testing strategies across languages and frameworks.
Use this skill when
- Writing or reviewing unit, integration, or e2e tests
- Setting up testing infrastructure for a new project
- Designing a test strategy or choosing testing tools
- Debugging flaky tests or improving test reliability
- Configuring CI test pipelines or coverage gates
- Refactoring code to be more testable
Test Pyramid & Coverage Targets
Distribute effort by blast radius:
- Unit tests (70%): Fast, isolated, one assertion per behavior. Target: 80% line coverage, 60% branch coverage.
- Integration tests (20%): Real database, real HTTP, real filesystem. Target: critical paths only.
- E2E tests (10%): Full user flows through UI. Target: happy paths + top 3 error scenarios.
Coverage is a ceiling detector, not a quality metric. 80% line coverage with meaningful assertions beats 95% coverage with expect(true).toBe(true).
Unit Testing Patterns
Arrange-Act-Assert (AAA)
Every test follows this structure. No exceptions.
// Vitest / Jest
describe("calculateDiscount", () => {
it("applies 10% discount for orders over $100", () => {
// Arrange
const order = { items: [{ price: 150, qty: 1 }] };
// Act
const result = calculateDiscount(order);
// Assert
expect(result.discount).toBe(15);
});
});
Isolation via Dependency Injection
Never mock what you don't own. Wrap third-party code in an adapter, mock the adapter.
// Bad: mocking fetch globally
vi.spyOn(global, "fetch");
// Good: inject the dependency
interface HttpClient { get(url: string): Promise<Response>; }
function createUserService(http: HttpClient) {
return {
async getUser(id: string) {
const res = await http.get(`/users/${id}`);
return res.json();
},
};
}
// Test with a fake
const fakeHttp: HttpClient = { get: vi.fn().mockResolvedValue({ json: () => ({ id: "1", name: "Test" }) }) };
const service = createUserService(fakeHttp);
Go Testing
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want Config
wantErr bool
}{
{name: "valid yaml", input: "port: 8080", want: Config{Port: 8080}},
{name: "empty input", input: "", wantErr: true},
{name: "invalid port", input: "port: -1", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("ParseConfig() = %v, want %v", got, tt.want)
}
})
}
}
Pytest Fixtures & Parametrize
import pytest
@pytest.fixture
def db_session():
session = create_test_session()
yield session
session.rollback()
session.close()
@pytest.mark.parametrize("input_val,expected", [
("[email protected]", True),
("no-at-sign", False),
("@missing-local", False),
("spaces [email protected]", False),
])
def test_validate_email(input_val, expected):
assert validate_email(input_val) == expected
Integration Testing
Database Tests
Always use transactions that roll back. Never share state between tests.
// Vitest with Prisma
beforeEach(async () => {
await prisma.$executeRaw`BEGIN`;
});
afterEach(async () => {
await prisma.$executeRaw`ROLLBACK`;
});
it("creates a user with hashed password", async () => {
const user = await createUser(prisma, { email: "[email protected]", password: "secret" });
expect(user.passwordHash).not.toBe("secret");
expect(user.passwordHash).toMatch(/^\$2[aby]\$/); // bcrypt
});
API Integration Tests
Test the HTTP layer with a real server, fake dependencies.
import { createApp } from "../app";
import supertest from "supertest";
const app = createApp({ db: fakeDb, mailer: fakeMailer });
const request = supertest(app);
it("POST /api/users returns 201 with valid data", async () => {
const res = await request.post("/api/users").send({ email: "[email protected]", name: "Test" });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty("id");
});
it("POST /api/users returns 422 for duplicate email", async () => {
fakeDb.findUser.mockResolvedValue({ id: "existing" });
const res = await request.post("/api/users").send({ email: "[email protected]", name: "Test" });
expect(res.status).toBe(422);
});
E2E Testing
Playwright Best Practices
import { test, expect } from "@playwright/test";
// Use data-testid, never CSS selectors for structure
test("user can complete checkout", async ({ page }) => {
await page.goto("/products");
await page.getByTestId("product-card").first().click();
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByTestId("cart-icon").click();
await page.getByRole("link", { name: "Checkout" }).click();
await page.getByLabel("Email").fill("[email protected]");
await page.getByRole("button", { name: "Place order" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible({ timeout: 10_000 });
});
Key Playwright rules:
- Use
getByRole,getByLabel,getByTestId-- neverpage.$(".class"). - Set
timeouton assertions, not on actions. - Use
test.describe.configure({ mode: "serial" })only when tests genuinely depend on order. - Use
page.waitForResponse()to sync on network, notpage.waitForTimeout().
TDD Workflow
- Red: Write a failing test that describes the desired behavior.
- Green: Write the minimum code to make it pass.
- Refactor: Clean up without changing behavior. Tests stay green.
TDD is most valuable for: pure logic, parsers, validators, state machines. Skip TDD for: UI layout, third-party integrations, one-off scripts.
Property-Based Testing
Test invariants, not examples. Use fast-check (TS) or hypothesis (Python).
import fc from "fast-check";
test("sort is idempotent", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = mySort(arr);
expect(mySort(sorted)).toEqual(sorted);
})
);
});
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
assert len(sorted(xs)) == len(xs)
Snapshot Testing
Use sparingly. Best for: serialized output, CLI formatting, error messages. Bad for: UI components (too brittle).
it("formats error response correctly", () => {
const result = formatError(new ValidationError("bad input", ["field1", "field2"]));
expect(result).toMatchInlineSnapshot(`
{
"error": "bad input",
"fields": ["field1", "field2"],
"status": 422
}
`);
});
CI Integration
# GitHub Actions
- name: Test with coverage
run: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml
- name: Enforce coverage thresholds
run: |
npx vitest run --coverage --coverage.thresholds.lines=80 --coverage.thresholds.branches=60
For Go: go test -race -coverprofile=coverage.out ./... then go tool cover -func=coverage.out.
For Python: pytest --cov=src --cov-report=xml --cov-fail-under=80.
Flakiness Detection and Prevention
Identifying Flaky Tests
Tests that pass/fail non-deterministically are flaky. Common causes:
- Race conditions in async code
- Timing dependencies (setTimeout, Date.now())
- Shared global state between tests
- Network dependencies without proper mocking
- Random data generation without seeding
Flakiness Detection Pattern
// Run test multiple times to detect flakiness
describe("potential flaky test", () => {
it.each(Array.from({ length: 10 }))("run %#", async () => {
// Test code that might be flaky
const result = await fetchData();
expect(result).toBeDefined();
});
});
Timing Isolation Patterns
// BAD: Real timers, flaky
test("debounce", async () => {
debounce(fn, 100);
await new Promise((r) => setTimeout(r, 150)); // Flaky on slow CI
expect(fn).toHaveBeenCalled();
});
// GOOD: Fake timers, deterministic
test("debounce", () => {
vi.useFakeTimers();
debounce(fn, 100);
vi.advanceTimersByTime(150);
expect(fn).toHaveBeenCalled();
vi.useRealTimers();
});
// GOOD: Inject clock for testability
class Timer {
constructor(private clock = Date) {}
now() { return this.clock.now(); }
}
// Test with fake clock
const fakeClock = { now: () => 1000 };
const timer = new Timer(fakeClock);
Retry Logic for E2E
// Playwright: auto-retry assertions
await expect(page.getByText("Success")).toBeVisible({ timeout: 5000 });
// Custom retry for non-assertion operations
async function retryUntil<T>(fn: () => Promise<T>, predicate: (val: T) => boolean, maxAttempts = 3) {
for (let i = 0; i < maxAttempts; i++) {
const result = await fn();
if (predicate(result)) return result;
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("Retry exhausted");
}
Anti-Patterns to Avoid
- Testing implementation: Assert on behavior and output, never on internal method calls.
- Shared mutable state: Each test must set up its own world. Use
beforeEach, notbeforeAllfor mutable state. - Flaky time-dependent tests: Inject a clock. Never use
Date.now()directly in business logic. - Over-mocking: If you mock more than 3 things, your design needs refactoring, not more mocks.
- Testing framework code: Don't test that Express routes or React renders. Test YOUR logic through those tools.