Test ts
My personal, curated set of artifacts for AI coding agents
npx -y skills add sergeyklay/.agents --skill test-tsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 5 stars5 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Write, review, and run TypeScript/React tests for this Next.js 16 App Router project. Use whenever writing or modifying *.test.ts or *.test.tsx files, adding test coverage to components, hooks, Server Actions, or utilities, setting up Vitest configuration, or asked about testing strategy. Covers Vitest (the project's test runner, not Jest), React Testing Library v16, accessibility-first queries, user-event for interactions, AAA structure, parameterized tests with describe.each and it.each, Prisma mock patterns, next/navigation and next/headers mocking, and the RSC boundary testing strategy. Do NOT use for Playwright end-to-end tests or performance benchmarks.
SKILL.md
15.1 KB, as published. Nobody here has run it
TypeScript/React testing
This project uses Vitest as the test runner. The Next.js 16 official documentation recommends Vitest over Jest for App Router projects: native ESM and TypeScript support require no additional transformation configuration, and Vitest runs 3–5x faster than Jest on equivalent suites. Do not introduce Jest.
Test runner commands
npm test # run all tests once
npm run test:watch # watch mode
npm run test:coverage # with coverage report
# Targeted runs
npx vitest run src/components/invoice/ # run tests in a directory
npx vitest run -t "renders the client" # filter by test name pattern
npx vitest run path/to/component.test.tsx # run a single file
Decision framework
Before writing any test, classify it:
| Category | What it covers | Vitest environment |
|---|---|---|
| Unit | Pure functions, utilities, Zod schemas, computed logic | node |
| Component | Client Components rendered with RTL | jsdom |
| Hook | Custom hooks via renderHook | jsdom |
| Server Action | 'use server' functions with mocked Prisma | node |
| Integration | Real database or external service | node + env gate |
jsdom is the default environment configured in vitest.config.ts. For Server Actions and utilities, add a file-level directive to switch to node:
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
Pick the lightest category that validates the behavior. Async Server Components (RSCs) cannot be rendered by RTL; see "RSC testing strategy" below and references/rsc-patterns.md for full mocking recipes.
File organization
- One test file per source file:
invoice-card.tsxmaps toinvoice-card.test.tsx. - Co-locate test files next to the source file, not in a separate
__tests__/directory. - Fixture factories live in
src/__fixtures__/<domain>.fixtures.ts. - All new features require tests; every bug fix requires a regression test.
Canonical test structure
Every test file follows the Arrange/Act/Assert pattern separated by blank lines. Do NOT write // Arrange, // Act, or // Assert comments. Tests read like a specification.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InvoiceCard } from './invoice-card';
import { buildInvoice } from '@/__fixtures__/invoice.fixtures';
describe('InvoiceCard', () => {
it('renders the client name and formatted amount', () => {
const invoice = buildInvoice({ clientName: 'Acme Corp', amount: 1500 });
render(<InvoiceCard invoice={invoice} />);
expect(screen.getByRole('heading', { name: 'Acme Corp' })).toBeInTheDocument();
expect(screen.getByText('$1,500.00')).toBeInTheDocument();
});
it('calls onMarkPaid with the invoice id when the mark-paid button is clicked', async () => {
const user = userEvent.setup();
const onMarkPaid = vi.fn();
const invoice = buildInvoice({ status: 'unpaid' });
render(<InvoiceCard invoice={invoice} onMarkPaid={onMarkPaid} />);
await user.click(screen.getByRole('button', { name: /mark paid/i }));
expect(onMarkPaid).toHaveBeenCalledExactlyOnceWith(invoice.id);
});
it('disables the mark-paid button when isPending is true', () => {
const invoice = buildInvoice({ status: 'unpaid' });
render(<InvoiceCard invoice={invoice} isPending />);
expect(screen.getByRole('button', { name: /mark paid/i })).toBeDisabled();
});
});
Structural rules:
describenames the component or function under test. Nestdescribeblocks to scope a scenario or method.itreads as a complete sentence:it('disables the submit button while the mutation is pending').- Each
itblock covers one logical scenario. Split complex assertions into focused tests.
RTL query priority
Use queries in accessibility-first order:
getByRole(first choice; exercises semantic HTML)getByLabelText(for labeled form inputs)getByPlaceholderText(fallback for unlabeled inputs)getByText(for non-interactive text content)getByTestId(last resort; adddata-testidonly when no semantic query fits)
// ✅ Accessibility-first
screen.getByRole('button', { name: /save invoice/i });
screen.getByLabelText('Invoice amount');
screen.getByRole('alert');
screen.getByRole('combobox', { name: 'Status' });
// ❌ Coupled to implementation details
screen.getByTestId('save-btn');
container.querySelector('.invoice-form');
User interactions
Always use @testing-library/user-event, not fireEvent. user-event simulates the full browser event sequence (pointerdown, focus, input, keydown, keyup, click). fireEvent dispatches a single synthetic event and misses intermediary behavior that real components react to.
const user = userEvent.setup();
await user.type(screen.getByLabelText('Client name'), 'Acme Corp');
await user.selectOptions(screen.getByRole('combobox', { name: 'Status' }), 'paid');
await user.click(screen.getByRole('button', { name: /save/i }));
await user.keyboard('{Escape}');
await user.clear(screen.getByRole('textbox', { name: 'Amount' }));
Parameterized tests
Use describe.each or it.each whenever multiple inputs share the same execution logic. Never loop with .forEach inside a single it block.
// ✅ Parameterized: each case gets its own entry in the test report
it.each([
{ status: 'paid', label: 'Paid', expectedClass: 'bg-green-100' },
{ status: 'overdue', label: 'Overdue', expectedClass: 'bg-red-100' },
{ status: 'pending', label: 'Pending', expectedClass: 'bg-yellow-100' },
{ status: 'draft', label: 'Draft', expectedClass: 'bg-muted' },
])('renders "$label" badge with the correct color for $status status', ({ status, label, expectedClass }) => {
render(<StatusBadge status={status} />);
const badge = screen.getByRole('status');
expect(badge).toHaveTextContent(label);
expect(badge).toHaveClass(expectedClass);
});
// ✅ describe.each when each group needs multiple it blocks
describe.each([
{ dueDate: new Date('2025-01-01'), expectedStatus: 'overdue' },
{ dueDate: new Date('2030-01-01'), expectedStatus: 'upcoming' },
])('invoice due $dueDate', ({ dueDate, expectedStatus }) => {
it('displays the correct status badge', () => { ... });
it('sorts before invoices due later', () => { ... });
});
// ❌ forEach hides failures and conflates scenarios
it('handles all badge statuses', () => {
['paid', 'overdue', 'pending'].forEach((status) => {
render(<StatusBadge status={status} />);
// ...
});
});
Fixture factories
Build domain objects with factory functions, not inline literals. Inline literals couple tests to schema shape and break silently when fields are added or renamed.
// src/__fixtures__/invoice.fixtures.ts
import type { Invoice } from '@/types/invoice.types';
export function buildInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: 'inv_test_001',
clientName: 'Test Client',
amount: 1000,
currency: 'USD',
status: 'unpaid',
dueDate: new Date('2026-12-31'),
createdAt: new Date('2026-01-01'),
userId: 'user_test_001',
...overrides, // must be last so callers can override any field
};
}
Co-locate factories with the domain type. One factory per domain entity. Name them build<Entity>.
Server Action testing
Call the Server Action function directly. Mock the Prisma client at the module boundary.
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { markInvoicePaid } from '@/lib/actions/invoice-actions';
vi.mock('@/lib/db', () => ({
db: {
invoice: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock('next/cache', () => ({
revalidateTag: vi.fn(),
}));
describe('markInvoicePaid', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns success and revalidates the invoices cache tag', async () => {
const { db } = await import('@/lib/db');
const { revalidateTag } = await import('next/cache');
vi.mocked(db.invoice.findUnique).mockResolvedValue(buildInvoice({ userId: 'user_1' }));
vi.mocked(db.invoice.update).mockResolvedValue(buildInvoice({ status: 'paid' }));
const result = await markInvoicePaid({ invoiceId: 'inv_test_001', paidAt: new Date('2026-03-01') });
expect(result).toEqual({ success: true });
expect(revalidateTag).toHaveBeenCalledWith('invoices');
});
it('returns field errors without calling the database when invoiceId is empty', async () => {
const { db } = await import('@/lib/db');
const result = await markInvoicePaid({ invoiceId: '', paidAt: new Date() });
expect(result.success).toBe(false);
expect(result.fieldErrors?.invoiceId).toBeDefined();
expect(db.invoice.update).not.toHaveBeenCalled();
});
});
Rules:
vi.clearAllMocks()inbeforeEachprevents state from leaking between tests.vi.mocked()provides typed access to mock functions. Never cast toany.- Import mocked modules inside the test body with
await import(...)aftervi.mock()hoisting. - Always test the Zod validation-failure path. Do not only cover the happy path.
For Prisma chains with relations, pagination, or $transaction, see references/mocking-patterns.md.
RSC testing strategy
Async Server Components cannot be rendered by React Testing Library. They execute on the server and return RSC payloads; jsdom has no mechanism for this. Use three complementary approaches.
Extract and unit-test the data layer. If an RSC calls db.invoice.findMany(), extract that into a standalone async function and test it in the node environment with a mocked Prisma client. The RSC becomes a thin rendering shell.
// src/lib/data/invoice-data.ts (extracted, testable)
export async function getInvoicesForUser(userId: string): Promise<Invoice[]> {
return db.invoice.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
}
// @vitest-environment node
it('returns an empty array when the user has no invoices', async () => {
vi.mocked(db.invoice.findMany).mockResolvedValue([]);
const result = await getInvoicesForUser('user_123');
expect(result).toEqual([]);
});
Test the Client Component leaves. The interactive parts of RSC trees are Client Components. Test those normally with RTL. The RSC serves as a server-rendered shell; the interactive leaves are fully covered by component tests.
Use E2E for full-page rendering. Playwright renders through the real Next.js server, the only environment where RSCs execute. Use Playwright for assertions that require the full RSC pipeline: Suspense boundaries, loading.tsx states, streaming.
// ❌ Async Server Components are server functions; render() does not work
render(<InvoicesPage />);
// ✅ Test the extracted data function in node environment
// ✅ Test the Client Component leaf separately
// ✅ Test the full page with Playwright
For mocking next/navigation, next/headers, cookies(), and Auth.js auth() in code called by RSCs or Server Actions, see references/rsc-patterns.md.
Hook testing
Test custom hooks with renderHook from React Testing Library. Wrap timer-dependent behavior with vi.useFakeTimers().
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useJobPoller } from './use-job-poller';
describe('useJobPoller', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('starts in pending state when a jobId is provided', () => {
const { result } = renderHook(() => useJobPoller('job_001'));
expect(result.current.status).toBe('pending');
});
it('transitions to completed when the polling interval resolves', async () => {
const fetchStatus = vi.fn().mockResolvedValue('completed');
const { result } = renderHook(() => useJobPoller('job_001', { fetchStatus }));
await act(() => vi.runAllTimersAsync());
expect(result.current.status).toBe('completed');
expect(fetchStatus).toHaveBeenCalledWith('job_001');
});
it('returns null status when no jobId is provided', () => {
const { result } = renderHook(() => useJobPoller(null));
expect(result.current.status).toBeNull();
});
});
Forbidden patterns
| Pattern | Reason |
|---|---|
| Snapshot tests for complex UI | Brittle; asserts structure, not behavior. Allowed only for simple deterministic pure-output functions. |
any in mock types | Defeats TypeScript strictness in test files. Use vi.mocked() for typed mocks. |
| Real database calls | Slow, order-dependent, and fragile in CI. Mock Prisma at the module boundary. |
fireEvent for user interactions | Skips the full browser event sequence. Use userEvent instead. |
// Arrange, // Act, // Assert comments | Tests should read naturally. Blank lines separate the phases. |
| Inline domain object literals | Couples tests to schema shape. Use fixture factories. |
.forEach inside a single it block | Hides individual failures in the report. Use it.each instead. |
Multiple unrelated assertions per it | Makes failure diagnosis harder. One scenario per test. |
jest.* APIs | This project uses Vitest. The equivalent APIs are vi.fn(), vi.mock(), vi.mocked(), vi.spyOn(). |
Validation checklist
After writing or modifying tests, verify:
-
npm testpasses with no failures -
// @vitest-environment nodepresent on all Server Action and utility test files - RTL queries use
getByRole,getByLabelText, orgetByTextas the first choice - All user interactions use
userEvent, notfireEvent - Multiple-input scenarios use
it.eachordescribe.each, not.forEach - Domain objects built with fixture factories; no raw inline literals
-
vi.clearAllMocks()called inbeforeEachwhen tests share mocked modules -
vi.mocked()used for typed mock access; noas jest.Mockcasts - Async Server Component logic extracted into standalone testable functions
- Zod validation-failure paths covered in Server Action tests
- New features have tests; bug fixes have regression tests