Opinionated rule set for writing clean, fast, and reliable unit tests in TypeScript using Jest and associated tooling.
Writing unit tests shouldn't feel like navigating a minefield. Yet most TypeScript developers find themselves wrestling with flaky tests, brittle snapshots, and test suites that take forever to run. Sound familiar?
Here's what's actually happening in most codebases:
These aren't just minor annoyances. They're productivity killers that make developers avoid writing tests altogether.
These Cursor Rules transform your testing workflow by focusing on what actually matters: fast, reliable tests that catch real bugs and don't break when you refactor.
Instead of generic testing advice, you get opinionated rules that solve specific TypeScript testing pain points:
// Before: Testing implementation details
it('should call userService.getUser with correct ID', () => {
const spy = jest.spyOn(userService, 'getUser');
component.loadUser(123);
expect(spy).toHaveBeenCalledWith(123);
});
// After: Testing behavior
it('displays user name when user loads successfully', async () => {
const user = { id: 123, name: 'John Doe' };
jest.spyOn(userService, 'getUser').mockResolvedValue(user);
await component.loadUser(123);
expect(component.displayName).toBe('John Doe');
});
Configured Jest settings and parallel execution patterns that keep your entire test suite under 10 seconds locally. No more context-switching while waiting for results.
Focus on testing behavior, not implementation. Your tests survive code restructuring because they're asserting what users care about, not how you implemented it.
Systematic approach to normal, boundary, error, and edge cases. No more "but it works on my machine" bugs slipping through.
GitHub Actions setup that fails fast, caches dependencies, and provides meaningful coverage reports without the usual CI/CD headaches.
// Write the failing test first
describe('validateEmail()', () => {
it('returns null when email is valid', () => {
expect(validateEmail('[email protected]')).toBe(null);
});
it('throws DomainError when email is invalid', () => {
expect(() => validateEmail('invalid')).toThrow(DomainError);
});
});
// Then write minimal code to pass
export function validateEmail(email: string): null {
if (!email.includes('@')) {
throw new DomainError('Invalid email format');
}
return null;
}
Instead of mocking everything, these rules teach you what to mock and when:
// Mock boundaries, not your own code
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(Date, 'now').mockReturnValue(1609459200000); // Fixed timestamp
});
// Use MSW for HTTP instead of complex fetch mocks
import { rest } from 'msw';
import { server } from '../test-utils/server';
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'Test User' }));
})
);
Focus on critical paths with meaningful thresholds:
// jest.config.ts
coverageThreshold: {
global: {
branches: 80, // Catches missing error handling
functions: 85, // Ensures public API coverage
lines: 85, // Reasonable without gaming the system
statements: 85 // Catches dead code
}
}
npm install -D jest ts-jest @types/jest msw
Create jest.config.ts:
export default {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageThreshold: {
global: { branches: 80, functions: 85, lines: 85, statements: 85 }
},
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
resetMocks: true,
restoreMocks: true,
clearMocks: true,
};
Place test files next to source code:
src/
user/
user.service.ts
user.service.spec.ts
user.controller.ts
user.controller.spec.ts
import { UserService } from './user.service';
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
it('returns null when user is unauthenticated', () => {
const result = userService.getCurrentUser(null);
expect(result).toBe(null);
});
it('throws DomainError when user ID is invalid', () => {
expect(() => userService.getUser(-1)).toThrow(DomainError);
});
});
Create .github/workflows/test.yml:
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with: { version: 8 }
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --ci
- uses: codecov/codecov-action@v3
Your test suite runs in under 10 seconds locally. You'll run tests more frequently because there's no friction.
You'll refactor code without fear because your tests are asserting behavior, not implementation details.
Systematic error case coverage catches edge cases before they reach production.
Other developers start asking about your testing approach because your code has fewer bugs and your tests actually help during code reviews.
The difference between these rules and typical testing advice? These are battle-tested patterns from teams shipping production TypeScript applications, not theoretical best practices.
Stop writing tests that break when you refactor. Start building a test suite that actually helps you ship better code faster.
You are an expert in TypeScript, Jest, ts-node, ESLint, Prettier, ts-jest, Mock Service Worker (MSW), and GitHub Actions.
Key Principles
- Follow Test-Driven Development (TDD): write the failing test first, then minimal code to pass.
- Each test must be isolated, deterministic, and idempotent; no hidden state, dates, or network calls.
- Prefer readability over cleverness: the intent of the test should be clear at a glance.
- Fast feedback loop: the entire test suite must finish < 10 s locally and < 60 s in CI.
- Assert behaviour, not implementation details; refactoring production code must rarely break tests.
- Aim for **critical-path coverage > 90 %**, but never sacrifice meaningful assertions for superficial coverage.
TypeScript / JavaScript Rules
- Use ES2022 syntax and `import`/`export`. Compile with target `es2020`.
- Name test files `<subject>.spec.ts` or `<subject>.test.ts` located next to the code (`src/foo/foo.spec.ts`).
- Use descriptive test titles: `it('returns null when user is unauthenticated', …)`.
- Avoid control flow or complex calculations inside tests; move helpers to `test-utils.ts`.
- Keep every spec ≤ 120 LOC and ≤ 10 assertions; split when larger.
- NEVER silence TypeScript errors with `// @ts-ignore` in test code.
Error Handling and Validation
- Cover normal, boundary, error, and edge cases:
• **Normal path:** expected input produces correct output.
• **Boundary:** off-by-one limits, empty strings, zero values.
• **Error:** invalid parameters throw `DomainError`.
• **Edge:** unusual but valid situations (e.g., leap year).
- Use `expect(() => fn()).toThrow(DomainError)` to assert error types.
- Stub external services with mocks; assert that failures are surfaced to callers, not swallowed.
- Favour early returns in production code; test verifies that guards trigger correctly.
Jest Framework Rules
- Use `jest --runInBand` only for debugging; default to parallel runs.
- Configure `jest.config.ts`:
```ts
export default {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageThreshold: { global: { branches: 80, functions: 85, lines: 85, statements: 85 } },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
resetMocks: true,
restoreMocks: true,
clearMocks: true,
};
```
- Always reset global state using `beforeEach(() => jest.resetAllMocks())` when mocks are modified.
- Prefer `jest.fn()` for simple spies, `jest.mock()` for modules, and MSW for HTTP.
- Never mock what you own; only mock boundaries (FS, network, random, time).
- Use `.toMatchInlineSnapshot()` for complex objects **only** when shape is stable.
Additional Sections
Mocking & Stubbing
- Time: use `vi.useFakeTimers()` or `jest.useFakeTimers()` and advance with `runOnlyPendingTimers()`.
- HTTP: intercept with MSW; declare handlers in `tests/msw/handlers.ts` and start server in `setupTests.ts`.
- Randomness: inject `RandomProvider` interface; supply deterministic implementation in tests.
Performance
- Mark slow tests with `test.concurrent.skip` until optimized; no skipped tests in `main` branch.
- Split suites using Jest projects when total runtime exceeds 3 minutes.
- Run flaky-test detector in CI: rerun any failed test up to 3 times; fail if passes inconsistently.
CI / CD Integration
- Use GitHub Actions:
```yaml
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with: { version: 8 }
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --ci
- uses: codecov/codecov-action@v3
```
- Fail build when coverage threshold not met or any test flaky.
- Cache `~/.pnpm-store` to speed up runs.
Security
- Never commit secret keys; mock secrets in tests via environment variables (`process.env.API_KEY = 'test'`).
- Validate that unexpected input is rejected (e.g., prototype pollution vectors).
Common Pitfalls & Guardrails
- Avoid `await` inside `expect`. Instead, `await expect(promise).resolves.toEqual(..)`.
- Do not test private functions; test through public API.
- Snapshot misuse leads to brittle tests; update snapshots only after human review.
- Delete obsolete tests when functionality is removed; failing tests should never live on `main`.
Example Template
```ts
import { sum } from './sum';
describe('sum()', () => {
it('adds two positive numbers', () => {
expect(sum(2, 3)).toBe(5);
});
it('throws when any argument is NaN', () => {
expect(() => sum(NaN, 1)).toThrow('Invalid number');
});
});
```
Checklist Before Merge
- [ ] All tests pass locally (\n) and in CI
- [ ] Coverage ≥ threshold
- [ ] No `console.log` left in tests
- [ ] No skipped or only tests (`.skip`, `.only`)
- [ ] Reviewer approval for new or updated snapshots