| name | tdd |
| description | CRITICAL: Always use this skill, no matter what task you are working on! |
Test-Driven Development
Tests are not optional. Every non-trivial piece of logic ships with tests. The cycle is
red → green → refactor, but pragmatically applied — spikes and exploration are fine without tests,
but nothing moves to production without coverage that actually verifies the behavior.
Step 0 — Inspect the project first
Before writing a single test or line of implementation, understand the testing setup:
cat package.json | grep -E '"jest"|"vitest"|"bun"|"@testing-library"|"@jest"|"msw"|"supertest"'
cat jest.config.* 2>/dev/null || cat vitest.config.* 2>/dev/null
cat package.json | grep -E '"scripts"' -A 10 | grep "bun test"
ls bunfig.toml 2>/dev/null
cat package.json | grep -E '"playwright"|"@playwright"'
ls .maestro 2>/dev/null || find . -name "*.yaml" -path "*maestro*" | head -5
find . -name "*.test.ts" -o -name "*.test.tsx" -o -name "*.spec.ts" \
| grep -v node_modules | head -10
grep -r "#\[cfg(test)\]" src/ | head -10
cat Cargo.toml | grep -E '\[dev-dependencies\]' -A 20
Testing layers — understand which exist in the project:
Unit / Integration → Jest, Vitest, bun:test, cargo test
E2E Web → Playwright
E2E Mobile → Maestro
Match the conventions already established. Never introduce a second testing library when one is
already in use. Never add an E2E framework to a project that doesn't have one without confirming
with the developer first.
The TDD Cycle — Pragmatic
1. UNDERSTAND — clarify exactly what the unit should do, its inputs, outputs, edge cases
2. TEST — write tests that specify the behavior (can be before or alongside impl)
3. IMPLEMENT — write the minimum code to make tests pass
4. VERIFY — run the tests, confirm green
5. REFACTOR — clean up with tests as a safety net, keep green throughout
Flexible on order, strict on coverage:
- New feature from scratch → write tests first (or alongside), then implement
- Bug fix → write a failing test that reproduces the bug first, then fix
- Exploratory / spike → implement freely, but write tests before the code is considered done
- Refactor → tests must exist before touching implementation; never refactor untested code
Never skip the run. Always actually execute the tests — don't assume they pass.
Jest / Vitest
Detect which one is installed
cat package.json | grep -E '"jest"|"vitest"'
cat package.json | grep -A 5 '"scripts"' | grep -E "test"
Vitest and Jest have nearly identical APIs — describe, it/test, expect, vi/jest for
mocking. The differences are in config and import style. Match what the project uses.
Test file location — match the project
src/utils/format.ts
src/utils/format.test.ts
src/utils/format.ts
src/__tests__/utils/format.test.ts
find src -name "*.test.*" | head -5
Structure — match existing conventions
describe("formatBytes", () => {
it("formats bytes to human-readable string", () => {
expect(formatBytes(1024)).toBe("1 KB")
})
it("handles zero", () => {
expect(formatBytes(0)).toBe("0 B")
})
it("handles negative input", () => {
expect(() => formatBytes(-1)).toThrow("Input must be non-negative")
})
})
test("formatBytes: formats bytes to human-readable string", () => {
expect(formatBytes(1024)).toBe("1 KB")
})
What to test — and what not to
Test:
- Return values for all meaningful input variations
- Edge cases: empty, zero, null/undefined, boundary values, max values
- Error conditions: does it throw the right error with the right message?
- Side effects: was the right function called with the right args?
- Async behavior: resolution, rejection, loading states
Don't test:
- Implementation details — test behavior, not how it's achieved internally
- Third-party library internals — assume they work
- Trivial getters/setters with no logic
- Type definitions alone (TypeScript catches those at compile time)
- Private methods directly — test them through the public interface
Assertions — be specific
expect(result).toBeTruthy()
expect(result).toBeDefined()
expect(result).toBe("expected string")
expect(result).toEqual({ id: 1, name: "Jan" })
expect(result).toHaveLength(3)
expect(mockFn).toHaveBeenCalledWith("expected-arg")
expect(mockFn).toHaveBeenCalledTimes(1)
expect(() => parse("")).toThrow("Input cannot be empty")
Mocking — mock at the boundary, not deep inside
vi.mock("../api/users", () => ({
fetchUser: vi.fn().mockResolvedValue({ id: "1", name: "Jan" })
}))
jest.mock("../api/users", () => ({
fetchUser: jest.fn().mockResolvedValue({ id: "1", name: "Jan" })
}))
afterEach(() => {
vi.restoreAllMocks()
jest.restoreAllMocks()
})
Async tests
it("fetches user data", async () => {
const user = await getUser("123")
expect(user.name).toBe("Jan")
})
it("throws on not found", async () => {
await expect(getUser("nonexistent")).rejects.toThrow("User not found")
})
React component tests — check what's installed first
grep -E '"@testing-library/react"|"@testing-library/react-native"' package.json
import { render, screen, fireEvent } from '@testing-library/react'
it('calls onPress when button is tapped', () => {
const onPress = vi.fn()
render(<SubmitButton onPress={onPress} label="Submit" />)
fireEvent.click(screen.getByText('Submit'))
expect(onPress).toHaveBeenCalledTimes(1)
})
import { render, fireEvent } from '@testing-library/react-native'
it('calls onPress when button is tapped', () => {
const onPress = jest.fn()
const { getByText } = render(<SubmitButton onPress={onPress} label="Submit" />)
fireEvent.press(getByText('Submit'))
expect(onPress).toHaveBeenCalledTimes(1)
})
Component tests should test behavior, not implementation:
- Does it render the right content given these props?
- Does it call the right callback when interacted with?
- Does it show/hide things based on state?
- Does it handle loading/error states correctly?
Never test internal state, refs, or implementation details of a component.
Running tests
npx vitest run
npx vitest run src/utils/format.test.ts
npx vitest --coverage
npx jest
npx jest src/utils/format.test.ts
npx jest --coverage
npx jest --watch
Always run the specific test file after writing tests — don't run the full suite every time during
development. Run the full suite before considering the work done.
bun:test — Bun Server Projects
Bun ships its own test runner (bun:test) that is Jest-compatible but significantly faster.
Use it for Bun-native server projects. Do not add Jest or Vitest to a Bun project that already
uses bun:test.
Detect bun:test
cat package.json | grep -E '"test"' | grep "bun test"
grep -r "from 'bun:test'" src/ | head -5
API — Jest-compatible with Bun imports
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
describe("UserService", () => {
it("creates a user with hashed password", async () => {
const user = await UserService.create({
email: "mail@example.com",
password: "secret"
})
expect(user.email).toBe("mail@example.com")
expect(user.passwordHash).not.toBe("secret")
expect(user.passwordHash).toHaveLength(60)
})
})
Mocking with bun:test
import { mock, spyOn } from "bun:test"
const fetchMock = mock(() => Promise.resolve(new Response('{"ok":true}')))
globalThis.fetch = fetchMock
const spy = spyOn(db, "query").mockResolvedValue([{ id: 1 }])
afterEach(() => {
mock.restore()
})
Testing Bun.serve HTTP handlers directly
import { describe, it, expect } from "bun:test"
describe("POST /upload", () => {
it("rejects files over size limit", async () => {
const req = new Request("http://localhost/upload", {
method: "POST",
body: new Uint8Array(11 * 1024 * 1024)
})
const res = await handleUpload(req)
expect(res.status).toBe(413)
const body = await res.json()
expect(body.error).toContain("File too large")
})
it("accepts valid files", async () => {
const form = new FormData()
form.append("file", new Blob(["hello"]), "test.txt")
const req = new Request("http://localhost/upload", {
method: "POST",
body: form
})
const res = await handleUpload(req)
expect(res.status).toBe(200)
})
})
Testing Bun native APIs
import { describe, it, expect, afterEach } from "bun:test"
import { rm } from "node:fs/promises"
describe("FileStore", () => {
const testPath = "/tmp/bun-test-file"
afterEach(async () => {
await rm(testPath, { force: true })
})
it("writes and reads back correctly", async () => {
const data = new Uint8Array([1, 2, 3, 4])
await Bun.write(testPath, data)
const read = new Uint8Array(await Bun.file(testPath).arrayBuffer())
expect(read).toEqual(data)
})
})
Running bun:test
bun test
bun test src/utils/format.test.ts
bun test --watch
bun test --coverage
bun test --timeout 10000
Rust — cargo test
Test organization — match the project
grep -r "#\[cfg(test)\]" src/ | head -10
ls tests/ 2>/dev/null
Rust has two natural homes for tests:
Unit tests — inline in the module (preferred for testing private functions):
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn handles_negative_numbers() {
assert_eq!(add(-1, 1), 0);
}
}
Integration tests — in tests/ directory (public API only):
use my_crate::add;
#[test]
fn add_works_from_outside() {
assert_eq!(add(10, 20), 30);
}
Assertions
assert_eq!(result, expected);
assert_ne!(result, unexpected);
assert!(condition, "message if fails");
#[test]
#[should_panic(expected = "index out of bounds")]
fn panics_on_out_of_bounds() {
let v = vec![1, 2, 3];
let _ = v[99];
}
#[test]
fn parse_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let parsed = parse_input("valid")?;
assert_eq!(parsed.value, 42);
Ok(())
}
#[test]
fn parse_fails_on_empty() {
let result = parse_input("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty input"));
}
Test naming — descriptive, snake_case
#[test] fn returns_zero_for_empty_input() { ... }
#[test] fn rejects_negative_values() { ... }
#[test] fn handles_utf8_boundary_correctly() { ... }
#[test] fn test1() { ... }
#[test] fn it_works() { ... }
Test setup — use helper functions, not a test framework
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> Config {
Config {
timeout: 30,
retries: 3,
base_url: "http://localhost".to_string(),
}
}
#[test]
fn connects_with_valid_config() {
let config = make_config();
assert!(connect(config).is_ok());
}
}
Running tests
cargo test
cargo test test_name
cargo test --lib
cargo test --test integration_test
cargo test -- --nocapture
cargo test -- --test-threads=1
What to test in Rust
- All
Result/Option return paths — both Ok/Some and Err/None
- Boundary values for numeric types (0, max, overflow-adjacent values)
- String handling: empty, UTF-8 boundaries, whitespace-only
- Panic conditions with
#[should_panic]
- Trait implementations — does the type actually behave correctly?
- Any
unsafe code — it must have thorough tests
Playwright — Web E2E
Playwright tests real user flows in a real browser. They are slower than unit tests and should
cover critical paths — not every permutation. Use them for flows that span multiple pages,
require real network, or verify visual/interaction behavior that unit tests cannot catch.
Detect Playwright
cat package.json | grep "@playwright/test"
ls playwright.config.* 2>/dev/null
ls e2e/ tests/e2e/ 2>/dev/null
File structure — match the project
e2e/
tests/e2e/
playwright/
find . -name "*.spec.ts" | grep -v node_modules | head -10
Writing Playwright tests
import { test, expect } from "@playwright/test"
test("user can upload and share a file", async ({ page }) => {
await page.goto("/drive")
await page.getByRole("button", { name: "Upload" }).click()
await page.getByLabel("Choose file").setInputFiles("test-assets/document.pdf")
await expect(page.getByText("document.pdf")).toBeVisible()
await expect(page.getByText("Upload complete")).toBeVisible()
await page.getByRole("button", { name: "Share" }).click()
const shareLink = page.getByTestId("share-link")
await expect(shareLink).toBeVisible()
expect(await shareLink.inputValue()).toMatch(/^https:\/\//)
})
Locator strategy — preferred order
page.getByRole("button", { name: "Submit" })
page.getByRole("textbox", { name: "Email" })
page.getByLabel("Password")
page.getByTestId("file-upload-zone")
page.getByText("Upload complete")
page.locator(".btn-primary.upload")
page.locator('//div[@class="container"]/button')
Waiting — use assertions, never arbitrary sleeps
await page.waitForTimeout(2000)
await expect(page.getByRole("status")).toHaveText("Saved")
await expect(page.getByTestId("spinner")).not.toBeVisible()
await page.waitForURL("/dashboard")
Authentication — use storageState to avoid re-login on every test
export default defineConfig({
use: {
storageState: "e2e/.auth/user.json"
}
})
import { test as setup } from "@playwright/test"
setup("authenticate", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("mail@example.com")
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!)
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/drive")
await page.context().storageState({ path: "e2e/.auth/user.json" })
})
What Playwright should and shouldn't test
✅ Test with Playwright:
- Critical user journeys (login → upload → share → download)
- Flows that cross multiple pages or require real auth
- File upload/download behavior
- Form validation with real network responses
- Navigation and routing
❌ Don't test with Playwright:
- Unit-level logic (use Jest/Vitest/bun:test instead)
- Every component state (use @testing-library instead)
- Styling details (use visual regression tools if needed)
- Things that can be verified without a browser
Running Playwright
npx playwright test
npx playwright test e2e/upload.spec.ts
npx playwright test --headed
npx playwright test --ui
npx playwright test --project=chromium
npx playwright show-report
Maestro — Mobile E2E (React Native / Expo)
Maestro runs E2E tests against a real or simulated device. Tests are written in YAML and drive
the app through actual user interactions. Use for critical mobile flows that must be verified
on-device — not for unit logic.
Detect Maestro
ls .maestro/ 2>/dev/null
find . -name "*.yaml" | xargs grep -l "appId" 2>/dev/null | head -5
which maestro 2>/dev/null
File structure
.maestro/
flows/
login.yaml
upload_file.yaml
share.yaml
config.yaml # optional global config
Writing Maestro flows
appId: com.example.app
---
- launchApp
- tapOn: "Drive"
- tapOn:
id: "upload-button"
- assertVisible: "Upload from device"
- tapOn: "Upload from device"
- assertVisible:
text: "document.pdf"
timeout: 10000
- assertVisible: "Upload complete"
- assertNotVisible: "Uploading..."
- tapOn: "document.pdf"
- assertVisible: "Share"
- assertVisible: "Download"
Locator strategy for Maestro
- tapOn:
id: "upload-button"
- tapOn: "Upload"
- tapOn:
text: "Delete"
index: 0
Waiting and assertions
- assertVisible:
text: "Sync complete"
timeout: 15000
- assertNotVisible: "Loading..."
- tapOn: "Email"
- inputText: "mail@example.com"
- scrollUntilVisible:
element:
text: "document.pdf"
direction: DOWN
Running Maestro
maestro test .maestro/flows/upload_file.yaml
maestro test .maestro/flows/
maestro studio
maestro test --device <id> flow.yaml
What Maestro should and shouldn't test
✅ Test with Maestro:
- Critical user journeys end-to-end on real device/simulator
- Flows that involve native OS interactions (file picker, camera, permissions)
- Push notification handling
- Deep link navigation
- App state after backgrounding and foregrounding
❌ Don't test with Maestro:
- Unit logic (use Jest/bun:test)
- Component rendering (use @testing-library/react-native)
- Anything that can be verified without a running device
- Every possible state — only critical paths
Bug Fix Protocol — Always Reproduce First
When fixing a bug, the sequence is non-negotiable:
1. Write a test that reproduces the bug (it must fail before the fix)
2. Confirm the test fails — run it and see the red
3. Fix the bug
4. Confirm the test passes — run it and see the green
5. Run the full test suite — confirm nothing else broke
This proves the fix works and prevents regression.
it("formatBytes: handles zero correctly", () => {
expect(formatBytes(0)).toBe("0 B")
})
Coverage — What Good Looks Like
Coverage is a signal, not a goal. 100% coverage with weak assertions is worthless.
Good coverage means every meaningful behavior path is verified.
Aim for:
- All happy paths tested
- All error/failure paths tested
- Edge cases and boundaries tested
- Coverage naturally follows — don't chase the number
Meaningful coverage check:
npx vitest --coverage
npx jest --coverage
cargo tarpaulin --out Stdout
If coverage reveals an untested branch — add a test for it. If the branch is genuinely
unreachable dead code, remove it.
Test Data — Real API Data vs Mocks
When a test needs data that comes from an API or server, always check whether real data is
available before reaching for mocks. Real data finds bugs mocks never would.
Decision flow
Does the test need API/server data?
│
├─ Can we hit the real API/server in this test environment?
│ (dev server running, test DB seeded, API key available, no rate limits, fast enough)
│ │
│ ├─ YES → Use real data. Write an integration test.
│ │ Seed or query the actual endpoint. Don't mock it.
│ │
│ └─ NO → Ask the developer before inventing data:
│ "This test needs data from <endpoint/service>.
│ I can't reach it in this context. Should I:
│ (a) mock the response with example data?
│ (b) set up a test fixture/seed instead?
│ (c) skip this test and mark it as integration-only?"
│
└─ Never silently invent mock data that looks realistic.
Always ask first when real data isn't available.
Checking if the real API/server is reachable
curl -s http://localhost:3000/health | head -5
curl -s http://localhost:8080/api/ping | head -5
cat .env.test 2>/dev/null | grep -E "DATABASE_URL|DB_HOST|DB_PORT"
cat .env.local 2>/dev/null | grep -E "DATABASE_URL|DB_HOST"
cat .env.test 2>/dev/null | grep -E "API_KEY|API_URL|TEST_"
find . -name "*.test.ts" | xargs grep -l "supertest\|testClient\|fetch.*localhost" 2>/dev/null | head -5
If the real endpoint is reachable, write a genuine integration test against it. Only fall back
to mocking when you've confirmed the real thing isn't accessible.
When mocking is the right call
Mocking is appropriate when:
- External third-party APIs (payment providers, email services, cloud storage) — never call real ones in tests
- The API is unavailable in CI/test environments by design
- The operation has irreversible side effects (send email, charge card, delete data)
- The test needs to simulate specific error conditions (500, timeout, rate limit) that are hard to trigger on a real server
- Speed — a test suite that makes hundreds of real network calls is impractical
When mocking IS used, the mock data must accurately represent the real response shape. Check
the actual API response structure first (docs, existing calls in the codebase, or a real request),
then mirror it precisely.
grep -r "mockResolvedValue\|mockReturnValue" src/ | head -10
find . -name "*.json" -path "*fixtures*" -o -name "*.json" -path "*mocks*" | head -10
What NOT to Do
- Don't write tests after the fact just to hit a number — tests written to satisfy coverage metrics are usually weak. Write them to specify behavior.
- Don't test implementation details — if you refactor and tests break without behavior changing, the tests were wrong.
- Don't share mutable state between tests — every test must be independently runnable in any order.
- Don't use
any or loose assertions to make tests pass — a test that always passes is worse than no test.
- Don't leave
test.only or it.only committed — these silently disable the rest of the suite.
- Don't ignore flaky tests — a flaky test is a broken test. Fix or delete it.
- Don't mock what you own — mock external dependencies (HTTP, DB, time), not your own modules.