Back to Blog
TestingUnit TestsIntegrationE2E

Testing Strategies: Unit, Integration, and E2E

Build a testing strategy. From unit tests to integration tests to end-to-end testing and when to use each.

B
Bootspring Team
Engineering
May 12, 2022
6 min read

A balanced testing strategy catches bugs efficiently. Here's how to structure your tests at each level.

Testing Pyramid#

/\ / \ E2E Tests (few) /----\ - Critical user journeys / \ - Slow, expensive /--------\ / \ Integration Tests (some) / \ - Component interactions /--------------\- API endpoints / \ /------------------\ Unit Tests (many) - Fast, isolated - Business logic

Unit Tests#

1// Test isolated units of code 2import { describe, it, expect } from 'vitest'; 3import { calculateDiscount, formatPrice } from './pricing'; 4 5describe('calculateDiscount', () => { 6 it('applies percentage discount', () => { 7 expect(calculateDiscount(100, { type: 'percentage', value: 10 })) 8 .toBe(90); 9 }); 10 11 it('applies fixed discount', () => { 12 expect(calculateDiscount(100, { type: 'fixed', value: 15 })) 13 .toBe(85); 14 }); 15 16 it('does not go below zero', () => { 17 expect(calculateDiscount(10, { type: 'fixed', value: 15 })) 18 .toBe(0); 19 }); 20 21 it('handles no discount', () => { 22 expect(calculateDiscount(100, null)) 23 .toBe(100); 24 }); 25}); 26 27describe('formatPrice', () => { 28 it('formats USD correctly', () => { 29 expect(formatPrice(1234.56, 'USD')).toBe('$1,234.56'); 30 }); 31 32 it('formats EUR correctly', () => { 33 expect(formatPrice(1234.56, 'EUR')).toBe('€1,234.56'); 34 }); 35 36 it('handles zero', () => { 37 expect(formatPrice(0, 'USD')).toBe('$0.00'); 38 }); 39});
1// Testing with mocks 2import { UserService } from './UserService'; 3import { vi } from 'vitest'; 4 5describe('UserService', () => { 6 const mockRepository = { 7 findById: vi.fn(), 8 save: vi.fn(), 9 }; 10 11 const mockEmailService = { 12 sendWelcome: vi.fn(), 13 }; 14 15 const service = new UserService(mockRepository, mockEmailService); 16 17 beforeEach(() => { 18 vi.clearAllMocks(); 19 }); 20 21 it('creates user and sends welcome email', async () => { 22 const userData = { email: 'test@example.com', name: 'Test' }; 23 const savedUser = { id: '123', ...userData }; 24 25 mockRepository.save.mockResolvedValue(savedUser); 26 mockEmailService.sendWelcome.mockResolvedValue(undefined); 27 28 const result = await service.createUser(userData); 29 30 expect(result).toEqual(savedUser); 31 expect(mockRepository.save).toHaveBeenCalledWith(userData); 32 expect(mockEmailService.sendWelcome).toHaveBeenCalledWith('test@example.com'); 33 }); 34 35 it('throws on invalid email', async () => { 36 const userData = { email: 'invalid', name: 'Test' }; 37 38 await expect(service.createUser(userData)) 39 .rejects.toThrow('Invalid email'); 40 41 expect(mockRepository.save).not.toHaveBeenCalled(); 42 }); 43});

Integration Tests#

1// Test component interactions 2import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 3import { createTestDatabase, cleanupDatabase } from './test-utils'; 4import { UserRepository } from './UserRepository'; 5import { prisma } from './db'; 6 7describe('UserRepository', () => { 8 beforeAll(async () => { 9 await createTestDatabase(); 10 }); 11 12 afterAll(async () => { 13 await cleanupDatabase(); 14 }); 15 16 beforeEach(async () => { 17 await prisma.user.deleteMany(); 18 }); 19 20 const repository = new UserRepository(prisma); 21 22 it('creates and retrieves user', async () => { 23 const created = await repository.create({ 24 email: 'test@example.com', 25 name: 'Test User', 26 }); 27 28 const retrieved = await repository.findById(created.id); 29 30 expect(retrieved).toEqual(created); 31 }); 32 33 it('returns null for non-existent user', async () => { 34 const result = await repository.findById('non-existent'); 35 expect(result).toBeNull(); 36 }); 37 38 it('updates user', async () => { 39 const user = await repository.create({ 40 email: 'test@example.com', 41 name: 'Original Name', 42 }); 43 44 const updated = await repository.update(user.id, { 45 name: 'New Name', 46 }); 47 48 expect(updated.name).toBe('New Name'); 49 expect(updated.email).toBe('test@example.com'); 50 }); 51});
1// API integration tests 2import { describe, it, expect } from 'vitest'; 3import request from 'supertest'; 4import { app } from './app'; 5import { prisma } from './db'; 6 7describe('POST /api/users', () => { 8 beforeEach(async () => { 9 await prisma.user.deleteMany(); 10 }); 11 12 it('creates a new user', async () => { 13 const response = await request(app) 14 .post('/api/users') 15 .send({ 16 email: 'test@example.com', 17 name: 'Test User', 18 }); 19 20 expect(response.status).toBe(201); 21 expect(response.body).toMatchObject({ 22 email: 'test@example.com', 23 name: 'Test User', 24 }); 25 expect(response.body.id).toBeDefined(); 26 }); 27 28 it('returns 400 for invalid email', async () => { 29 const response = await request(app) 30 .post('/api/users') 31 .send({ 32 email: 'invalid', 33 name: 'Test', 34 }); 35 36 expect(response.status).toBe(400); 37 expect(response.body.error).toContain('email'); 38 }); 39 40 it('returns 409 for duplicate email', async () => { 41 await prisma.user.create({ 42 data: { email: 'test@example.com', name: 'Existing' }, 43 }); 44 45 const response = await request(app) 46 .post('/api/users') 47 .send({ 48 email: 'test@example.com', 49 name: 'New User', 50 }); 51 52 expect(response.status).toBe(409); 53 }); 54});

End-to-End Tests#

1// Playwright E2E tests 2import { test, expect } from '@playwright/test'; 3 4test.describe('User Registration', () => { 5 test('completes registration flow', async ({ page }) => { 6 // Navigate to registration 7 await page.goto('/register'); 8 9 // Fill form 10 await page.fill('[name="email"]', 'newuser@example.com'); 11 await page.fill('[name="password"]', 'SecurePass123!'); 12 await page.fill('[name="confirmPassword"]', 'SecurePass123!'); 13 14 // Submit 15 await page.click('button[type="submit"]'); 16 17 // Verify redirect to dashboard 18 await expect(page).toHaveURL('/dashboard'); 19 20 // Verify welcome message 21 await expect(page.locator('h1')).toContainText('Welcome'); 22 }); 23 24 test('shows validation errors', async ({ page }) => { 25 await page.goto('/register'); 26 27 // Submit empty form 28 await page.click('button[type="submit"]'); 29 30 // Check for error messages 31 await expect(page.locator('.error-email')) 32 .toContainText('Email is required'); 33 await expect(page.locator('.error-password')) 34 .toContainText('Password is required'); 35 }); 36}); 37 38test.describe('Checkout Flow', () => { 39 test.beforeEach(async ({ page }) => { 40 // Login before each test 41 await page.goto('/login'); 42 await page.fill('[name="email"]', 'test@example.com'); 43 await page.fill('[name="password"]', 'password123'); 44 await page.click('button[type="submit"]'); 45 await page.waitForURL('/dashboard'); 46 }); 47 48 test('completes purchase', async ({ page }) => { 49 // Add item to cart 50 await page.goto('/products/1'); 51 await page.click('button:has-text("Add to Cart")'); 52 53 // Go to checkout 54 await page.click('a:has-text("Checkout")'); 55 56 // Fill payment info 57 await page.fill('[name="cardNumber"]', '4242424242424242'); 58 await page.fill('[name="expiry"]', '12/25'); 59 await page.fill('[name="cvv"]', '123'); 60 61 // Complete purchase 62 await page.click('button:has-text("Pay")'); 63 64 // Verify success 65 await expect(page.locator('.success-message')) 66 .toContainText('Order confirmed'); 67 }); 68});

Test Data Management#

1// Factory functions for test data 2import { faker } from '@faker-js/faker'; 3 4export function createUser(overrides: Partial<User> = {}): User { 5 return { 6 id: faker.string.uuid(), 7 email: faker.internet.email(), 8 name: faker.person.fullName(), 9 createdAt: faker.date.past(), 10 ...overrides, 11 }; 12} 13 14export function createOrder(overrides: Partial<Order> = {}): Order { 15 return { 16 id: faker.string.uuid(), 17 userId: faker.string.uuid(), 18 items: [ 19 { 20 productId: faker.string.uuid(), 21 quantity: faker.number.int({ min: 1, max: 5 }), 22 price: parseFloat(faker.commerce.price()), 23 }, 24 ], 25 total: parseFloat(faker.commerce.price({ min: 10, max: 500 })), 26 status: 'pending', 27 createdAt: faker.date.recent(), 28 ...overrides, 29 }; 30} 31 32// Usage in tests 33it('processes order', async () => { 34 const user = createUser(); 35 const order = createOrder({ userId: user.id, status: 'pending' }); 36 37 const result = await processOrder(order); 38 39 expect(result.status).toBe('completed'); 40});

When to Use Each#

Unit Tests: ✓ Business logic ✓ Utility functions ✓ Data transformations ✓ Edge cases ✓ Fast feedback Integration Tests: ✓ Database operations ✓ API endpoints ✓ Service interactions ✓ External API integrations ✓ Error handling across layers E2E Tests: ✓ Critical user journeys ✓ Multi-page flows ✓ Authentication flows ✓ Payment processes ✓ Cross-browser testing

Test Organization#

src/ ├── components/ │ ├── Button.tsx │ └── Button.test.tsx # Unit tests ├── services/ │ ├── UserService.ts │ └── UserService.test.ts ├── api/ │ ├── users.ts │ └── __tests__/ │ └── users.integration.test.ts tests/ ├── e2e/ │ ├── auth.spec.ts │ └── checkout.spec.ts ├── fixtures/ │ └── users.json └── utils/ └── test-helpers.ts

Best Practices#

General: ✓ Test behavior, not implementation ✓ Keep tests independent ✓ Use descriptive names ✓ Follow AAA pattern (Arrange, Act, Assert) Unit Tests: ✓ Fast execution ✓ No external dependencies ✓ Mock at boundaries ✓ High coverage of logic Integration Tests: ✓ Use test database ✓ Clean up between tests ✓ Test real interactions ✓ Cover error paths E2E Tests: ✓ Focus on critical paths ✓ Use stable selectors ✓ Handle async properly ✓ Run in CI/CD

Conclusion#

A balanced testing strategy uses many unit tests for fast feedback, integration tests for component interactions, and targeted E2E tests for critical flows. Each level catches different bugs—invest in the right mix for your application's needs.

Share this article

Help spread the word about Bootspring