test-ts — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited test-ts (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
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 fileBefore 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.
invoice-card.tsx maps to invoice-card.test.tsx.__tests__/ directory.src/__fixtures__/<domain>.fixtures.ts.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:
describe names the component or function under test. Nest describe blocks to scope a scenario or method.it reads as a complete sentence: it('disables the submit button while the mutation is pending').it block covers one logical scenario. Split complex assertions into focused tests.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; add data-testid only 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');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' }));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} />);
// ...
});
});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>.
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() in beforeEach prevents state from leaking between tests.vi.mocked() provides typed access to mock functions. Never cast to any.await import(...) after vi.mock() hoisting.For Prisma chains with relations, pagination, or $transaction, see references/mocking-patterns.md.
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 PlaywrightFor mocking next/navigation, next/headers, cookies(), and Auth.js auth() in code called by RSCs or Server Actions, see references/rsc-patterns.md.
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();
});
});| 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(). |
After writing or modifying tests, verify:
npm test passes with no failures// @vitest-environment node present on all Server Action and utility test filesgetByRole, getByLabelText, or getByText as the first choiceuserEvent, not fireEventit.each or describe.each, not .forEachvi.clearAllMocks() called in beforeEach when tests share mocked modulesvi.mocked() used for typed mock access; no as jest.Mock casts~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.