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 |