Testing

Integration Testing Patterns with Express.js

A practical guide to integration testing Express.js APIs with supertest and Jest, covering database setup, authentication testing, test isolation, CI/CD integration, and a complete test suite example.

Integration Testing Patterns with Express.js

Overview

Integration tests verify that the layers of your application work together -- your routes, middleware, database queries, and response formatting all exercised in a single request-response cycle. They catch the bugs that unit tests miss: the wrong column name in a query, a middleware that strips a header your route needs, a validation rule that rejects valid input. If you ship an Express.js API without integration tests, you are relying on manual testing and hope, and hope is not a strategy.

Prerequisites

  • Node.js 18+ installed
  • Basic Express.js experience (routes, middleware, error handling)
  • PostgreSQL installed locally or via Docker
  • Familiarity with Jest (or willingness to learn it in the next ten minutes)
  • A terminal, a code editor, and a willingness to write tests before your manager asks

Unit vs Integration vs E2E Testing

Before writing a single test, you need to understand where integration tests sit in the testing pyramid.

Unit tests verify isolated functions. You test that calculateDiscount(100, 0.15) returns 85. No HTTP, no database, no file system. They run in milliseconds and you should have hundreds of them.

Integration tests verify that components work together. You send an HTTP request to your Express app, it hits the route, runs middleware, queries the database, and returns a response. You assert on the full response. These take seconds, and you should have dozens of them covering every endpoint.

E2E tests verify the entire system from the user's perspective. Browser automation, real infrastructure, the whole stack. They take minutes and you should have a focused set covering critical user journeys.

The mistake I see most teams make is skipping integration tests entirely. They write unit tests for their utility functions and E2E tests with Playwright or Cypress, and they have nothing in between. Integration tests are the most valuable tests for an API because they verify the actual contract your consumers depend on.


Setting Up Supertest with Jest

Supertest is the standard library for testing Express.js applications. It wraps your Express app, sends HTTP requests without starting a real server, and gives you a chainable assertion API. Paired with Jest, it is the fastest path to a working integration test suite.

Install the dependencies:

npm install --save-dev jest supertest

Your package.json needs a test script:

{
  "scripts": {
    "test": "jest",
    "test:integration": "jest --testPathPattern=tests/integration"
  }
}

Create a jest.config.js at your project root:

module.exports = {
  testEnvironment: "node",
  testTimeout: 15000,
  verbose: true,
  forceExit: true,
  detectOpenHandles: true,
  testPathPattern: "tests/integration",
  setupFilesAfterSetup: ["./tests/setup.js"]
};

The forceExit and detectOpenHandles flags matter. Express apps frequently leave database connections or timers open after tests complete, and Jest will hang indefinitely without these flags. I wasted an entire afternoon debugging a hanging CI pipeline before learning this.

Separating Your App from Your Server

This is the most important structural decision for testability. Your app.js must export the Express app object without calling app.listen(). The server startup belongs in a separate file.

// app.js
var express = require("express");
var bodyParser = require("body-parser");
var usersRouter = require("./routes/users");
var productsRouter = require("./routes/products");
var authMiddleware = require("./middleware/auth");

var app = express();

app.use(bodyParser.json());
app.use("/users", usersRouter);
app.use("/products", authMiddleware, productsRouter);

app.use(function(err, req, res, next) {
  var status = err.status || 500;
  res.status(status).json({
    error: err.message,
    status: status
  });
});

module.exports = app;
// server.js
var app = require("./app");
var port = process.env.PORT || 3000;

app.listen(port, function() {
  console.log("Server running on port " + port);
});

Now supertest can import app directly and test it without binding to a port. No port conflicts, no network overhead, no flaky tests caused by port contention in CI.


Testing HTTP Endpoints

GET Requests

The simplest integration test verifies a GET endpoint returns the expected status code and body shape:

var request = require("supertest");
var app = require("../../app");

describe("GET /users", function() {
  it("should return a list of users", function(done) {
    request(app)
      .get("/users")
      .expect("Content-Type", /json/)
      .expect(200)
      .then(function(response) {
        expect(Array.isArray(response.body)).toBe(true);
        expect(response.body.length).toBeGreaterThan(0);
        expect(response.body[0]).toHaveProperty("id");
        expect(response.body[0]).toHaveProperty("email");
        expect(response.body[0]).not.toHaveProperty("password_hash");
        done();
      })
      .catch(done);
  });

  it("should support pagination", function(done) {
    request(app)
      .get("/users?page=1&limit=5")
      .expect(200)
      .then(function(response) {
        expect(response.body.length).toBeLessThanOrEqual(5);
        expect(response.headers).toHaveProperty("x-total-count");
        done();
      })
      .catch(done);
  });
});

POST Requests

POST tests verify request body parsing, validation, and resource creation:

describe("POST /users", function() {
  it("should create a new user", function(done) {
    var newUser = {
      email: "test-" + Date.now() + "@example.com",
      name: "Test User",
      password: "SecurePass123!"
    };

    request(app)
      .post("/users")
      .send(newUser)
      .set("Content-Type", "application/json")
      .expect(201)
      .then(function(response) {
        expect(response.body).toHaveProperty("id");
        expect(response.body.email).toBe(newUser.email);
        expect(response.body.name).toBe(newUser.name);
        expect(response.body).not.toHaveProperty("password");
        done();
      })
      .catch(done);
  });

  it("should reject duplicate email", function(done) {
    var user = {
      email: "[email protected]",
      name: "First User",
      password: "SecurePass123!"
    };

    request(app)
      .post("/users")
      .send(user)
      .expect(201)
      .then(function() {
        return request(app)
          .post("/users")
          .send(user)
          .expect(409);
      })
      .then(function(response) {
        expect(response.body.error).toMatch(/already exists/i);
        done();
      })
      .catch(done);
  });
});

PUT and DELETE Requests

describe("PUT /users/:id", function() {
  it("should update an existing user", function(done) {
    var updates = { name: "Updated Name" };

    request(app)
      .put("/users/1")
      .send(updates)
      .set("Authorization", "Bearer " + testToken)
      .expect(200)
      .then(function(response) {
        expect(response.body.name).toBe("Updated Name");
        done();
      })
      .catch(done);
  });

  it("should return 404 for non-existent user", function(done) {
    request(app)
      .put("/users/99999")
      .send({ name: "Ghost" })
      .set("Authorization", "Bearer " + testToken)
      .expect(404, done);
  });
});

describe("DELETE /users/:id", function() {
  it("should delete an existing user", function(done) {
    request(app)
      .delete("/users/" + createdUserId)
      .set("Authorization", "Bearer " + testToken)
      .expect(204, done);
  });
});

Asserting Response Status Codes, Headers, and Body

Supertest gives you three levels of assertion. Use all of them.

Status codes catch routing and business logic errors:

request(app).get("/users").expect(200);
request(app).get("/nonexistent").expect(404);
request(app).post("/users").send({}).expect(400);
request(app).get("/admin").expect(401);
request(app).delete("/users/1").set("Authorization", "Bearer " + userToken).expect(403);

Headers catch content negotiation and security issues:

request(app)
  .get("/users")
  .expect("Content-Type", /application\/json/)
  .expect("X-Request-Id", /^[a-f0-9-]+$/)
  .expect(function(res) {
    expect(res.headers["x-powered-by"]).toBeUndefined();
    expect(res.headers["cache-control"]).toBe("no-store");
  });

Body structure catches serialization and data leakage issues:

request(app)
  .get("/users/1")
  .expect(200)
  .then(function(response) {
    var user = response.body;
    // Verify expected fields exist
    expect(user).toHaveProperty("id");
    expect(user).toHaveProperty("email");
    expect(user).toHaveProperty("created_at");
    // Verify sensitive fields are excluded
    expect(user).not.toHaveProperty("password_hash");
    expect(user).not.toHaveProperty("reset_token");
    // Verify field types
    expect(typeof user.id).toBe("number");
    expect(typeof user.email).toBe("string");
  });

Testing with a Real Database

Mock-based tests verify that you call the right functions with the right arguments. Integration tests with a real database verify that those functions actually work. I have seen teams with 100% unit test coverage ship broken queries because they mocked their ORM and the mock did not match the actual behavior.

Test Database Setup and Teardown

Create a separate database for testing. Never run tests against your development database.

// tests/setup.js
var { Pool } = require("pg");
var fs = require("fs");
var path = require("path");

var pool = new Pool({
  connectionString: process.env.TEST_DATABASE_URL ||
    "postgresql://localhost:5432/myapp_test"
});

beforeAll(function() {
  var schema = fs.readFileSync(
    path.join(__dirname, "../db/schema.sql"),
    "utf8"
  );
  return pool.query(schema);
});

afterAll(function() {
  return pool.end();
});

module.exports = pool;

Your .env.test file:

NODE_ENV=test
TEST_DATABASE_URL=postgresql://localhost:5432/myapp_test
JWT_SECRET=test-secret-do-not-use-in-production
PORT=0

Create and destroy the test database in your npm scripts:

{
  "scripts": {
    "test:db:create": "createdb myapp_test 2>/dev/null || true",
    "test:db:drop": "dropdb myapp_test 2>/dev/null || true",
    "test:db:reset": "npm run test:db:drop && npm run test:db:create",
    "pretest:integration": "npm run test:db:reset",
    "test:integration": "NODE_ENV=test jest --testPathPattern=tests/integration --runInBand"
  }
}

The --runInBand flag is critical. Integration tests that share a database must run sequentially, not in parallel. Jest runs test files in parallel by default, and two test files truncating the same table at the same time will produce failures that are impossible to reproduce locally.


Database Seeding Strategies

There are three approaches to seeding test data, and I have used all of them at different scales.

Strategy 1: Per-Test Seeding

Each test creates its own data and cleans up after itself:

describe("GET /products/:id", function() {
  var productId;

  beforeEach(function() {
    return pool.query(
      "INSERT INTO products (name, price, category) VALUES ($1, $2, $3) RETURNING id",
      ["Test Widget", 29.99, "electronics"]
    ).then(function(result) {
      productId = result.rows[0].id;
    });
  });

  afterEach(function() {
    return pool.query("DELETE FROM products WHERE id = $1", [productId]);
  });

  it("should return the product", function(done) {
    request(app)
      .get("/products/" + productId)
      .expect(200)
      .then(function(response) {
        expect(response.body.name).toBe("Test Widget");
        expect(response.body.price).toBe(29.99);
        done();
      })
      .catch(done);
  });
});

Strategy 2: Fixture Files

Load a consistent dataset before each test suite:

// tests/fixtures/seed.js
var pool = require("../setup");

function seedDatabase() {
  return pool.query("BEGIN")
    .then(function() {
      return pool.query(
        "INSERT INTO users (id, email, name, password_hash) VALUES " +
        "(1, '[email protected]', 'Alice', '$2b$10$hash1'), " +
        "(2, '[email protected]', 'Bob', '$2b$10$hash2'), " +
        "(3, '[email protected]', 'Charlie', '$2b$10$hash3')"
      );
    })
    .then(function() {
      return pool.query(
        "INSERT INTO products (id, name, price, category, created_by) VALUES " +
        "(1, 'Widget A', 19.99, 'electronics', 1), " +
        "(2, 'Widget B', 29.99, 'electronics', 1), " +
        "(3, 'Gadget C', 49.99, 'gadgets', 2)"
      );
    })
    .then(function() {
      return pool.query(
        "SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))"
      );
    })
    .then(function() {
      return pool.query(
        "SELECT setval('products_id_seq', (SELECT MAX(id) FROM products))"
      );
    })
    .then(function() {
      return pool.query("COMMIT");
    })
    .catch(function(err) {
      return pool.query("ROLLBACK").then(function() {
        throw err;
      });
    });
}

function cleanDatabase() {
  return pool.query("TRUNCATE users, products RESTART IDENTITY CASCADE");
}

module.exports = { seedDatabase: seedDatabase, cleanDatabase: cleanDatabase };

Strategy 3: Transaction Rollback

Wrap each test in a transaction and roll it back. The data never persists, and you never need explicit cleanup:

describe("Product CRUD", function() {
  var client;

  beforeEach(function() {
    return pool.connect().then(function(c) {
      client = c;
      return client.query("BEGIN");
    });
  });

  afterEach(function() {
    return client.query("ROLLBACK").then(function() {
      client.release();
    });
  });

  // Tests use `client` instead of `pool` for queries
});

This is the fastest approach for test isolation, but it requires your application code to accept a database client parameter rather than using a global pool. I use this pattern for data layer tests and fixture seeding for HTTP-level tests.


Testing Authentication Flows

Authentication is the most critical thing to test and the most commonly skipped. Here is a complete pattern.

Login and Token Generation

describe("POST /auth/login", function() {
  beforeAll(function() {
    return seedDatabase(); // Seeds a user with known credentials
  });

  afterAll(function() {
    return cleanDatabase();
  });

  it("should return a JWT for valid credentials", function(done) {
    request(app)
      .post("/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .expect(200)
      .then(function(response) {
        expect(response.body).toHaveProperty("token");
        expect(response.body).toHaveProperty("refreshToken");
        expect(response.body.user.email).toBe("[email protected]");
        expect(response.body.user).not.toHaveProperty("password_hash");

        // Verify the token is valid JWT
        var parts = response.body.token.split(".");
        expect(parts.length).toBe(3);
        done();
      })
      .catch(done);
  });

  it("should reject invalid password", function(done) {
    request(app)
      .post("/auth/login")
      .send({ email: "[email protected]", password: "wrongpassword" })
      .expect(401)
      .then(function(response) {
        expect(response.body.error).toMatch(/invalid credentials/i);
        expect(response.body).not.toHaveProperty("token");
        done();
      })
      .catch(done);
  });

  it("should reject non-existent user", function(done) {
    request(app)
      .post("/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .expect(401)
      .then(function(response) {
        // Same error message as wrong password -- don't leak user existence
        expect(response.body.error).toMatch(/invalid credentials/i);
        done();
      })
      .catch(done);
  });
});

Protected Routes

Create a test helper that generates tokens:

// tests/helpers/auth.js
var jwt = require("jsonwebtoken");
var secret = process.env.JWT_SECRET || "test-secret";

function generateTestToken(userId, role) {
  return jwt.sign(
    { userId: userId, role: role || "user" },
    secret,
    { expiresIn: "1h" }
  );
}

function generateExpiredToken(userId) {
  return jwt.sign(
    { userId: userId, role: "user" },
    secret,
    { expiresIn: "-1h" }
  );
}

module.exports = {
  generateTestToken: generateTestToken,
  generateExpiredToken: generateExpiredToken
};
var { generateTestToken, generateExpiredToken } = require("../helpers/auth");

describe("Protected routes", function() {
  var validToken = generateTestToken(1, "user");
  var adminToken = generateTestToken(1, "admin");
  var expiredToken = generateExpiredToken(1);

  it("should reject requests without a token", function(done) {
    request(app)
      .get("/products")
      .expect(401)
      .then(function(response) {
        expect(response.body.error).toMatch(/token required/i);
        done();
      })
      .catch(done);
  });

  it("should reject expired tokens", function(done) {
    request(app)
      .get("/products")
      .set("Authorization", "Bearer " + expiredToken)
      .expect(401)
      .then(function(response) {
        expect(response.body.error).toMatch(/token expired/i);
        done();
      })
      .catch(done);
  });

  it("should accept valid tokens", function(done) {
    request(app)
      .get("/products")
      .set("Authorization", "Bearer " + validToken)
      .expect(200, done);
  });

  it("should enforce role-based access", function(done) {
    request(app)
      .delete("/products/1")
      .set("Authorization", "Bearer " + validToken)
      .expect(403)
      .then(function(response) {
        expect(response.body.error).toMatch(/insufficient permissions/i);
        return request(app)
          .delete("/products/1")
          .set("Authorization", "Bearer " + adminToken)
          .expect(204);
      })
      .then(function() {
        done();
      })
      .catch(done);
  });
});

Token Refresh

describe("POST /auth/refresh", function() {
  it("should issue a new access token", function(done) {
    var refreshToken;

    request(app)
      .post("/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .expect(200)
      .then(function(response) {
        refreshToken = response.body.refreshToken;
        return request(app)
          .post("/auth/refresh")
          .send({ refreshToken: refreshToken })
          .expect(200);
      })
      .then(function(response) {
        expect(response.body).toHaveProperty("token");
        expect(response.body.token).not.toBe(refreshToken);
        done();
      })
      .catch(done);
  });
});

Testing File Uploads

Supertest handles multipart file uploads with .attach():

var path = require("path");

describe("POST /uploads", function() {
  it("should accept an image upload", function(done) {
    var testImage = path.join(__dirname, "../fixtures/test-image.png");

    request(app)
      .post("/uploads")
      .set("Authorization", "Bearer " + testToken)
      .attach("file", testImage)
      .field("description", "A test image")
      .expect(201)
      .then(function(response) {
        expect(response.body).toHaveProperty("url");
        expect(response.body).toHaveProperty("filename");
        expect(response.body.mimetype).toBe("image/png");
        done();
      })
      .catch(done);
  });

  it("should reject files over the size limit", function(done) {
    var largeFile = path.join(__dirname, "../fixtures/large-file.bin");

    request(app)
      .post("/uploads")
      .set("Authorization", "Bearer " + testToken)
      .attach("file", largeFile)
      .expect(413)
      .then(function(response) {
        expect(response.body.error).toMatch(/file too large/i);
        done();
      })
      .catch(done);
  });

  it("should reject unsupported file types", function(done) {
    var exeFile = path.join(__dirname, "../fixtures/malicious.exe");

    request(app)
      .post("/uploads")
      .set("Authorization", "Bearer " + testToken)
      .attach("file", exeFile)
      .expect(400)
      .then(function(response) {
        expect(response.body.error).toMatch(/unsupported file type/i);
        done();
      })
      .catch(done);
  });
});

Create small fixture files for testing. Do not check in multi-megabyte test files. A 1x1 pixel PNG is 68 bytes and perfectly adequate for testing upload logic.


Testing Error Responses and Validation

Validation errors are part of your API contract. Test them explicitly:

describe("POST /products - validation", function() {
  var token = generateTestToken(1, "admin");

  it("should require a name", function(done) {
    request(app)
      .post("/products")
      .set("Authorization", "Bearer " + token)
      .send({ price: 19.99, category: "electronics" })
      .expect(400)
      .then(function(response) {
        expect(response.body.errors).toContainEqual(
          expect.objectContaining({ field: "name", message: expect.any(String) })
        );
        done();
      })
      .catch(done);
  });

  it("should reject negative prices", function(done) {
    request(app)
      .post("/products")
      .set("Authorization", "Bearer " + token)
      .send({ name: "Bad Product", price: -5, category: "electronics" })
      .expect(400)
      .then(function(response) {
        expect(response.body.errors).toContainEqual(
          expect.objectContaining({ field: "price" })
        );
        done();
      })
      .catch(done);
  });

  it("should return 400 for malformed JSON", function(done) {
    request(app)
      .post("/products")
      .set("Authorization", "Bearer " + token)
      .set("Content-Type", "application/json")
      .send("{ invalid json")
      .expect(400, done);
  });
});

Testing Middleware Behavior

Test middleware indirectly through the routes they protect:

describe("Rate limiting middleware", function() {
  it("should allow requests under the limit", function(done) {
    request(app)
      .get("/api/status")
      .expect(200)
      .then(function(response) {
        expect(response.headers).toHaveProperty("x-ratelimit-remaining");
        done();
      })
      .catch(done);
  });

  it("should block requests over the limit", function(done) {
    var requests = [];
    for (var i = 0; i < 105; i++) {
      requests.push(request(app).get("/api/status"));
    }

    Promise.all(requests).then(function(responses) {
      var blocked = responses.filter(function(r) { return r.status === 429; });
      expect(blocked.length).toBeGreaterThan(0);
      expect(blocked[0].body.error).toMatch(/too many requests/i);
      expect(blocked[0].headers).toHaveProperty("retry-after");
      done();
    }).catch(done);
  });
});

describe("CORS middleware", function() {
  it("should set CORS headers for allowed origins", function(done) {
    request(app)
      .options("/api/products")
      .set("Origin", "https://myapp.com")
      .expect("Access-Control-Allow-Origin", "https://myapp.com")
      .expect("Access-Control-Allow-Methods", /GET/)
      .expect(204, done);
  });

  it("should reject disallowed origins", function(done) {
    request(app)
      .options("/api/products")
      .set("Origin", "https://evil.com")
      .expect(function(res) {
        expect(res.headers["access-control-allow-origin"]).toBeUndefined();
      })
      .then(function() { done(); })
      .catch(done);
  });
});

Managing Test Isolation

Test isolation is the difference between a test suite you trust and one you fear. Every test must be independent -- it should pass whether you run it first, last, or by itself.

Truncation Between Suites

// tests/helpers/db.js
var pool = require("../setup");

function truncateAll() {
  return pool.query(
    "TRUNCATE users, products, orders, sessions RESTART IDENTITY CASCADE"
  );
}

module.exports = { truncateAll: truncateAll };
var { truncateAll } = require("../helpers/db");
var { seedDatabase } = require("../fixtures/seed");

beforeEach(function() {
  return truncateAll().then(function() {
    return seedDatabase();
  });
});

This is the most reliable approach. Every test starts with an identical database state. It is slower than transaction rollback because it actually writes to disk, but it works with any application architecture.

Fresh Seeds per Test File

For large test suites, seed once per file instead of per test:

beforeAll(function() {
  return truncateAll().then(function() {
    return seedDatabase();
  });
});

afterAll(function() {
  return truncateAll();
});

This is faster but requires tests within a file to not depend on order. If test A creates a user and test B expects a specific user count, test B will fail when test A runs first. Design your assertions to be resilient to additional data.


Testing Against External APIs

When your Express app calls external services -- payment processors, email providers, geocoding APIs -- you have two options.

Mock External Calls

Use nock to intercept HTTP requests at the network level:

var nock = require("nock");

describe("POST /orders", function() {
  beforeEach(function() {
    nock("https://api.stripe.com")
      .post("/v1/charges")
      .reply(200, {
        id: "ch_test_123",
        status: "succeeded",
        amount: 2999
      });

    nock("https://api.sendgrid.com")
      .post("/v3/mail/send")
      .reply(202);
  });

  afterEach(function() {
    nock.cleanAll();
  });

  it("should process an order and send confirmation", function(done) {
    request(app)
      .post("/orders")
      .set("Authorization", "Bearer " + testToken)
      .send({ productId: 1, quantity: 1 })
      .expect(201)
      .then(function(response) {
        expect(response.body.chargeId).toBe("ch_test_123");
        expect(response.body.status).toBe("confirmed");
        expect(nock.isDone()).toBe(true); // All mocks were called
        done();
      })
      .catch(done);
  });
});

Use Test Instances

Some services provide test/sandbox environments -- Stripe test mode, SendGrid sandbox, AWS LocalStack. Prefer these over mocks when:

  • The external API behavior is complex and hard to mock accurately
  • You need to verify webhook payloads
  • The service offers a free test tier

For most services, mock in integration tests and verify against test instances in a separate E2E suite.


Environment Configuration for Tests

Create a .env.test file and load it in your test setup:

# .env.test
NODE_ENV=test
TEST_DATABASE_URL=postgresql://localhost:5432/myapp_test
JWT_SECRET=test-jwt-secret-not-for-production
REDIS_URL=redis://localhost:6379/1
LOG_LEVEL=error
RATE_LIMIT_ENABLED=false
// tests/setup.js
var dotenv = require("dotenv");
var path = require("path");

dotenv.config({ path: path.join(__dirname, "../.env.test") });

// Disable rate limiting in tests
process.env.RATE_LIMIT_ENABLED = "false";

// Use a separate Redis database for tests
process.env.REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379/1";

Add .env.test to your .gitignore and provide a .env.test.example with placeholder values. Real secrets do not belong in version control, even test secrets.


Organizing Integration Test Files

Mirror your route structure in your test directory:

tests/
  integration/
    auth.test.js          # Login, register, refresh, logout
    users.test.js         # User CRUD
    products.test.js      # Product CRUD
    orders.test.js        # Order workflow
    uploads.test.js       # File upload
    middleware.test.js     # CORS, rate limiting, etc.
  fixtures/
    seed.js               # Database seed data
    test-image.png        # Small test image
    users.json            # User fixture data
  helpers/
    auth.js               # Token generation helpers
    db.js                 # Database cleanup helpers
  setup.js                # Global test setup
jest.config.js
.env.test

One test file per resource or feature area. If products.test.js exceeds 300 lines, split it into products-crud.test.js and products-search.test.js. Long test files are as hard to maintain as long source files.


Running Integration Tests in CI/CD

Integration tests need infrastructure -- a database, possibly Redis, possibly external services. Docker Compose handles this cleanly.

Docker Compose for Test Dependencies

# docker-compose.test.yml
version: "3.8"
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: myapp_test
    ports:
      - "5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test"]
      interval: 2s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    ports:
      - "6380:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 5s
      retries: 10

GitHub Actions Workflow

# .github/workflows/test.yml
name: Integration Tests
on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci

      - name: Run integration tests
        env:
          TEST_DATABASE_URL: postgresql://test:test@localhost:5432/myapp_test
          JWT_SECRET: ci-test-secret
          REDIS_URL: redis://localhost:6379/1
          NODE_ENV: test
        run: npm run test:integration -- --coverage --ci

GitHub Actions services run containers alongside your job. They start before your steps execute and shut down automatically. This is cleaner than running docker-compose up in a step.


Performance Considerations

Shared Server Instance

Do not create a new Express app per test. Import it once and reuse it:

// Good: one app instance for all tests in this file
var app = require("../../app");

describe("Users API", function() {
  it("test 1", function(done) {
    request(app).get("/users").expect(200, done);
  });
  it("test 2", function(done) {
    request(app).get("/users/1").expect(200, done);
  });
});

Parallel vs Sequential

Run test files sequentially with --runInBand when they share a database. Run them in parallel only if each file uses a separate database or transaction isolation:

{
  "scripts": {
    "test:integration": "jest --runInBand --testPathPattern=tests/integration",
    "test:unit": "jest --testPathPattern=tests/unit"
  }
}

Unit tests can always run in parallel. Integration tests almost always need --runInBand.

Connection Pooling

Use a small connection pool for tests. The default pool size of 10 is wasteful when tests run sequentially:

var pool = new Pool({
  connectionString: process.env.TEST_DATABASE_URL,
  max: 3
});

Complete Working Example

Here is a full integration test suite for an Express.js API with PostgreSQL. This covers CRUD operations, authentication, validation errors, and database state management.

Project Structure

project/
  app.js
  routes/
    users.js
    products.js
    auth.js
  middleware/
    auth.js
  db/
    pool.js
    schema.sql
  tests/
    integration/
      users.test.js
      products.test.js
      auth.test.js
    fixtures/
      seed.js
    helpers/
      auth.js
      db.js
    setup.js
  jest.config.js
  .env.test
  docker-compose.test.yml

jest.config.js

module.exports = {
  testEnvironment: "node",
  testTimeout: 15000,
  verbose: true,
  forceExit: true,
  detectOpenHandles: true,
  setupFilesAfterSetup: ["./tests/setup.js"],
  coverageDirectory: "coverage",
  collectCoverageFrom: [
    "routes/**/*.js",
    "middleware/**/*.js",
    "!**/node_modules/**"
  ]
};

tests/setup.js

var dotenv = require("dotenv");
var path = require("path");
var fs = require("fs");
var { Pool } = require("pg");

dotenv.config({ path: path.join(__dirname, "../.env.test") });

var pool = new Pool({
  connectionString: process.env.TEST_DATABASE_URL,
  max: 3
});

beforeAll(function() {
  var schema = fs.readFileSync(
    path.join(__dirname, "../db/schema.sql"),
    "utf8"
  );
  return pool.query(schema);
});

afterAll(function() {
  return pool.end();
});

global.testPool = pool;

tests/helpers/auth.js

var jwt = require("jsonwebtoken");
var secret = process.env.JWT_SECRET || "test-secret";

function generateTestToken(userId, role) {
  return jwt.sign(
    { userId: userId, role: role || "user" },
    secret,
    { expiresIn: "1h" }
  );
}

function generateExpiredToken(userId) {
  return jwt.sign(
    { userId: userId, role: "user" },
    secret,
    { expiresIn: "-1h" }
  );
}

module.exports = {
  generateTestToken: generateTestToken,
  generateExpiredToken: generateExpiredToken
};

tests/helpers/db.js

function truncateAll() {
  return global.testPool.query(
    "TRUNCATE users, products RESTART IDENTITY CASCADE"
  );
}

module.exports = { truncateAll: truncateAll };

tests/fixtures/seed.js

var bcrypt = require("bcrypt");

function seedDatabase() {
  var pool = global.testPool;

  return bcrypt.hash("password123", 10).then(function(hash) {
    return pool.query(
      "INSERT INTO users (email, name, password_hash, role) VALUES " +
      "($1, $2, $3, $4), ($5, $6, $7, $8), ($9, $10, $11, $12)",
      [
        "[email protected]", "Alice Johnson", hash, "admin",
        "[email protected]", "Bob Smith", hash, "user",
        "[email protected]", "Charlie Brown", hash, "user"
      ]
    );
  }).then(function() {
    return pool.query(
      "INSERT INTO products (name, price, category, created_by) VALUES " +
      "($1, $2, $3, $4), ($5, $6, $7, $8)",
      [
        "API Design Handbook", 39.99, "books", 1,
        "Node.js Sticker Pack", 9.99, "merch", 1
      ]
    );
  });
}

module.exports = { seedDatabase: seedDatabase };

tests/integration/products.test.js

var request = require("supertest");
var app = require("../../app");
var { generateTestToken } = require("../helpers/auth");
var { truncateAll } = require("../helpers/db");
var { seedDatabase } = require("../fixtures/seed");

describe("Products API", function() {
  var userToken = generateTestToken(2, "user");
  var adminToken = generateTestToken(1, "admin");

  beforeEach(function() {
    return truncateAll().then(function() {
      return seedDatabase();
    });
  });

  afterAll(function() {
    return truncateAll();
  });

  describe("GET /products", function() {
    it("should return all products", function(done) {
      request(app)
        .get("/products")
        .set("Authorization", "Bearer " + userToken)
        .expect("Content-Type", /json/)
        .expect(200)
        .then(function(response) {
          expect(Array.isArray(response.body)).toBe(true);
          expect(response.body.length).toBe(2);
          expect(response.body[0]).toHaveProperty("id");
          expect(response.body[0]).toHaveProperty("name");
          expect(response.body[0]).toHaveProperty("price");
          expect(response.body[0]).toHaveProperty("category");
          done();
        })
        .catch(done);
    });

    it("should filter by category", function(done) {
      request(app)
        .get("/products?category=books")
        .set("Authorization", "Bearer " + userToken)
        .expect(200)
        .then(function(response) {
          expect(response.body.length).toBe(1);
          expect(response.body[0].category).toBe("books");
          done();
        })
        .catch(done);
    });
  });

  describe("POST /products", function() {
    it("should create a product as admin", function(done) {
      var product = {
        name: "New Widget",
        price: 24.99,
        category: "electronics"
      };

      request(app)
        .post("/products")
        .set("Authorization", "Bearer " + adminToken)
        .send(product)
        .expect(201)
        .then(function(response) {
          expect(response.body.name).toBe("New Widget");
          expect(response.body.price).toBe(24.99);
          expect(response.body).toHaveProperty("id");
          expect(response.body).toHaveProperty("created_at");
          done();
        })
        .catch(done);
    });

    it("should reject creation by non-admin", function(done) {
      request(app)
        .post("/products")
        .set("Authorization", "Bearer " + userToken)
        .send({ name: "Blocked", price: 10, category: "test" })
        .expect(403)
        .then(function(response) {
          expect(response.body.error).toMatch(/insufficient permissions/i);
          done();
        })
        .catch(done);
    });

    it("should validate required fields", function(done) {
      request(app)
        .post("/products")
        .set("Authorization", "Bearer " + adminToken)
        .send({ price: 10 })
        .expect(400)
        .then(function(response) {
          expect(response.body.errors).toBeDefined();
          expect(response.body.errors.length).toBeGreaterThan(0);
          done();
        })
        .catch(done);
    });
  });

  describe("PUT /products/:id", function() {
    it("should update a product", function(done) {
      request(app)
        .put("/products/1")
        .set("Authorization", "Bearer " + adminToken)
        .send({ name: "Updated Handbook", price: 44.99 })
        .expect(200)
        .then(function(response) {
          expect(response.body.name).toBe("Updated Handbook");
          expect(response.body.price).toBe(44.99);
          done();
        })
        .catch(done);
    });

    it("should return 404 for missing product", function(done) {
      request(app)
        .put("/products/99999")
        .set("Authorization", "Bearer " + adminToken)
        .send({ name: "Ghost" })
        .expect(404, done);
    });
  });

  describe("DELETE /products/:id", function() {
    it("should delete a product as admin", function(done) {
      request(app)
        .delete("/products/1")
        .set("Authorization", "Bearer " + adminToken)
        .expect(204)
        .then(function() {
          return request(app)
            .get("/products/1")
            .set("Authorization", "Bearer " + userToken)
            .expect(404);
        })
        .then(function() {
          done();
        })
        .catch(done);
    });
  });
});

Sample Test Output

$ npm run test:integration

> [email protected] test:integration
> NODE_ENV=test jest --runInBand --testPathPattern=tests/integration --verbose

 PASS  tests/integration/auth.test.js
  POST /auth/login
    ✓ should return a JWT for valid credentials (45 ms)
    ✓ should reject invalid password (38 ms)
    ✓ should reject non-existent user (12 ms)
  POST /auth/refresh
    ✓ should issue a new access token (52 ms)

 PASS  tests/integration/users.test.js
  GET /users
    ✓ should return a list of users (23 ms)
    ✓ should support pagination (18 ms)
  POST /users
    ✓ should create a new user (34 ms)
    ✓ should reject duplicate email (41 ms)

 PASS  tests/integration/products.test.js
  Products API
    GET /products
      ✓ should return all products (19 ms)
      ✓ should filter by category (15 ms)
    POST /products
      ✓ should create a product as admin (28 ms)
      ✓ should reject creation by non-admin (11 ms)
      ✓ should validate required fields (9 ms)
    PUT /products/:id
      ✓ should update a product (22 ms)
      ✓ should return 404 for missing product (8 ms)
    DELETE /products/:id
      ✓ should delete a product as admin (31 ms)

Test Suites: 3 passed, 3 total
Tests:       16 passed, 16 total
Snapshots:   0 total
Time:        4.217 s
Ran all test suites.

Common Issues and Troubleshooting

1. Jest Hangs After Tests Complete

Test Suites: 3 passed, 3 total
Tests:       16 passed, 16 total
Time:        4.5 s

(Jest process does not exit)

Cause: Open database connections, timers, or event listeners. Express middleware like express-session with a database store is a frequent offender.

Fix: Add forceExit: true and detectOpenHandles: true to jest.config.js. Then close your pool in afterAll:

afterAll(function() {
  return pool.end();
});

2. EADDRINUSE: Address Already in Use

Error: listen EADDRINUSE: address already in use :::3000

Cause: Your app.js calls app.listen() when imported. Supertest creates its own ephemeral server and conflicts with yours.

Fix: Move app.listen() to a separate server.js file. Export only the Express app from app.js. This is the most common integration testing mistake I see in Express projects.

3. Tests Pass Individually but Fail Together

FAIL tests/integration/products.test.js
  ● Products API > GET /products > should return all products
    expect(received).toBe(expected)
    Expected: 2
    Received: 5

Cause: Test isolation failure. A previous test file inserted data that was not cleaned up.

Fix: Add beforeEach with truncateAll() and seedDatabase(). Use --runInBand to prevent parallel execution when sharing a database.

4. Connection Refused to Database in CI

Error: connect ECONNREFUSED 127.0.0.1:5432

Cause: The PostgreSQL service container is not ready when tests start. Container health checks have not passed yet.

Fix: In GitHub Actions, use the options field with health checks. In Docker Compose, use depends_on with condition: service_healthy. As a fallback, add a wait script:

// tests/wait-for-db.js
var { Pool } = require("pg");

function waitForDb(retries) {
  var pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
  retries = retries || 30;

  return pool.query("SELECT 1").then(function() {
    console.log("Database is ready");
    return pool.end();
  }).catch(function(err) {
    if (retries === 0) throw err;
    console.log("Waiting for database... (" + retries + " retries left)");
    return new Promise(function(resolve) {
      setTimeout(resolve, 1000);
    }).then(function() {
      return pool.end().then(function() {
        return waitForDb(retries - 1);
      });
    });
  });
}

waitForDb();

5. Supertest Not Sending JSON Content-Type

FAIL tests/integration/users.test.js
  ● POST /users > should create a new user
    expected 201 "Created", got 400 "Bad Request"

Cause: Supertest's .send() with a plain object sets Content-Type: application/json automatically, but only if you pass a JavaScript object. If you pass a string, it defaults to text/plain.

Fix: Always pass objects to .send(), not strings. If you must send a raw string, explicitly set the content type:

request(app)
  .post("/users")
  .set("Content-Type", "application/json")
  .send('{"email":"[email protected]"}')
  .expect(201);

Best Practices

  • Separate app from server. Export the Express app without calling listen(). This is non-negotiable for supertest to work. Every Express project should follow this pattern from day one, whether you plan to write tests or not.

  • Use a real database in integration tests. Mocking the database defeats the purpose of integration testing. You want to verify that your SQL queries work, that your constraints hold, that your indexes are used. A mock cannot tell you any of that.

  • Run integration tests sequentially. Use --runInBand when tests share a database. Parallel execution with shared state produces intermittent failures that erode trust in your test suite.

  • Clean state before each test, not after. Use beforeEach for cleanup and seeding. If a test fails and skips its afterEach, the next test inherits dirty state. Cleaning before the test guarantees a fresh starting point regardless of what happened previously.

  • Never test against your development database. Use a dedicated test database with its own connection string. One careless TRUNCATE statement against your development data will ruin your morning.

  • Test error paths as thoroughly as success paths. Your API's error responses are part of its contract. Consumers rely on specific status codes, error message formats, and validation error structures. If you change a 400 to a 422 without testing it, someone's error handler breaks.

  • Keep test files focused and under 300 lines. A test file that covers every edge case for every endpoint becomes impossible to maintain. Split by resource or feature area.

  • Use helper functions for repeated patterns. Token generation, database seeding, request factories -- extract these into tests/helpers/ and import them. Duplicated test setup is as harmful as duplicated production code.

  • Pin your test infrastructure versions. Use postgres:16-alpine, not postgres:latest, in your Docker Compose and CI config. A major version bump in your test database should be a deliberate decision, not a Tuesday surprise.

  • Log sparingly in test mode. Set LOG_LEVEL=error in .env.test. Console output from your application clutters test output and makes failures harder to find. You should only see test results, not request logs.


References

Powered by Contentful