Skip to content

Instantly share code, notes, and snippets.

@cpereiraweb
Forked from vedovelli/testing.SKILL.md
Created November 27, 2025 23:27
Show Gist options
  • Select an option

  • Save cpereiraweb/842240bcd83f7381ea56a35fc159903e to your computer and use it in GitHub Desktop.

Select an option

Save cpereiraweb/842240bcd83f7381ea56a35fc159903e to your computer and use it in GitHub Desktop.
name description
testing
Write comprehensive unit and integration tests following project conventions and best practices

Testing Skill

Stack

  • Runner: Vitest (jsdom) — globals enabled (describe, it, expect, etc.)
  • React: @testing-library/react, @testing-library/user-event
  • API Mocking: MSW (Mock Service Worker)

File Organization

Convention Value
Extension .spec.ts / .spec.tsx (NOT .test.ts)
Location __specs__ directories (NOT __tests__)
Structure Mirror source structure within __specs__
src/features/auth/
├── pages/login-page.tsx
├── components/registration-flow.tsx
└── __specs__/
    ├── pages/login-page.spec.tsx
    └── components/registration-flow.spec.tsx

Workflow

  1. Plan first — List all test cases, get user confirmation before writing
  2. Write iteratively — One test at a time, run and pass before moving on

Core Patterns

User Interactions — Always userEvent, NEVER fireEvent

import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

it("handles user input", async () => {
  const user = userEvent.setup()
  render(<LoginForm />)

  await user.type(screen.getByLabelText(/email/i), "user@example.com")
  await user.click(screen.getByRole("button", { name: /submit/i }))
})

Async Operations — Use waitFor

await waitFor(() => {
  expect(screen.getByText("Loading complete")).toBeInTheDocument()
}, { timeout: 3000 })

TanStack Form — NEVER wrap userEvent in act()

userEvent handles act() internally. Manual wrapping causes issues with subscription-based reactivity.

// ✅ CORRECT
await user.click(screen.getByRole("button", { name: /submit/i }))

// ❌ WRONG — userEvent already handles act()
act(() => { await user.click(button) })

High ROI Testing (MANDATORY)

Only write tests that prevent real bugs, not tests that achieve coverage metrics.

✅ HIGH ROI — Always Test

  • Complex business logic with multiple branches/edge cases
  • Data transformations (parsing, validation, normalization)
  • Error handling with multiple states
  • Critical user paths (auth, payments, data submission)
  • Bug-prone or frequently modified code

❌ LOW ROI — Skip

  • Simple pass-through functions, trivial getters/setters
  • Framework code (React, Zustand, TanStack Query itself)
  • Pure I/O without transformation logic
  • Obvious behavior (return a + b), static config objects

ROI Evaluation

Ask: "If this breaks, how would we know?"

  • User reports bug → HIGH ROI
  • TypeScript/CI catches it → LOW ROI
  • Silent failure → HIGH ROI

Rule: 10 tests for critical code > 100 tests for trivial code

Example Decisions

// ❌ LOW ROI — Trivial Zustand store
export const useRegisterStore = create((set) => ({
  password: null,
  setPassword: (password) => set({ password })
}))

// ❌ LOW ROI — Thin I/O wrapper
export async function saveStepData<T>(step: 1|2|3, data: T): Promise<void> {
  await registerStorage.setItem(getStepKey(step), data)
}

// ✅ HIGH ROI — Complex validation with branches
export async function validateStepAccess(requestedStep: number): Promise<StepValidationResult> {
  if (requestedStep === 1) return { canAccess: true, redirectTo: null }
  const step1Complete = await checkStep1()
  if (!step1Complete) return { canAccess: false, redirectTo: 1 }
  // ... more branches
}

// ✅ HIGH ROI — Error transformation
export function normalizeApiError(error: unknown): FriendlyError {
  if (error instanceof NetworkError) return { message: "Connection failed", code: "NETWORK" }
  if (error instanceof ValidationError) return { message: error.details, code: "VALIDATION" }
  return { message: "Unknown error", code: "UNKNOWN" }
}

Unit-Testable Logic in I/O Functions

Don't skip testing based on function names. HTTP/database functions often contain pure logic worth testing.

Unit-testable in I/O functions:

  • Request/response transformations
  • Header construction, URL building
  • Error normalization, status code routing
  • Parse/validation logic

Requires integration tests:

  • Actual network calls, real database ops, file system I/O

Test Behavior, Not Implementation

Ask: "Would a consumer of this API care about this test?"

  • Test breaks on internal refactor → testing implementation ❌
  • Test breaks on behavior change → testing behavior ✅

Assert vs Avoid

✅ Assert (Behavior) ❌ Avoid (Implementation)
Return values Mock call arguments structure
Thrown errors Internal function calls
Observable side effects mockFetch.mock.calls[0] inspection
Error messages expect(mockFn).toHaveBeenCalledWith(exactArgs)
// ❌ BAD — Testing implementation
it("calls fetch with correct headers", async () => {
  await httpResource("/api/test")
  expect(mockFetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({
    headers: expect.objectContaining({ "Content-Type": "application/json" })
  }))
})

// ✅ GOOD — Testing behavior
it("returns data when authenticated", async () => {
  mockFetch.mockResolvedValue(new Response(JSON.stringify({ data: "test" }), { status: 200 }))
  const result = await httpResource("/api/test")
  expect(result).toEqual({ data: "test" })
})

Keep internal functions private. Test the public contract.

Data-Driven Testing with it.each

Use object-based test cases for readability:

const testCases = [
  { description: "value within range", value: 5, min: 0, max: 10, expected: 5 },
  { description: "value below min", value: -5, min: 0, max: 10, expected: 0 },
  { description: "value above max", value: 15, min: 0, max: 10, expected: 10 },
]

it.each(testCases)("returns correct value when $description", ({ value, min, max, expected }) => {
  expect(clamp(value, min, max)).toBe(expected)
})

Benefits: Self-documenting, no positional confusion, $description interpolation

Schema Validation (Vitest 4)

import * as v from "valibot"

// ✅ Valid inputs — use schemaMatching
it.each(validNames)("accepts %s: %s", (_, name) => {
  expect(name).toEqual(expect.schemaMatching(NameSchema))
})

// ✅ Invalid inputs — use safeParse for error access
it.each(invalidNames)("rejects %s", (_, name, expectedMessage) => {
  const result = v.safeParse(NameSchema, name)
  expect(result.success).toBe(false)
  if (!result.success) expect(result.issues[0].message).toBe(expectedMessage)
})

// Transformation test — need safeParse for output
const result = v.safeParse(EmailSchema, "USER@EXAMPLE.COM")
expect(result.output).toBe("user@example.com")

Quality Checklist

  • .spec.ts(x) extension in __specs__ directory (mirrored structure)
  • userEvent for all interactions, no manual act() around it
  • waitFor for async operations
  • Object-based it.each test cases
  • expect.schemaMatching() for valid schema inputs
  • Tests behavior, not implementation
  • Reasonable test count — no redundant assertions
  • All tests pass with zero warnings

Common Issues

// Multiple buttons — use specific identifiers
screen.getByRole("button", { name: /browse files/i })  // ✅
screen.getByRole("button")  // ❌ fails with multiple buttons

// State updates — use act() for DOM events, but NOT with userEvent
act(() => { component.dispatchEvent(dragEnterEvent) })  // ✅ DOM events
await user.click(button)  // ✅ userEvent handles act() internally

Running Tests

pnpm test                          # Run all
pnpm test path/to/file.spec.tsx    # Run specific file
vitest                             # Watch mode
vitest --coverage                  # With coverage
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment