Integration Testing Pattern

Test complete user flows and service interactions with database integration, API testing, and webhook verification.

Overview#

Integration tests verify that different parts of your application work together correctly. They test real database interactions, API endpoints, and service integrations to ensure the system functions as a whole.

When to use:

  • Testing complete user flows (registration, checkout)
  • Verifying database operations work correctly
  • Testing API endpoints with real data
  • Validating webhook handling

Key features:

  • Tests real database interactions
  • Verifies service integrations
  • Tests complete user journeys
  • Catches integration bugs

Code Example#

Database Integration Tests#

1// __tests__/integration/setup.ts 2import { PrismaClient } from '@prisma/client' 3import { execSync } from 'child_process' 4 5const prisma = new PrismaClient() 6 7export async function setupTestDatabase() { 8 // Reset database before tests 9 await prisma.$executeRawUnsafe(` 10 TRUNCATE TABLE "User", "Post", "Comment" CASCADE; 11 `) 12} 13 14export async function teardownTestDatabase() { 15 await prisma.$disconnect() 16} 17 18// Or use test containers 19import { PostgreSqlContainer } from '@testcontainers/postgresql' 20 21let container: PostgreSqlContainer 22 23export async function startTestContainer() { 24 container = await new PostgreSqlContainer() 25 .withDatabase('testdb') 26 .withUsername('test') 27 .withPassword('test') 28 .start() 29 30 process.env.DATABASE_URL = container.getConnectionUri() 31 32 // Run migrations 33 execSync('npx prisma migrate deploy', { 34 env: { ...process.env, DATABASE_URL: container.getConnectionUri() } 35 }) 36} 37 38export async function stopTestContainer() { 39 await container?.stop() 40}

User Flow Integration Test#

1// __tests__/integration/user-flow.test.ts 2import { prisma } from '@/lib/db' 3import { POST as createUser } from '@/app/api/users/route' 4import { POST as login } from '@/app/api/auth/login/route' 5import { POST as createPost } from '@/app/api/posts/route' 6 7describe('User Flow Integration', () => { 8 beforeEach(async () => { 9 await prisma.post.deleteMany() 10 await prisma.user.deleteMany() 11 }) 12 13 afterAll(async () => { 14 await prisma.$disconnect() 15 }) 16 17 it('completes full user registration and posting flow', async () => { 18 // Step 1: Register user 19 const registerRequest = new Request('http://localhost/api/users', { 20 method: 'POST', 21 headers: { 'Content-Type': 'application/json' }, 22 body: JSON.stringify({ 23 email: 'test@example.com', 24 password: 'password123', 25 name: 'Test User' 26 }) 27 }) 28 29 const registerResponse = await createUser(registerRequest) 30 expect(registerResponse.status).toBe(201) 31 32 const user = await registerResponse.json() 33 expect(user.email).toBe('test@example.com') 34 35 // Step 2: Login 36 const loginRequest = new Request('http://localhost/api/auth/login', { 37 method: 'POST', 38 headers: { 'Content-Type': 'application/json' }, 39 body: JSON.stringify({ 40 email: 'test@example.com', 41 password: 'password123' 42 }) 43 }) 44 45 const loginResponse = await login(loginRequest) 46 expect(loginResponse.status).toBe(200) 47 48 const { token } = await loginResponse.json() 49 expect(token).toBeDefined() 50 51 // Step 3: Create post 52 const postRequest = new Request('http://localhost/api/posts', { 53 method: 'POST', 54 headers: { 55 'Content-Type': 'application/json', 56 Authorization: `Bearer ${token}` 57 }, 58 body: JSON.stringify({ 59 title: 'My First Post', 60 content: 'Hello world!' 61 }) 62 }) 63 64 const postResponse = await createPost(postRequest) 65 expect(postResponse.status).toBe(201) 66 67 const post = await postResponse.json() 68 expect(post.title).toBe('My First Post') 69 expect(post.authorId).toBe(user.id) 70 71 // Verify in database 72 const dbPost = await prisma.post.findUnique({ 73 where: { id: post.id }, 74 include: { author: true } 75 }) 76 77 expect(dbPost?.author.email).toBe('test@example.com') 78 }) 79})

API Integration Tests with Supertest#

1// __tests__/integration/api.test.ts 2import request from 'supertest' 3import { createServer } from 'http' 4import { parse } from 'url' 5import next from 'next' 6 7const dev = process.env.NODE_ENV !== 'production' 8const app = next({ dev }) 9const handle = app.getRequestHandler() 10 11let server: ReturnType<typeof createServer> 12 13beforeAll(async () => { 14 await app.prepare() 15 server = createServer((req, res) => { 16 const parsedUrl = parse(req.url!, true) 17 handle(req, res, parsedUrl) 18 }) 19 await new Promise<void>(resolve => server.listen(0, resolve)) 20}) 21 22afterAll(async () => { 23 await new Promise<void>(resolve => server.close(() => resolve())) 24 await app.close() 25}) 26 27describe('API Integration Tests', () => { 28 it('GET /api/health returns healthy status', async () => { 29 const res = await request(server) 30 .get('/api/health') 31 .expect(200) 32 33 expect(res.body.status).toBe('healthy') 34 }) 35 36 it('POST /api/users creates a new user', async () => { 37 const res = await request(server) 38 .post('/api/users') 39 .send({ 40 email: 'new@example.com', 41 name: 'New User', 42 password: 'password123' 43 }) 44 .expect(201) 45 46 expect(res.body.email).toBe('new@example.com') 47 expect(res.body.password).toBeUndefined() 48 }) 49 50 it('GET /api/users requires authentication', async () => { 51 await request(server) 52 .get('/api/users') 53 .expect(401) 54 }) 55})

Service Integration Tests#

1// __tests__/integration/services.test.ts 2import { PaymentService } from '@/services/payment' 3import { EmailService } from '@/services/email' 4import { prisma } from '@/lib/db' 5 6// Mock external services 7vi.mock('@/services/email', () => ({ 8 EmailService: { 9 sendWelcomeEmail: vi.fn().mockResolvedValue(true), 10 sendReceiptEmail: vi.fn().mockResolvedValue(true) 11 } 12})) 13 14describe('Payment Service Integration', () => { 15 beforeEach(async () => { 16 await prisma.payment.deleteMany() 17 await prisma.subscription.deleteMany() 18 vi.clearAllMocks() 19 }) 20 21 it('processes subscription and sends confirmation', async () => { 22 const user = await prisma.user.create({ 23 data: { 24 email: 'subscriber@example.com', 25 name: 'Subscriber' 26 } 27 }) 28 29 // Process subscription 30 const subscription = await PaymentService.createSubscription({ 31 userId: user.id, 32 planId: 'pro', 33 paymentMethodId: 'pm_test_123' 34 }) 35 36 expect(subscription.status).toBe('active') 37 expect(subscription.planId).toBe('pro') 38 39 // Verify database state 40 const dbSubscription = await prisma.subscription.findUnique({ 41 where: { id: subscription.id } 42 }) 43 expect(dbSubscription?.userId).toBe(user.id) 44 45 // Verify email was sent 46 expect(EmailService.sendReceiptEmail).toHaveBeenCalledWith( 47 expect.objectContaining({ 48 email: 'subscriber@example.com', 49 subscriptionId: subscription.id 50 }) 51 ) 52 }) 53 54 it('handles failed payment gracefully', async () => { 55 const user = await prisma.user.create({ 56 data: { 57 email: 'fail@example.com', 58 name: 'Failed User' 59 } 60 }) 61 62 await expect( 63 PaymentService.createSubscription({ 64 userId: user.id, 65 planId: 'pro', 66 paymentMethodId: 'pm_card_declined' 67 }) 68 ).rejects.toThrow('Payment declined') 69 70 // Verify no subscription was created 71 const subscriptions = await prisma.subscription.findMany({ 72 where: { userId: user.id } 73 }) 74 expect(subscriptions).toHaveLength(0) 75 }) 76})

Webhook Integration Tests#

1// __tests__/integration/webhooks.test.ts 2import crypto from 'crypto' 3import { POST } from '@/app/api/webhooks/stripe/route' 4import { prisma } from '@/lib/db' 5 6function createStripeSignature(payload: string, secret: string): string { 7 const timestamp = Math.floor(Date.now() / 1000) 8 const signedPayload = `${timestamp}.${payload}` 9 const signature = crypto 10 .createHmac('sha256', secret) 11 .update(signedPayload) 12 .digest('hex') 13 14 return `t=${timestamp},v1=${signature}` 15} 16 17describe('Stripe Webhook Integration', () => { 18 const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! 19 20 beforeEach(async () => { 21 await prisma.payment.deleteMany() 22 }) 23 24 it('handles payment_intent.succeeded event', async () => { 25 const user = await prisma.user.create({ 26 data: { 27 email: 'payer@example.com', 28 name: 'Payer', 29 stripeCustomerId: 'cus_test_123' 30 } 31 }) 32 33 const event = { 34 id: 'evt_test_123', 35 type: 'payment_intent.succeeded', 36 data: { 37 object: { 38 id: 'pi_test_123', 39 amount: 2000, 40 currency: 'usd', 41 customer: 'cus_test_123', 42 metadata: { userId: user.id } 43 } 44 } 45 } 46 47 const payload = JSON.stringify(event) 48 const signature = createStripeSignature(payload, webhookSecret) 49 50 const request = new Request('http://localhost/api/webhooks/stripe', { 51 method: 'POST', 52 headers: { 53 'Content-Type': 'application/json', 54 'stripe-signature': signature 55 }, 56 body: payload 57 }) 58 59 const response = await POST(request) 60 expect(response.status).toBe(200) 61 62 // Verify payment was recorded 63 const payment = await prisma.payment.findFirst({ 64 where: { stripePaymentIntentId: 'pi_test_123' } 65 }) 66 67 expect(payment).toBeDefined() 68 expect(payment?.amount).toBe(2000) 69 expect(payment?.status).toBe('succeeded') 70 }) 71 72 it('rejects invalid signatures', async () => { 73 const event = { 74 id: 'evt_test_456', 75 type: 'payment_intent.succeeded', 76 data: { object: {} } 77 } 78 79 const request = new Request('http://localhost/api/webhooks/stripe', { 80 method: 'POST', 81 headers: { 82 'Content-Type': 'application/json', 83 'stripe-signature': 'invalid_signature' 84 }, 85 body: JSON.stringify(event) 86 }) 87 88 const response = await POST(request) 89 expect(response.status).toBe(400) 90 }) 91})

Database Transaction Tests#

1// __tests__/integration/transactions.test.ts 2import { prisma } from '@/lib/db' 3import { transferFunds } from '@/services/banking' 4 5describe('Database Transactions', () => { 6 beforeEach(async () => { 7 await prisma.account.deleteMany() 8 }) 9 10 it('transfers funds atomically', async () => { 11 const [sender, receiver] = await Promise.all([ 12 prisma.account.create({ data: { userId: 'user1', balance: 100 } }), 13 prisma.account.create({ data: { userId: 'user2', balance: 0 } }) 14 ]) 15 16 await transferFunds(sender.id, receiver.id, 50) 17 18 const [updatedSender, updatedReceiver] = await Promise.all([ 19 prisma.account.findUnique({ where: { id: sender.id } }), 20 prisma.account.findUnique({ where: { id: receiver.id } }) 21 ]) 22 23 expect(updatedSender?.balance).toBe(50) 24 expect(updatedReceiver?.balance).toBe(50) 25 }) 26 27 it('rolls back on insufficient funds', async () => { 28 const [sender, receiver] = await Promise.all([ 29 prisma.account.create({ data: { userId: 'user1', balance: 30 } }), 30 prisma.account.create({ data: { userId: 'user2', balance: 0 } }) 31 ]) 32 33 await expect(transferFunds(sender.id, receiver.id, 50)) 34 .rejects.toThrow('Insufficient funds') 35 36 // Verify no changes were made 37 const [unchangedSender, unchangedReceiver] = await Promise.all([ 38 prisma.account.findUnique({ where: { id: sender.id } }), 39 prisma.account.findUnique({ where: { id: receiver.id } }) 40 ]) 41 42 expect(unchangedSender?.balance).toBe(30) 43 expect(unchangedReceiver?.balance).toBe(0) 44 }) 45})

Usage Instructions#

  1. Set up test database: Use separate database or test containers
  2. Clean between tests: Reset database state in beforeEach
  3. Test real interactions: Use actual database and API calls
  4. Mock external services: Mock third-party APIs but test your integration code
  5. Verify side effects: Check database state, emails sent, etc.

Best Practices#

  1. Isolate tests - Each test should start with clean state
  2. Use transactions - Wrap tests in transactions for easy rollback
  3. Test happy and error paths - Verify both success and failure scenarios
  4. Mock external services - Don't call real third-party APIs in tests
  5. Use realistic data - Test with data similar to production
  6. Test complete flows - Verify entire user journeys
  7. Run in CI - Ensure integration tests run in your pipeline