Testing
How to write effective tests and run them successfully.
When to Use
-
Writing unit, integration, or E2E tests
-
Debugging test failures
-
Reviewing test quality
-
Deciding what to mock vs use real implementations
Layer Distribution
-
Unit (60-70%): Mock at boundaries only
-
Integration (20-30%): Real deps, mock external services only
-
E2E (5-10%): No mocking - real user journeys
Writing Tests by Layer
Unit Tests
Purpose: Verify isolated business logic.
Mocking rules:
-
Mock at the edge only (databases, APIs, file system, time)
-
Test the real system under test with actual implementations
-
Use real internal collaborators - mock only external boundaries
// CORRECT: Mock only external dependency const service = new OrderService(mockRepository) // Repository is the edge const total = service.calculateTotal(order) expect(total).toBe(90)
// WRONG: Mocking internal methods vi.spyOn(service, 'applyDiscount') // Now you're testing the mock
Characteristics: < 100ms, no I/O, deterministic
Test here: Business logic, validation, transformations, edge cases
Integration Tests
Purpose: Verify components work together with real dependencies.
Mocking rules:
-
Use real databases
-
Use real caches
-
Mock only external third-party services (Stripe, SendGrid)
// CORRECT: Real DB, mock external payment API const db = await createTestDatabase() const paymentApi = vi.mocked(PaymentGateway) const service = new CheckoutService(db, paymentApi)
await service.checkout(cart)
expect(await db.orders.find(orderId)).toBeDefined() // Real DB expect(paymentApi.charge).toHaveBeenCalledOnce() // Mocked external
Characteristics: < 5 seconds, containerized deps, clean state between tests
Test here: Database queries, API contracts, service communication, caching
E2E Tests
Purpose: Validate critical user journeys in the real system.
Mocking rules:
-
No mocking - that's the entire point
-
Use real services (sandbox/test modes)
-
Real browser automation
// Real browser, real system (Playwright example) await page.goto('/checkout') await page.fill('#card', '4242424242424242') await page.click('[data-testid="pay"]')
await expect(page.locator('.confirmation')).toContainText('Order confirmed')
Characteristics: < 30 seconds, critical paths only, fix flakiness immediately
Test here: Signup, checkout, auth flows, smoke tests
Core Principles
Test Behavior, Not Implementation
// CORRECT: Observable behavior expect(order.total).toBe(108)
// WRONG: Implementation detail expect(order._calculateTax).toHaveBeenCalled()
Arrange-Act-Assert
// Arrange const mockEmail = vi.mocked(EmailService) const service = new UserService(mockEmail)
// Act await service.register(userData)
// Assert expect(mockEmail.sendTo).toHaveBeenCalledWith('user@example.com')
One Behavior Per Test
Multiple assertions OK if verifying same logical outcome.
Descriptive Names
// GOOD it('rejects order when inventory insufficient', ...)
// BAD it('test order', ...)
Test Isolation
No shared mutable state between tests.
Running Tests
Execution Order
-
Lint/typecheck - Fastest feedback
-
Unit tests - Fast, high volume
-
Integration tests - Real dependencies
-
E2E tests - Highest confidence
Debugging Failures
Unit test fails:
-
Read the assertion message carefully
-
Check test setup (Arrange section)
-
Run in isolation to rule out state leakage
-
Add logging to trace execution path
Integration test fails:
-
Check database state before/after
-
Verify mocks configured correctly
-
Look for race conditions or timing issues
-
Check transaction/rollback behavior
E2E test fails:
-
Check screenshots/videos (most frameworks capture these)
-
Verify selectors still match the UI
-
Add explicit waits for async operations
-
Run locally with visible browser to observe
-
Compare CI environment to local
Flaky Tests
Handle aggressively - they erode trust:
-
Quarantine - Move to separate suite immediately
-
Fix within 1 week - Or delete
-
Common causes:
-
Shared state between tests
-
Time-dependent logic
-
Race conditions
-
Non-deterministic ordering
Coverage
Quality over quantity - 80% meaningful coverage beats 100% trivial coverage.
Focus testing effort on business-critical paths (payments, auth, core domain logic). Skip generated code.
Edge Cases
Always test:
Boundaries: min-1, min, min+1, max-1, max, max+1, zero, one, many
Special values: null, empty, negative, MAX_INT, NaN, unicode, leap years, timezones
Errors: Network failures, timeouts, invalid input, unauthorized
Anti-Patterns
Pattern Problem
Over-mocking Testing mocks instead of code
Implementation testing Breaks on refactoring
Shared state Test order affects results
Test duplication Use parameterized tests instead