test-write

Installation
SKILL.md

Write Tests

Generate tests that catch real bugs and survive refactors. Every test should answer: what behavior breaks if this test fails?

Step 1: Read the Code

Before writing any test, understand the code under test. If the user provides a file or function, read it first.

What to identify Why it matters Watch out for
Inputs These become your test parameters Assuming only happy-path inputs
Outputs / return values These are your assertions Testing internal state instead of outputs
Side effects These need mocking or verification Missing async side effects, event emissions
Edge cases These are where bugs hide null, undefined, empty arrays, boundary values, concurrent calls
Error paths These need explicit test coverage Only testing success cases

Step 2: Determine Test Type

Match the code to the right kind of test.

Code under test Test type Tools
Pure function (util, helper, transformer) Unit test Vitest
React component Component test Vitest + @testing-library/react + @testing-library/user-event
React hook Hook test Vitest + @testing-library/react (renderHook)
API route / server handler Integration test Vitest + supertest or direct handler invocation
Full user flow (multi-page, auth) E2E test Playwright
Redux / Zustand store Unit test Vitest

Step 3: Write Tests Using the AAA Pattern

Structure every test as Arrange, Act, Assert. This keeps tests readable and intention-revealing.

describe('functionName', () => {
  it('should [expected behavior] when [condition]', () => {
    // Arrange — set up inputs and dependencies
    const input = { name: 'test', value: 42 };

    // Act — call the code under test
    const result = functionName(input);

    // Assert — verify the output
    expect(result).toEqual({ processed: true, name: 'test' });
  });
});

Test Naming Convention

Use describe for the unit, it for the behavior. Name tests so a failure message reads like a sentence.

describe('calculateTotal')
  it('should return 0 when cart is empty')
  it('should sum item prices including quantity')
  it('should apply percentage discount to subtotal')
  it('should throw when discount exceeds 100%')

Common Test Patterns

Testing Async Functions

it('should resolve with user data when API call succeeds', async () => {
  const user = await fetchUser('user-123');
  expect(user).toEqual({ id: 'user-123', name: 'Test' });
});

it('should reject when user is not found', async () => {
  await expect(fetchUser('nonexistent')).rejects.toThrow('User not found');
});

Testing React Hooks

import { renderHook, act } from '@testing-library/react';

it('should increment counter', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Testing React Components

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

it('should submit form with entered values', async () => {
  const onSubmit = vi.fn();
  const user = userEvent.setup();
  render(<LoginForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'test@example.com');
  await user.type(screen.getByLabelText('Password'), 'password123');
  await user.click(screen.getByRole('button', { name: 'Log in' }));

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

Testing Components with Async State

import { render, screen, waitFor } from '@testing-library/react';

it('should display user name after loading', async () => {
  render(<UserProfile userId="123" />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
  });
});

Testing API Routes (with mocking)

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Test User' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('should handle API error gracefully', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserProfile userId="123" />);

  await waitFor(() => {
    expect(screen.getByText('Failed to load user')).toBeInTheDocument();
  });
});

Testing Error Cases

it('should throw TypeError when input is null', () => {
  expect(() => processData(null)).toThrow(TypeError);
});

it('should render error boundary fallback on component error', () => {
  const BrokenComponent = () => { throw new Error('boom'); };

  render(
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <BrokenComponent />
    </ErrorBoundary>
  );

  expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});

Edge Cases to Always Consider

Include these in every test suite. They catch the bugs that slip past happy-path testing.

Edge case Example test
null / undefined inputs processData(null) — should throw or return default
Empty arrays / objects calculateTotal([]) — should return 0, not NaN
Boundary values paginate(items, { page: 0 }) — zero, negative, max int
Concurrent calls Two rapid clicks on submit — should not double-submit
Large inputs 10,000-item array — should not crash or hang
Unicode / special characters search('caf\u00e9') — should handle diacritics
Type coercion traps compare('1', 1) — should use strict equality

Code Conventions

Convention Rationale
One assertion per test (when practical) Pinpoints exactly what failed. Use multiple asserts only when they verify one logical behavior.
No test interdependency Every test must pass in isolation. Never rely on execution order or shared mutable state.
Use factories, not fixtures createUser({ name: 'Test' }) is flexible. fixtures/user.json is rigid and hides intent.
Colocate tests with source utils.ts and utils.test.ts in the same directory. Easy to find, easy to maintain.
Use vi.fn() for spies Track calls and arguments. Prefer over manual tracking variables.
Clean up after tests Use afterEach for DOM cleanup, timer restoration, server reset. Leaking state causes flaky tests.
Avoid test.skip accumulation Skipped tests rot. Fix or delete within one sprint.

Default Tools

Tool Purpose Install
Vitest Test runner and assertion library npm i -D vitest
@testing-library/react Component rendering and queries npm i -D @testing-library/react
@testing-library/user-event Realistic user interaction simulation npm i -D @testing-library/user-event
@testing-library/jest-dom Extended DOM matchers (toBeInTheDocument, etc.) npm i -D @testing-library/jest-dom
msw Network-level API mocking npm i -D msw
Playwright E2E browser testing npm i -D @playwright/test

When Tests Are Done

Deliver tests that meet these checks:

  • Every test has a clear name. Reading the test name alone tells you what broke.
  • No implementation details tested. Tests don't reference internal variable names, CSS classes (prefer roles), or private methods.
  • Edge cases are covered. At minimum: null input, empty collection, error path.
  • Tests run independently. Shuffle the order — they should still pass.
  • Mocks are minimal. Only mock what crosses a boundary (network, file system, time). Let everything else run for real.
Weekly Installs
9
GitHub Stars
34
First Seen
1 day ago