Skip to content

Integration Testing in NestJS

Testing NestJS applications end-to-end with real dependencies

Related: Unit Testing in NestJS | Backend Integration Testing

Overview

Integration tests verify that all parts of your NestJS application work together correctly. They test complete user journeys from HTTP requests through to database persistence, using real implementations rather than mocks.

Testing Philosophy

What to Test

Integration tests should cover:

  • Complete API endpoints - From HTTP request to response
  • Repository implementations - Against real databases
  • Module interactions - Verify modules work together
  • Error scenarios - 4xx and 5xx responses
  • Business workflows - Multi-step user journeys

Testing Principles

  1. Use real dependencies - Real database, real modules
  2. Test through public APIs - HTTP endpoints, not internal methods
  3. Setup via API calls - Not direct database manipulation
  4. Clean state between tests - Isolated, repeatable tests
  5. Test user journeys - Not implementation details

Test Environment Setup

Test Database Configuration

typescript
// test/setup/test-database.ts
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
import { randomUUID } from 'crypto';

export class TestDatabase {
  private prisma: PrismaClient;
  private databaseUrl: string;
  private schema: string;

  constructor() {
    // Create unique schema for each test run
    this.schema = `test_${randomUUID().replace(/-/g, '_')}`;
    this.databaseUrl = this.buildDatabaseUrl();
    this.prisma = new PrismaClient({
      datasources: {
        db: { url: this.databaseUrl },
      },
    });
  }

  async setup(): Promise<void> {
    // Create schema
    await this.prisma.$executeRawUnsafe(
      `CREATE SCHEMA IF NOT EXISTS "${this.schema}"`,
    );

    // Run migrations
    execSync('npx prisma migrate deploy', {
      env: {
        ...process.env,
        DATABASE_URL: this.databaseUrl,
      },
    });
  }

  async teardown(): Promise<void> {
    await this.prisma.$disconnect();
    
    // Drop test schema
    const adminPrisma = new PrismaClient();
    await adminPrisma.$executeRawUnsafe(
      `DROP SCHEMA IF EXISTS "${this.schema}" CASCADE`,
    );
    await adminPrisma.$disconnect();
  }

  async clear(): Promise<void> {
    // Clear all tables but keep schema
    const tables = await this.prisma.$queryRaw<Array<{ tablename: string }>>`
      SELECT tablename FROM pg_tables 
      WHERE schemaname = ${this.schema}
    `;

    for (const { tablename } of tables) {
      await this.prisma.$executeRawUnsafe(
        `TRUNCATE TABLE "${this.schema}"."${tablename}" CASCADE`,
      );
    }
  }

  private buildDatabaseUrl(): string {
    const baseUrl = process.env.DATABASE_URL || 'postgresql://localhost/test';
    const url = new URL(baseUrl);
    url.searchParams.set('schema', this.schema);
    return url.toString();
  }
}

Test Application Factory

typescript
// test/setup/test-app.factory.ts
export class TestAppFactory {
  static async create(): Promise<INestApplication> {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(ConfigService)
      .useValue(new TestConfigService())
      .compile();

    const app = moduleFixture.createNestApplication();
    
    // Apply same configuration as production
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
    }));
    
    app.useGlobalFilters(new HttpExceptionFilter());
    app.useGlobalInterceptors(new LoggingInterceptor());

    await app.init();
    return app;
  }
}

Testing REST Endpoints

Basic Endpoint Testing

typescript
// test/integration/users/create-user.integration.spec.ts
describe('POST /users', () => {
  let app: INestApplication;
  let database: TestDatabase;

  beforeAll(async () => {
    database = new TestDatabase();
    await database.setup();
    app = await TestAppFactory.create();
  });

  afterAll(async () => {
    await app.close();
    await database.teardown();
  });

  beforeEach(async () => {
    await database.clear();
  });

  describe('when creating a new user', () => {
    it('should create user successfully', async () => {
      const createUserDto = {
        email: 'john@example.com',
        name: 'John Doe',
        password: 'SecurePass123!',
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(201);

      expect(response.body).toMatchObject({
        id: expect.any(String),
        email: 'john@example.com',
        name: 'John Doe',
      });
      expect(response.body.password).toBeUndefined();
    });

    it('should return 400 for invalid email', async () => {
      const createUserDto = {
        email: 'invalid-email',
        name: 'John Doe',
        password: 'SecurePass123!',
      };

      const response = await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(400);

      expect(response.body.message).toContain('email must be an email');
    });

    it('should return 409 for duplicate email', async () => {
      const createUserDto = {
        email: 'john@example.com',
        name: 'John Doe',
        password: 'SecurePass123!',
      };

      // Create first user
      await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(201);

      // Attempt to create duplicate
      const response = await request(app.getHttpServer())
        .post('/users')
        .send(createUserDto)
        .expect(409);

      expect(response.body.message).toBe('User with this email already exists');
    });
  });
});

Testing with Authentication

typescript
describe('Protected endpoints', () => {
  let app: INestApplication;
  let authToken: string;
  let userId: string;

  beforeAll(async () => {
    app = await TestAppFactory.create();
    
    // Create user and get auth token via API
    const { body: user } = await request(app.getHttpServer())
      .post('/users')
      .send({
        email: 'test@example.com',
        name: 'Test User',
        password: 'TestPass123!',
      });
    
    userId = user.id;

    const { body: auth } = await request(app.getHttpServer())
      .post('/auth/login')
      .send({
        email: 'test@example.com',
        password: 'TestPass123!',
      });
    
    authToken = auth.accessToken;
  });

  describe('GET /users/profile', () => {
    it('should return user profile when authenticated', async () => {
      const response = await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', `Bearer ${authToken}`)
        .expect(200);

      expect(response.body).toMatchObject({
        id: userId,
        email: 'test@example.com',
        name: 'Test User',
      });
    });

    it('should return 401 without authentication', async () => {
      await request(app.getHttpServer())
        .get('/users/profile')
        .expect(401);
    });

    it('should return 401 with invalid token', async () => {
      await request(app.getHttpServer())
        .get('/users/profile')
        .set('Authorization', 'Bearer invalid-token')
        .expect(401);
    });
  });
});

Testing Business Workflows

Multi-Step User Journeys

typescript
describe('Order workflow', () => {
  let app: INestApplication;
  let customerToken: string;
  let customerId: string;

  beforeAll(async () => {
    app = await TestAppFactory.create();
    
    // Setup: Create customer
    const customer = await createCustomer(app, {
      email: 'customer@example.com',
      name: 'Test Customer',
    });
    
    customerId = customer.id;
    customerToken = customer.token;

    // Setup: Create products
    await createProduct(app, {
      id: 'product-1',
      name: 'Widget',
      price: 10.00,
      stock: 100,
    });
    
    await createProduct(app, {
      id: 'product-2',
      name: 'Gadget',
      price: 25.00,
      stock: 50,
    });
  });

  it('should complete full order workflow', async () => {
    // Step 1: Add items to cart
    await request(app.getHttpServer())
      .post('/cart/items')
      .set('Authorization', `Bearer ${customerToken}`)
      .send({
        productId: 'product-1',
        quantity: 2,
      })
      .expect(201);

    await request(app.getHttpServer())
      .post('/cart/items')
      .set('Authorization', `Bearer ${customerToken}`)
      .send({
        productId: 'product-2',
        quantity: 1,
      })
      .expect(201);

    // Step 2: View cart
    const { body: cart } = await request(app.getHttpServer())
      .get('/cart')
      .set('Authorization', `Bearer ${customerToken}`)
      .expect(200);

    expect(cart.items).toHaveLength(2);
    expect(cart.total).toBe(45.00);

    // Step 3: Create order from cart
    const { body: order } = await request(app.getHttpServer())
      .post('/orders')
      .set('Authorization', `Bearer ${customerToken}`)
      .send({
        shippingAddress: {
          street: '123 Main St',
          city: 'Anytown',
          zipCode: '12345',
        },
      })
      .expect(201);

    expect(order.status).toBe('PENDING');
    expect(order.total).toBe(45.00);

    // Step 4: Process payment
    const { body: payment } = await request(app.getHttpServer())
      .post(`/orders/${order.id}/payment`)
      .set('Authorization', `Bearer ${customerToken}`)
      .send({
        paymentMethod: 'card',
        token: 'test-stripe-token',
      })
      .expect(200);

    expect(payment.status).toBe('SUCCESS');

    // Step 5: Verify order status updated
    const { body: updatedOrder } = await request(app.getHttpServer())
      .get(`/orders/${order.id}`)
      .set('Authorization', `Bearer ${customerToken}`)
      .expect(200);

    expect(updatedOrder.status).toBe('PAID');

    // Step 6: Verify inventory was reduced
    const { body: product1 } = await request(app.getHttpServer())
      .get('/products/product-1')
      .expect(200);

    expect(product1.stock).toBe(98); // 100 - 2

    // Step 7: Verify cart was cleared
    const { body: emptyCart } = await request(app.getHttpServer())
      .get('/cart')
      .set('Authorization', `Bearer ${customerToken}`)
      .expect(200);

    expect(emptyCart.items).toHaveLength(0);
  });
});

Testing Repositories

Repository Integration Tests

typescript
describe('UserRepository Integration', () => {
  let repository: UserRepository;
  let prisma: PrismaService;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [PrismaModule],
      providers: [UserRepository],
    }).compile();

    repository = module.get<UserRepository>(UserRepository);
    prisma = module.get<PrismaService>(PrismaService);
  });

  beforeEach(async () => {
    await prisma.user.deleteMany();
  });

  describe('save', () => {
    it('should persist user with all properties', async () => {
      const user = User.create(
        new Email('test@example.com'),
        'Test User',
        'password123',
      );

      await repository.save(user);

      const dbRecord = await prisma.user.findUnique({
        where: { id: user.getId().toString() },
      });

      expect(dbRecord).toBeDefined();
      expect(dbRecord?.email).toBe('test@example.com');
      expect(dbRecord?.name).toBe('Test User');
      expect(dbRecord?.status).toBe('ACTIVE');
    });
  });

  describe('findByEmail', () => {
    it('should find user by email', async () => {
      const user = User.create(
        new Email('test@example.com'),
        'Test User',
        'password123',
      );

      await repository.save(user);

      const found = await repository.findByEmail(
        new Email('test@example.com')
      );

      expect(found).toBeDefined();
      expect(found?.getId()).toEqual(user.getId());
    });

    it('should return null for non-existent email', async () => {
      const found = await repository.findByEmail(
        new Email('nonexistent@example.com')
      );

      expect(found).toBeNull();
    });
  });

  describe('transaction support', () => {
    it('should rollback on error', async () => {
      const user1 = User.create(
        new Email('user1@example.com'),
        'User 1',
        'password123',
      );

      try {
        await prisma.$transaction(async (tx) => {
          await repository.save(user1, tx);
          
          // This should fail due to duplicate email
          const user2 = User.create(
            new Email('user1@example.com'),
            'User 2',
            'password123',
          );
          await repository.save(user2, tx);
        });
      } catch (error) {
        // Transaction should have rolled back
      }

      const count = await prisma.user.count();
      expect(count).toBe(0);
    });
  });
});

Test Data Builders

Creating Test Data

typescript
// test/builders/user.builder.ts
export class UserBuilder {
  private email = 'test@example.com';
  private name = 'Test User';
  private password = 'TestPass123!';
  private status = UserStatus.ACTIVE;

  withEmail(email: string): this {
    this.email = email;
    return this;
  }

  withName(name: string): this {
    this.name = name;
    return this;
  }

  withStatus(status: UserStatus): this {
    this.status = status;
    return this;
  }

  build(): User {
    return User.create(
      new Email(this.email),
      this.name,
      this.password,
    );
  }

  async buildAndSave(repository: UserRepositoryInterface): Promise<User> {
    const user = this.build();
    await repository.save(user);
    return user;
  }
}

// Usage in tests
const user = await new UserBuilder()
  .withEmail('john@example.com')
  .withName('John Doe')
  .buildAndSave(repository);

API Test Helpers

typescript
// test/helpers/api.helpers.ts
export class ApiTestHelper {
  constructor(private app: INestApplication) {}

  async createUser(data: Partial<CreateUserDto> = {}): Promise<{
    user: UserDto;
    token: string;
  }> {
    const createDto = {
      email: data.email || `test${Date.now()}@example.com`,
      name: data.name || 'Test User',
      password: data.password || 'TestPass123!',
    };

    const { body: user } = await request(this.app.getHttpServer())
      .post('/users')
      .send(createDto)
      .expect(201);

    const { body: auth } = await request(this.app.getHttpServer())
      .post('/auth/login')
      .send({
        email: createDto.email,
        password: createDto.password,
      })
      .expect(200);

    return { user, token: auth.accessToken };
  }

  async createOrder(
    token: string,
    items: OrderItemDto[],
  ): Promise<OrderDto> {
    const { body } = await request(this.app.getHttpServer())
      .post('/orders')
      .set('Authorization', `Bearer ${token}`)
      .send({ items })
      .expect(201);

    return body;
  }

  async getAuthenticatedRequest(token: string) {
    return request(this.app.getHttpServer())
      .set('Authorization', `Bearer ${token}`);
  }
}

Performance Testing

Load Testing Endpoints

typescript
describe('Performance tests', () => {
  let app: INestApplication;

  beforeAll(async () => {
    app = await TestAppFactory.create();
  });

  it('should handle concurrent requests', async () => {
    const promises = Array.from({ length: 100 }, (_, i) =>
      request(app.getHttpServer())
        .post('/users')
        .send({
          email: `user${i}@example.com`,
          name: `User ${i}`,
          password: 'TestPass123!',
        })
    );

    const results = await Promise.all(promises);
    
    const successCount = results.filter(r => r.status === 201).length;
    expect(successCount).toBe(100);
  });

  it('should complete order workflow within SLA', async () => {
    const start = Date.now();

    // Complete order workflow
    await completeOrderWorkflow(app);

    const duration = Date.now() - start;
    expect(duration).toBeLessThan(2000); // 2 second SLA
  });
});

Best Practices

DO ✅

  • Test complete user journeys - Not just individual endpoints
  • Use real databases - With proper cleanup between tests
  • Setup via API calls - Avoid direct database manipulation
  • Test error scenarios - 400s, 401s, 404s, 500s
  • Verify side effects - Check emails sent, events emitted

DON'T ❌

  • Mock dependencies - Use real implementations
  • Test implementation details - Focus on behavior
  • Share state between tests - Each test should be independent
  • Use production database - Always use test database
  • Skip database cleanup - Ensures test isolation

Common Patterns

Test Organization

test/
├── integration/
│   ├── users/
│   │   ├── create-user.spec.ts
│   │   ├── update-user.spec.ts
│   │   └── delete-user.spec.ts
│   ├── orders/
│   │   ├── order-workflow.spec.ts
│   │   └── order-cancellation.spec.ts
│   └── repositories/
│       ├── user-repository.spec.ts
│       └── order-repository.spec.ts
├── setup/
│   ├── test-app.factory.ts
│   ├── test-database.ts
│   └── test-config.ts
├── helpers/
│   ├── api.helpers.ts
│   └── database.helpers.ts
└── builders/
    ├── user.builder.ts
    └── order.builder.ts

Next Steps