Use when adding new error messages to React, or seeing "unknown error code" warnings.
npx skills add arielperez82/agents-and-skills --skill "testing"
Install specific skill from multi-skill repository
# Description
Testing patterns for behavior-driven tests with proper structure. Use when writing tests or test factories.
# SKILL.md
name: testing
description: Testing patterns for behavior-driven tests with proper structure. Use when writing tests or test factories.
Testing Patterns
Core Principles
Test behavior, not implementation. 100% coverage through business behavior, not implementation details.
Test structure matters. Proper organization makes tests maintainable and failures easy to diagnose.
Test Structure & Organization
Hierarchical Test Organization (Shallow)
Structure tests by:
1. Top-level describe for class/component/module
2. Nested describe for each method/function or major scenario ("when X condition")
3. it blocks for assertions
Keep nesting to two levels where possible (describe(Module) -> describe("when X") -> it(...)) to avoid hard-to-trace setup.
describe('PaymentProcessor', () => {
describe('processPayment', () => {
describe('when amount is valid', () => {
const processor = new PaymentProcessor();
const payment = getMockPayment({ amount: 100 });
const result = processor.processPayment(payment);
// Each assertion gets its own focused test
it('returns success status', () => {
expect(result.success).toBe(true);
});
it('includes transaction ID', () => {
expect(result.transactionId).toBeDefined();
});
it('charges the correct amount', () => {
expect(result.chargedAmount).toBe(100);
});
});
describe('when amount is negative', () => {
const processor = new PaymentProcessor();
const payment = getMockPayment({ amount: -100 });
const result = processor.processPayment(payment);
it('returns failure status', () => {
expect(result.success).toBe(false);
});
it('includes error message', () => {
expect(result.error).toContain('Amount must be positive');
});
});
});
});
Test Quality Principles
β
DO:
- Use pure setup functions for shared setup: Define top-level helper/factory functions that perform Arrange + Act and return the values needed by tests. Call these helpers inside the relevant describe block and assign their result to const variables.
- Avoid let in tests: Prefer const and pure functions that return new values. If you find yourself needing let plus beforeEach to reassign state, extract a helper instead.
- One Assertion Per Test: Keep each it focused on a single behavior/expectation (one logical assertion), but allow multiple its to reuse the same const result.
- Start Test Names With Verbs: Write as actions (e.g., returns 400, rejects invalid input)
- Mock External Dependencies: Isolate units by mocking databases, APIs, file systems
- Test Behavior Through Public API: Focus on what the code does, not how it does it
- Use Factory Functions: Create test data with optional overrides
β DON'T:
- Use let + beforeEach to reassign scenario state across nested blocks
- Hide setup in deeply nested hooks that make it hard to see where values come from
- Put multiple unrelated assertions in one test
- Start test names with "should" (the test either does or doesn't)
- Test implementation details (spying on internal methods)
- Use magic numbers or hardcoded test data
- Test private methods directly
- Use hooks for business/domain fixtures (use factories/helpers instead)
Anti-Pattern vs Pattern Examples
β ANTI-PATTERN: Repetitive Setup, Multiple Assertions
describe('PaymentProcessor', () => {
test('processes payment successfully', () => {
// Repetitive setup in every test
const payment = getMockPayment({ amount: 100 });
const processor = new PaymentProcessor();
const result = processor.processPayment(payment);
// Multiple unrelated assertions in one test - hard to debug failures
expect(result.success).toBe(true);
expect(result.transactionId).toBeDefined();
expect(result.chargedAmount).toBe(100);
expect(result.timestamp).toBeInstanceOf(Date);
});
test('validates CVV', () => {
// Same setup repeated again - duplication
const payment = getMockPayment({ cvv: '12' });
const processor = new PaymentProcessor();
const result = processor.processPayment(payment);
expect(result.success).toBe(false);
});
});
β CORRECT PATTERN: Pure Setup Functions, Focused Tests
describe('PaymentProcessor', () => {
describe('processPayment', () => {
describe('when payment is valid', () => {
const processor = new PaymentProcessor();
const payment = getMockPayment({ amount: 100 });
const result = processor.processPayment(payment);
// Each assertion in its own focused test
it('returns success status', () => {
expect(result.success).toBe(true);
});
it('includes transaction ID', () => {
expect(result.transactionId).toBeDefined();
});
it('charges the correct amount', () => {
expect(result.chargedAmount).toBe(100);
});
it('includes timestamp', () => {
expect(result.timestamp).toBeInstanceOf(Date);
});
});
describe('when CVV is invalid', () => {
const processor = new PaymentProcessor();
const payment = getMockPayment({ cvv: '12' });
const result = processor.processPayment(payment);
it('returns failure status', () => {
expect(result.success).toBe(false);
});
it('includes error message about CVV', () => {
expect(result.error).toContain('Invalid CVV');
});
});
});
});
Test Behavior, Not Implementation
Core Principle: Test through public API only. Never test implementation details.
Why this matters:
- Tests remain valid when refactoring
- Tests document intended behavior
- Tests catch real bugs, not implementation changes
Examples
β WRONG - Testing implementation:
// β Testing HOW (implementation detail)
it('calls validateAmount', () => {
const spy = jest.spyOn(validator, 'validateAmount');
processPayment(payment);
expect(spy).toHaveBeenCalled(); // Tests HOW, not WHAT
});
// β Testing private methods
it('validates CVV format', () => {
const result = validator._validateCVV('123'); // Private method!
expect(result).toBe(true);
});
// β Testing internal state
it('sets isValidated flag', () => {
processPayment(payment);
expect(processor.isValidated).toBe(true); // Internal state
});
β CORRECT - Testing behavior through public API:
describe('processPayment', () => {
describe('when amount is negative', () => {
const payment = getMockPayment({ amount: -100 });
const result = processPayment(payment);
it('rejects the payment', () => {
expect(result.success).toBe(false);
});
it('returns error message about amount', () => {
expect(result.error).toContain('Amount must be positive');
});
});
describe('when CVV is invalid', () => {
const payment = getMockPayment({ cvv: '12' }); // Only 2 digits
const result = processPayment(payment);
it('rejects the payment', () => {
expect(result.success).toBe(false);
});
it('returns error message about CVV', () => {
expect(result.error).toContain('Invalid CVV');
});
});
describe('when payment is valid', () => {
const payment = getMockPayment({ amount: 100, cvv: '123' });
const result = processPayment(payment);
it('accepts the payment', () => {
expect(result.success).toBe(true);
});
it('includes transaction ID', () => {
expect(result.data.transactionId).toBeDefined();
});
});
});
Test Hooks: Cross-Cutting Concerns Only
Use hooks only for cross-cutting concerns, not business setup.
beforeEach/afterEachare allowed for:- Resetting mocks (
jest.clearAllMocks()), timers, global config - Library/framework cleanup (e.g. React Testing Library, DB reset)
- Do not use hooks to build business/domain fixtures for a scenario; use factories/helpers inside
describeinstead.
// β
Allowed: Global concern
beforeEach(() => {
jest.clearAllMocks();
});
// β Avoid: Domain setup in hooks
beforeEach(() => {
payment = getMockPayment({ amount: 100 });
result = processPayment(payment);
});
// β
Correct: Domain setup with pure functions
describe('when payment is valid', () => {
const payment = getMockPayment({ amount: 100 });
const result = processPayment(payment);
// ...
});
Arrange-Act-Assert (AAA) with Helpers
ArrangeβActβAssert (AAA) with helpers:
- Arrange and Act usually happen in a setup/helper function
- Assert lives in the it block
- Use describe('when X') to express preconditions, and it('returns Y') to express outcomes
const processValidPayment = () => {
const payment = getMockPayment({ amount: 100 }); // Arrange
return processPayment(payment); // Act
};
describe('processPayment', () => {
describe('when amount is valid', () => {
const result = processValidPayment();
it('returns success status', () => {
expect(result.success).toBe(true); // Assert
});
it('includes transaction ID', () => {
expect(result.transactionId).toBeDefined();
});
});
});
Coverage Through Behavior
Key insight: When coverage drops, ask "What business behavior am I not testing?" not "What line am I missing?"
Validation code gets 100% coverage by testing the behavior it protects:
// Tests covering validation WITHOUT testing validator directly
describe('processPayment', () => {
describe('when amount is negative', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ amount: -100 });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
});
describe('when amount exceeds limit', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ amount: 15000 });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
});
describe('when CVV is invalid', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ cvv: '12' });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
});
describe('when payment is valid', () => {
it('processes the payment', () => {
const payment = getMockPayment({ amount: 100, cvv: '123' });
const result = processPayment(payment);
expect(result.success).toBe(true);
});
});
});
// β
Result: payment-validator.ts has 100% coverage through behavior
Example: Validation code in payment-validator.ts gets 100% coverage by testing processPayment() behavior, NOT by directly testing validator functions.
Test Factory Pattern
For test data, use factory functions with optional overrides.
Core Principles
- Return complete objects with sensible defaults
- Accept
Partial<T>overrides for customization - Validate with real schemas (don't redefine)
- NO
letin module scope - use factories and pure setup functions for fresh state
Basic Pattern
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
...overrides,
});
};
// Usage in tests with pure setup functions
describe('createUser', () => {
describe('when email is custom', () => {
const user = getMockUser({ email: '[email protected]' });
const result = createUser(user);
it('creates the user', () => {
expect(result.success).toBe(true);
});
it('uses the provided email', () => {
expect(result.user.email).toBe('[email protected]');
});
});
});
Complete Factory Example
import { UserSchema } from '@/schemas'; // Import real schema
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: new Date('2024-01-01'),
...overrides,
});
};
Why validate with schema?
- Ensures test data is valid according to production schema
- Catches breaking changes early (schema changes fail tests)
- Single source of truth (no schema redefinition)
Factory Composition
For nested objects, compose factories:
const getMockItem = (overrides?: Partial<Item>): Item => {
return ItemSchema.parse({
id: 'item-1',
name: 'Test Item',
price: 100,
...overrides,
});
};
const getMockOrder = (overrides?: Partial<Order>): Order => {
return OrderSchema.parse({
id: 'order-1',
items: [getMockItem()], // β
Compose factories
customer: getMockCustomer(), // β
Compose factories
payment: getMockPayment(), // β
Compose factories
...overrides,
});
};
// Usage with pure setup functions
describe('calculateTotal', () => {
describe('when order has multiple items', () => {
const order = getMockOrder({
items: [
getMockItem({ price: 100 }),
getMockItem({ price: 200 }),
],
});
const total = calculateTotal(order);
it('returns the sum of item prices', () => {
expect(total).toBe(300);
});
});
});
Factory Anti-Patterns
β WRONG: Module-scope let without beforeEach
let user: User = getMockUser(); // Shared mutable state at module level!
it('test 1', () => {
user.name = 'Modified User'; // Mutates shared state
// ...
});
it('test 2', () => {
expect(user.name).toBe('Test User'); // Fails! Modified by test 1
});
β CORRECT: Factory with const for fresh state
describe('userTests', () => {
describe('when user is created', () => {
const user = getMockUser(); // Fresh state for each scenario
it('test 1', () => {
const modifiedUser = { ...user, name: 'Modified User' };
// ...
});
it('test 2', () => {
expect(user.name).toBe('Test User'); // β
Passes - immutable state
});
});
});
β WRONG: Incomplete objects
const getMockUser = () => ({
id: 'user-123', // Missing name, email, role!
});
β CORRECT: Complete objects
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
...overrides, // All required fields present
});
};
β WRONG: Redefining schemas in tests
// β Schema already defined in src/schemas/user.ts!
const UserSchema = z.object({ ... });
const getMockUser = () => UserSchema.parse({ ... });
β CORRECT: Import real schema
import { UserSchema } from '@/schemas/user';
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
...overrides,
});
};
Coverage Theater Detection
Watch for these patterns that give fake 100% coverage:
Pattern 1: Mock the function being tested
β WRONG - Gives 100% coverage but tests nothing:
it('calls validator', () => {
const spy = jest.spyOn(validator, 'validate');
validate(payment);
expect(spy).toHaveBeenCalled(); // Meaningless assertion
});
β CORRECT - Test actual behavior:
describe('validate', () => {
describe('when payment is invalid', () => {
const payment = getMockPayment({ amount: -100 });
const result = validate(payment);
it('rejects the payment', () => {
expect(result.success).toBe(false);
});
it('includes error message', () => {
expect(result.error).toContain('Amount must be positive');
});
});
});
Pattern 2: Test only that function was called
β WRONG - No behavior validation:
it('processes payment', () => {
const spy = jest.spyOn(processor, 'process');
handlePayment(payment);
expect(spy).toHaveBeenCalledWith(payment); // So what?
});
β CORRECT - Verify the outcome:
describe('handlePayment', () => {
describe('when payment is valid', () => {
const payment = getMockPayment();
const result = handlePayment(payment);
it('processes the payment', () => {
expect(result.success).toBe(true);
});
it('returns transaction ID', () => {
expect(result.transactionId).toBeDefined();
});
});
});
Pattern 3: Test trivial getters/setters
β WRONG - Testing implementation, not behavior:
it('sets amount', () => {
payment.setAmount(100);
expect(payment.getAmount()).toBe(100); // Trivial
});
β CORRECT - Test meaningful behavior:
describe('calculateTotal', () => {
describe('when order has items with tax', () => {
const order = createOrder({ items: [item1, item2] });
const total = order.calculateTotal();
it('includes tax in total', () => {
expect(total).toBe(230); // 200 + 15% tax
});
});
});
Pattern 4: 100% line coverage, 0% branch coverage
β WRONG - Missing edge cases:
it('validates payment', () => {
const result = validate(getMockPayment());
expect(result.success).toBe(true); // Only happy path!
});
// Missing: negative amounts, invalid CVV, missing fields, etc.
β CORRECT - Test all branches:
describe('validate', () => {
describe('when amount is negative', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ amount: -100 });
expect(validate(payment).success).toBe(false);
});
});
describe('when amount exceeds limit', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ amount: 15000 });
expect(validate(payment).success).toBe(false);
});
});
describe('when CVV is invalid', () => {
it('rejects the payment', () => {
const payment = getMockPayment({ cvv: '12' });
expect(validate(payment).success).toBe(false);
});
});
describe('when payment is valid', () => {
it('accepts the payment', () => {
const payment = getMockPayment();
expect(validate(payment).success).toBe(true);
});
});
});
No 1:1 Mapping Between Tests and Implementation
Don't create test files that mirror implementation files.
β WRONG:
src/
payment-validator.ts
payment-processor.ts
payment-formatter.ts
tests/
payment-validator.test.ts β 1:1 mapping
payment-processor.test.ts β 1:1 mapping
payment-formatter.test.ts β 1:1 mapping
β CORRECT:
src/
payment-validator.ts
payment-processor.ts
payment-formatter.ts
tests/
process-payment.test.ts β Tests behavior, not implementation files
Why: Implementation details can be refactored without changing tests. Tests verify behavior remains correct regardless of how code is organized internally.
Test Naming Conventions
Start With Verbs
β
CORRECT - Action-oriented:
- returns success status
- rejects negative amounts
- includes transaction ID
- throws error for invalid input
- calculates total with tax
β WRONG - Using "should":
- should return success status
- should reject negative amounts
- should include transaction ID
Why avoid "should": The test either does the thing or it doesn't. No need for "should".
Summary Checklist
When writing tests, verify:
Structure
- [ ] Tests organized hierarchically (describe/describe/it, max 2 levels)
- [ ] Shared setup uses pure setup functions (not beforeEach for domain setup)
- [ ] Avoid
let- useconstwith pure functions - [ ] Each test has one assertion
- [ ] Test names start with verbs (not "should")
Behavior Testing
- [ ] Testing behavior through public API (not implementation details)
- [ ] No mocks of the function being tested
- [ ] No tests of private methods or internal state
- [ ] Edge cases covered (not just happy path)
Test Data
- [ ] Factory functions return complete, valid objects
- [ ] Factories validate with real schemas (not redefined in tests)
- [ ] Using Partial
for type-safe overrides - [ ] Fresh state via factories with const (not module-scope let, not beforeEach for domain setup)
Coverage
- [ ] Tests would pass even if implementation is refactored
- [ ] No 1:1 mapping between test files and implementation files
- [ ] Coverage achieved through testing behavior, not mocking internals
Schema Migration Test Coverage
For database schema migrations, ensure comprehensive coverage:
UNIQUE Constraint Testing
// β
CORRECT - Test UNIQUE constraint
describe('talent table', () => {
describe('handle uniqueness', () => {
it('rejects duplicate handles', async () => {
const handle = 'test-handle';
await createTestTalent({ handle });
const duplicate = createTestTalent({ handle });
const { error } = await client.from('talent').insert(duplicate);
expect(error?.code).toBe('23505'); // Unique violation
});
});
});
// β
CORRECT - Test single-row pattern (configuration table)
describe('configuration table', () => {
it('enforces single configuration row', async () => {
// Attempt to insert second row should fail or be prevented
const { error } = await client
.from('configuration')
.insert({ follow_up_intervals: [1, 2, 3] });
// Either fails with unique constraint or is prevented by application logic
expect(error).not.toBeNull();
});
});
Trigger Testing
Test ALL triggers created in migration:
// β
CORRECT - Test all updated_at triggers
describe('updated_at triggers', () => {
const tablesWithUpdatedAt = [
'entity', 'contact', 'contact_account', 'thread',
'talent', 'opportunity', 'talent_opportunity', 'deal', 'task', 'configuration'
];
tablesWithUpdatedAt.forEach(table => {
it(`updates ${table}.updated_at on row update`, async () => {
// Create record
const { data: created } = await createTestRecord(table);
const originalUpdatedAt = created.updated_at;
// Wait to ensure timestamp difference
await new Promise(resolve => setTimeout(resolve, 100));
// Update record
await client.from(table).update({ /* some field */ }).eq('id', created.id);
// Verify updated_at changed
const { data: updated } = await client.from(table).select('updated_at').eq('id', created.id).single();
expect(updated.updated_at).not.toEqual(originalUpdatedAt);
});
});
});
Security Testing (Read + Write Operations)
Use the template: assets/security-test-patterns.ts for reusable test patterns.
Test both read AND write operations for RLS:
// β
CORRECT - Security tests cover read AND write
describe('RLS security', () => {
describe('anon client access', () => {
it('blocks anon client from reading configuration', async () => {
const { data, error } = await anonClient.from('configuration').select('id').limit(1);
expect(data).toEqual([]);
});
it('blocks anon client from writing to configuration', async () => {
const { error } = await anonClient
.from('configuration')
.insert({ follow_up_intervals: [1, 2, 3] });
expect(error).not.toBeNull();
expect(error?.code).toBe('42501'); // Insufficient privilege
});
it('blocks anon client from updating configuration', async () => {
const { error } = await anonClient
.from('configuration')
.update({ follow_up_intervals: [1, 2, 3] })
.eq('id', 'some-id');
expect(error).not.toBeNull();
});
it('blocks anon client from deleting configuration', async () => {
const { error } = await anonClient
.from('configuration')
.delete()
.eq('id', 'some-id');
expect(error).not.toBeNull();
});
});
describe('service role access', () => {
it('allows service role to write to configuration', async () => {
const { error } = await serviceClient
.from('configuration')
.insert({ follow_up_intervals: [1, 2, 3] });
// Should succeed or fail for business logic reasons, not RLS
expect(error?.code).not.toBe('42501');
});
});
});
Branch Coverage Requirements
Target: Branch coverage β₯ 90%
- Test all constraint edge cases (invalid values, boundary conditions)
- Test all error paths (foreign key violations, constraint violations)
- Test both success and failure scenarios
- Use coverage reports to identify untested branches
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.