Skip to main content

Chapter 33: Integration Testing

Integration tests verify that multiple parts of your system work together correctly — API endpoints with database operations, services with their dependencies, and multi-step flows.

Testing API Endpoints

// Test an API endpoint that creates a project
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

beforeAll(async () => {
// Seed test data
await prisma.user.create({
data: { id: "test-user", email: "[email protected]", name: "Test", passwordHash: "hash" },
});
await prisma.organization.create({
data: { id: "test-org", name: "Test Org", slug: "test-org" },
});
await prisma.organizationMember.create({
data: { userId: "test-user", organizationId: "test-org", role: "ADMIN" },
});
});

afterAll(async () => {
// Clean up in reverse order of creation
await prisma.organizationMember.deleteMany({});
await prisma.project.deleteMany({});
await prisma.organization.deleteMany({});
await prisma.user.deleteMany({});
await prisma.$disconnect();
});

describe("POST /api/projects", () => {
it("creates a project with valid input", async () => {
const response = await fetch("http://localhost:3000/api/projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${testToken}`,
},
body: JSON.stringify({
name: "Integration Test Project",
organizationId: "test-org",
}),
});

expect(response.status).toBe(201);
const data = await response.json();
expect(data.data.name).toBe("Integration Test Project");
expect(data.data.id).toBeDefined();
});

it("returns 400 for missing name", async () => {
const response = await fetch("http://localhost:3000/api/projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${testToken}`,
},
body: JSON.stringify({
organizationId: "test-org",
}),
});

expect(response.status).toBe(400);
});

it("returns 401 without auth token", async () => {
const response = await fetch("http://localhost:3000/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test", organizationId: "test-org" }),
});

expect(response.status).toBe(401);
});
});

Testing Effect Services Integration

Test that services compose correctly with real-ish dependencies:

// Test the full create-project use case with in-memory implementations
import { describe, it, expect } from "vitest";
import { Effect, Layer } from "effect";
import { createProject } from "@/features/projects/application/use-cases/create-project";

// In-memory implementations that behave like real ones
const projects = new Map<string, Project>();

const InMemoryProjectRepo = Layer.succeed(ProjectRepository, {
findByName: (name, orgId) =>
Effect.succeed(
Array.from(projects.values()).find(
(p) => p.name === name && p.organizationId === orgId
) ?? null
),
create: (data) =>
Effect.sync(() => {
const project = {
id: `proj-${Date.now()}`,
...data,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
} as Project;
projects.set(project.id, project);
return project;
}),
findById: (id) => {
const project = projects.get(id);
return project
? Effect.succeed(project)
: Effect.fail(new ProjectNotFoundError({ projectId: id }));
},
});

const auditLog: Array<{ action: string; userId: string }> = [];
const InMemoryAudit = Layer.succeed(AuditService, {
record: (entry) =>
Effect.sync(() => { auditLog.push(entry); }),
});

const IntegrationLayer = Layer.mergeAll(
InMemoryProjectRepo,
TestAuthz, // From unit tests
InMemoryAudit,
);

describe("createProject integration", () => {
beforeEach(() => {
projects.clear();
auditLog.length = 0;
});

it("creates project and records audit log", async () => {
const project = await Effect.runPromise(
createProject(
{ name: "Test Project", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(IntegrationLayer))
);

expect(project.name).toBe("Test Project");
expect(projects.size).toBe(1);
expect(auditLog).toHaveLength(1);
expect(auditLog[0].action).toBe("project:created");
});

it("prevents duplicate project names", async () => {
// Create first project
await Effect.runPromise(
createProject(
{ name: "Unique Name", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(IntegrationLayer))
);

// Try to create duplicate
const exit = await Effect.runPromiseExit(
createProject(
{ name: "Unique Name", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(IntegrationLayer))
);

expect(Exit.isFailure(exit)).toBe(true);
});
});

Test Database Setup

For tests that need a real database, use a test-specific database:

# .env.test
DATABASE_URL="postgresql://user:password@localhost:5432/taskforge_test?schema=public"
// test/helpers/db.ts
import { PrismaClient } from "@prisma/client";
import { execSync } from "node:child_process";

export function setupTestDatabase() {
// Reset database before test suite
execSync("npx prisma migrate reset --force --skip-seed", {
env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL },
});
}

export function createTestPrisma() {
return new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
});
}

Test Data Factories

// test/factories/project-factory.ts
let counter = 0;

export function createProjectData(overrides: Partial<Project> = {}): Project {
counter++;
return {
id: `proj-${counter}`,
name: `Test Project ${counter}`,
description: null,
status: "active",
priority: "medium",
organizationId: "org-1",
createdById: "user-1",
dueDate: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}

Summary

  • API endpoint tests verify HTTP contract and error responses
  • Service integration tests with in-memory implementations verify composition
  • Test database with reset between suites for database-level tests
  • Test factories generate realistic test data
  • Audit trail verification ensures side effects occur correctly