Integration & API Testing
Learn to test how multiple parts of your application work together
Pixl presents
Every unit test passes and the app is still broken? Classic. Integration tests check the pieces actually work together.Every unit test passes and the app is still broken? Classic. Integration tests check the pieces actually work together.

Unit tests check the pieces one at a time. Cool. So what happens when those pieces have to actually work together? That's the job of integration tests. They check that your components, APIs and databases play nice as a team.
Think about an orchestra. Every musician might sound PERFECT practicing alone in their bedroom. The real test is whether they sound any good together on stage, and integration testing is that test for your code.
Remember those 49 form notifications from the first lesson? The form submitted fine and the email system swore it delivered. Each piece looked healthy on its own, and the whole thing still failed where the pieces met. That's the flavor of problem integration tests exist for (even if nobody's test was ever gonna catch a surprise Microsoft update).
What Makes a Test an Integration Test?#
An integration test checks how two or more parts of your system get along. Stuff like this.
- A component that fetches data from an API
- An API endpoint that reads from a database
- A form that submits data and updates the UI
- A service that calls another service
import { describe, it, expect } from 'vitest';
// Your dev server needs to be running for this one
const BASE_URL = 'http://localhost:3000';
// Integration test: API endpoint + database
describe('POST /api/users', () => {
it('should create a user and return it', async () => {
const response = await fetch(`${BASE_URL}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice', email: '[email protected]' })
});
const user = await response.json();
expect(response.status).toBe(201);
expect(user.name).toBe('Alice');
expect(user.id).toBeDefined();
});
});Testing API Endpoints#
For Next.js or Express APIs, you can test endpoints directly with tools like supertest or plain old fetch calls. One gotcha with fetch in a test file. Your tests run in Node, not a browser, so there's no page address to fill in the blanks. Give it the full URL (like http://localhost:3000/api/tasks) or it throws a "Failed to parse URL" error before your API ever gets a say.
import { describe, it, expect } from 'vitest';
const BASE_URL = 'http://localhost:3000';
describe('Tasks API', () => {
let taskId: string;
it('should create a new task', async () => {
const response = await fetch(`${BASE_URL}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Buy groceries', done: false })
});
const task = await response.json();
taskId = task.id;
expect(response.status).toBe(201);
expect(task.title).toBe('Buy groceries');
expect(task.done).toBe(false);
});
it('should retrieve the created task', async () => {
const response = await fetch(`${BASE_URL}/api/tasks/${taskId}`);
const task = await response.json();
expect(response.status).toBe(200);
expect(task.title).toBe('Buy groceries');
});
it('should update the task', async () => {
const response = await fetch(`${BASE_URL}/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: true })
});
const task = await response.json();
expect(task.done).toBe(true);
});
it('should delete the task', async () => {
const response = await fetch(`${BASE_URL}/api/tasks/${taskId}`, {
method: 'DELETE'
});
expect(response.status).toBe(204);
});
});Test Fixtures and Setup#
Integration tests usually need some sample data to work with. Test fixtures give every test the same starting data, every time.
// fixtures/users.ts
export const testUsers = [
{ name: 'Alice', email: '[email protected]', role: 'admin' },
{ name: 'Bob', email: '[email protected]', role: 'user' },
{ name: 'Charlie', email: '[email protected]', role: 'user' }
];
// In your test file
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { testUsers } from './fixtures/users';
const BASE_URL = 'http://localhost:3000';
describe('User Management', () => {
beforeEach(async () => {
// Seed the database with test data
await db.user.createMany({ data: testUsers });
});
afterEach(async () => {
// Clean up after each test
await db.user.deleteMany({});
});
it('should list all users', async () => {
const response = await fetch(`${BASE_URL}/api/users`);
const users = await response.json();
expect(users).toHaveLength(3);
});
});Testing Database Operations#

When you're testing code that talks to a database, you've got a few options.
Step 1. Use a Test Database#
Make a separate database just for testing. Your development data stays safe, and your tests can go nuts without breaking anything.
Step 2. Use Transactions with Rollback#
Wrap each test in a database transaction, then roll it back when the test is done. The data never actually sticks around.
Step 3. Reset Between Tests#
Wipe all the tables before or after each test. Simple, but it can get slow on big databases.
describe('UserRepository', () => {
// Use a test database
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.$disconnect();
});
afterEach(async () => {
// Clean slate for each test
await prisma.user.deleteMany();
});
it('should find user by email', async () => {
// Create a user
await prisma.user.create({
data: { name: 'Alice', email: '[email protected]' }
});
// Test the repository method
const user = await userRepository.findByEmail('[email protected]');
expect(user).not.toBeNull();
expect(user?.name).toBe('Alice');
});
});Testing Error Scenarios#
Integration tests should also make sure errors get handled gracefully.
const BASE_URL = 'http://localhost:3000';
describe('Error Handling', () => {
it('should return 404 for non-existent resource', async () => {
const response = await fetch(`${BASE_URL}/api/users/non-existent-id`);
expect(response.status).toBe(404);
const error = await response.json();
expect(error.message).toContain('not found');
});
it('should return 400 for invalid input', async () => {
const response = await fetch(`${BASE_URL}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }) // Missing required email
});
expect(response.status).toBe(400);
});
it('should return 401 for unauthorized access', async () => {
const response = await fetch(`${BASE_URL}/api/admin/users`, {
// No auth token provided
});
expect(response.status).toBe(401);
});
});TL;DR#
- Integration tests check that multiple parts work together the right way
- Test your API endpoints with realistic requests and responses
- Use fixtures so every test starts from the same data
- Always clean up test data so tests don't trip over each other
- Test the error cases too, like 404s, 400s, 401s and server errors
- Use a separate test database to keep your development data safe
What's Next?#
You can test single functions and you can test how the pieces fit together. Next up is testing React components, where clicks, typing and rendering bring a few new wrinkles to the party...
This lesson ends with a short activity.