Testing

Integration Testing Patterns with Express.js

A practical guide to integration testing Express.js APIs with Supertest including HTTP assertions, database setup, authentication testing, and test isolation.

Integration Testing Patterns with Express.js

Unit tests verify individual functions. Integration tests verify that those functions work together — that routes handle requests correctly, middleware runs in the right order, database queries return expected results, and error handling catches real failures. For Express.js applications, integration tests are the most valuable tests you can write because they test the actual HTTP interface your clients use.

I test every API endpoint with integration tests. They catch bugs that unit tests miss — missing middleware, incorrect status codes, malformed responses, authentication failures. This guide covers the patterns that make Express integration testing reliable and fast.

Prerequisites

  • Node.js installed (v14+)
  • Express.js application
  • Jest installed (npm install --save-dev jest)
  • Supertest installed (npm install --save-dev supertest)

Setting Up Supertest

Supertest makes HTTP assertions against Express apps without starting a server:

npm install --save-dev jest supertest

Separating App from Server

The key pattern: export your Express app separately from the listen() call:

// app.js
var express = require("express");
var app = express();

app.use(express.json());

app.get("/api/health", function(req, res) {
  res.json({ status: "ok" });
});

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

app.listen(port, function() {
  console.log("Server listening on port " + port);
});
// app.test.js
var request = require("supertest");
var app = require("./app");

describe("GET /api/health", function() {
  test("returns 200 with status ok", function() {
    return request(app)
      .get("/api/health")
      .expect(200)
      .expect("Content-Type", /json/)
      .then(function(response) {
        expect(response.body.status).toBe("ok");
      });
  });
});

Basic HTTP Assertions

Testing GET Endpoints

describe("GET /api/users", function() {

  test("returns a list of users", function() {
    return request(app)
      .get("/api/users")
      .expect(200)
      .expect("Content-Type", /json/)
      .then(function(res) {
        expect(Array.isArray(res.body)).toBe(true);
        expect(res.body.length).toBeGreaterThan(0);
        expect(res.body[0]).toHaveProperty("id");
        expect(res.body[0]).toHaveProperty("name");
        expect(res.body[0]).toHaveProperty("email");
      });
  });

  test("supports pagination", function() {
    return request(app)
      .get("/api/users?page=2&limit=10")
      .expect(200)
      .then(function(res) {
        expect(res.body.page).toBe(2);
        expect(res.body.limit).toBe(10);
        expect(res.body.data.length).toBeLessThanOrEqual(10);
        expect(res.body).toHaveProperty("total");
      });
  });

  test("returns 404 for non-existent user", function() {
    return request(app)
      .get("/api/users/99999")
      .expect(404)
      .then(function(res) {
        expect(res.body.error).toBe("User not found");
      });
  });
});

Testing POST Endpoints

describe("POST /api/users", function() {

  test("creates a new user", function() {
    var newUser = {
      name: "Shane",
      email: "[email protected]",
      role: "developer"
    };

    return request(app)
      .post("/api/users")
      .send(newUser)
      .expect(201)
      .expect("Content-Type", /json/)
      .then(function(res) {
        expect(res.body).toHaveProperty("id");
        expect(res.body.name).toBe("Shane");
        expect(res.body.email).toBe("[email protected]");
      });
  });

  test("returns 400 for missing required fields", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Shane" })  // Missing email
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("email");
      });
  });

  test("returns 409 for duplicate email", function() {
    var user = { name: "Shane", email: "[email protected]" };

    return request(app)
      .post("/api/users")
      .send(user)
      .expect(409)
      .then(function(res) {
        expect(res.body.error).toContain("already exists");
      });
  });
});

Testing PUT and DELETE

describe("PUT /api/users/:id", function() {

  test("updates an existing user", function() {
    return request(app)
      .put("/api/users/1")
      .send({ name: "Updated Name" })
      .expect(200)
      .then(function(res) {
        expect(res.body.name).toBe("Updated Name");
      });
  });

  test("returns 404 for non-existent user", function() {
    return request(app)
      .put("/api/users/99999")
      .send({ name: "Ghost" })
      .expect(404);
  });
});

describe("DELETE /api/users/:id", function() {

  test("deletes a user", function() {
    return request(app)
      .delete("/api/users/1")
      .expect(204);
  });

  test("returns 404 when deleting non-existent user", function() {
    return request(app)
      .delete("/api/users/99999")
      .expect(404);
  });
});

Testing with a Database

Database Setup and Teardown

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

describe("User API with database", function() {

  beforeAll(function() {
    return db.connect("test_database");
  });

  afterAll(function() {
    return db.disconnect();
  });

  beforeEach(function() {
    return db.clear("users").then(function() {
      return db.seed("users", [
        { id: 1, name: "Alice", email: "[email protected]" },
        { id: 2, name: "Bob", email: "[email protected]" }
      ]);
    });
  });

  test("GET /api/users returns seeded users", function() {
    return request(app)
      .get("/api/users")
      .expect(200)
      .then(function(res) {
        expect(res.body).toHaveLength(2);
        expect(res.body[0].name).toBe("Alice");
      });
  });

  test("POST /api/users persists to database", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Carol", email: "[email protected]" })
      .expect(201)
      .then(function() {
        return request(app)
          .get("/api/users")
          .expect(200);
      })
      .then(function(res) {
        expect(res.body).toHaveLength(3);
      });
  });
});

In-Memory Database for Tests

// test/setup.js
var sqlite3 = require("better-sqlite3");

var testDb;

function getTestDb() {
  if (!testDb) {
    testDb = sqlite3(":memory:");
    testDb.exec(require("fs").readFileSync("schema.sql", "utf-8"));
  }
  return testDb;
}

function clearTestDb() {
  var tables = testDb.prepare(
    "SELECT name FROM sqlite_master WHERE type='table'"
  ).all();

  tables.forEach(function(table) {
    testDb.exec("DELETE FROM " + table.name);
  });
}

module.exports = { getTestDb: getTestDb, clearTestDb: clearTestDb };

Testing Authentication

Testing Protected Routes

describe("Protected Routes", function() {

  var token;

  beforeAll(function() {
    // Get a valid token
    return request(app)
      .post("/api/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .then(function(res) {
        token = res.body.token;
      });
  });

  test("returns 401 without token", function() {
    return request(app)
      .get("/api/admin/users")
      .expect(401);
  });

  test("returns 401 with invalid token", function() {
    return request(app)
      .get("/api/admin/users")
      .set("Authorization", "Bearer invalid-token")
      .expect(401);
  });

  test("returns 200 with valid token", function() {
    return request(app)
      .get("/api/admin/users")
      .set("Authorization", "Bearer " + token)
      .expect(200);
  });

  test("returns 403 for unauthorized role", function() {
    // Get a non-admin token
    return request(app)
      .post("/api/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .then(function(res) {
        return request(app)
          .get("/api/admin/users")
          .set("Authorization", "Bearer " + res.body.token)
          .expect(403);
      });
  });
});

Helper for Authenticated Requests

function authenticatedRequest(app, method, path) {
  var token = "test-jwt-token";  // From test setup or mock
  var req = request(app)[method](path);
  req.set("Authorization", "Bearer " + token);
  return req;
}

test("authenticated GET works", function() {
  return authenticatedRequest(app, "get", "/api/profile")
    .expect(200)
    .then(function(res) {
      expect(res.body).toHaveProperty("name");
    });
});

Testing Headers and Cookies

describe("Headers", function() {

  test("sets correct CORS headers", function() {
    return request(app)
      .get("/api/data")
      .expect("Access-Control-Allow-Origin", "*")
      .expect("Access-Control-Allow-Methods", /GET/);
  });

  test("sets cache headers", function() {
    return request(app)
      .get("/api/config")
      .expect("Cache-Control", "public, max-age=3600");
  });

  test("accepts custom content type", function() {
    return request(app)
      .post("/api/data")
      .set("Content-Type", "application/xml")
      .send("<data><value>test</value></data>")
      .expect(200);
  });
});

describe("Cookies", function() {

  test("sets session cookie on login", function() {
    return request(app)
      .post("/api/auth/login")
      .send({ email: "[email protected]", password: "password123" })
      .expect(200)
      .then(function(res) {
        expect(res.headers["set-cookie"]).toBeDefined();
        expect(res.headers["set-cookie"][0]).toContain("session");
      });
  });
});

Testing File Uploads

var path = require("path");

describe("File Upload", function() {

  test("uploads an image", function() {
    return request(app)
      .post("/api/upload")
      .attach("avatar", path.join(__dirname, "fixtures/test-image.png"))
      .expect(200)
      .then(function(res) {
        expect(res.body).toHaveProperty("url");
        expect(res.body.filename).toMatch(/\.png$/);
      });
  });

  test("rejects files that are too large", function() {
    return request(app)
      .post("/api/upload")
      .attach("avatar", path.join(__dirname, "fixtures/large-file.zip"))
      .expect(413);
  });

  test("rejects invalid file types", function() {
    return request(app)
      .post("/api/upload")
      .attach("avatar", path.join(__dirname, "fixtures/test.exe"))
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("file type");
      });
  });
});

Testing Middleware

describe("Rate Limiting Middleware", function() {

  test("allows requests under the limit", function() {
    return request(app)
      .get("/api/data")
      .expect(200);
  });

  test("returns 429 when rate limit is exceeded", function() {
    var requests = [];
    for (var i = 0; i < 101; i++) {
      requests.push(request(app).get("/api/data"));
    }

    return Promise.all(requests).then(function(responses) {
      var tooMany = responses.filter(function(res) {
        return res.status === 429;
      });
      expect(tooMany.length).toBeGreaterThan(0);
    });
  });
});

describe("Request Validation Middleware", function() {

  test("rejects request with missing content-type", function() {
    return request(app)
      .post("/api/data")
      .set("Content-Type", "text/plain")
      .send("plain text")
      .expect(415);
  });

  test("rejects oversized request body", function() {
    var largeBody = { data: "x".repeat(1024 * 1024 + 1) };  // Over 1MB
    return request(app)
      .post("/api/data")
      .send(largeBody)
      .expect(413);
  });
});

Complete Working Example: Full API Test Suite

// tests/api.test.js
var request = require("supertest");
var app = require("../app");
var db = require("../db");

describe("Articles API", function() {

  beforeAll(function() {
    return db.connect(process.env.TEST_DATABASE_URL || "sqlite::memory:");
  });

  afterAll(function() {
    return db.disconnect();
  });

  beforeEach(function() {
    return db.clear().then(function() {
      return db.seed({
        articles: [
          { id: 1, title: "First Article", content: "Content one", status: "published" },
          { id: 2, title: "Second Article", content: "Content two", status: "published" },
          { id: 3, title: "Draft Article", content: "Draft content", status: "draft" }
        ]
      });
    });
  });

  describe("GET /api/articles", function() {

    test("returns only published articles", function() {
      return request(app)
        .get("/api/articles")
        .expect(200)
        .expect("Content-Type", /json/)
        .then(function(res) {
          expect(res.body.data).toHaveLength(2);
          res.body.data.forEach(function(article) {
            expect(article.status).toBe("published");
          });
        });
    });

    test("supports search by title", function() {
      return request(app)
        .get("/api/articles?search=First")
        .expect(200)
        .then(function(res) {
          expect(res.body.data).toHaveLength(1);
          expect(res.body.data[0].title).toBe("First Article");
        });
    });

    test("returns empty array for no matches", function() {
      return request(app)
        .get("/api/articles?search=nonexistent")
        .expect(200)
        .then(function(res) {
          expect(res.body.data).toHaveLength(0);
        });
    });
  });

  describe("GET /api/articles/:id", function() {

    test("returns a single article", function() {
      return request(app)
        .get("/api/articles/1")
        .expect(200)
        .then(function(res) {
          expect(res.body.id).toBe(1);
          expect(res.body.title).toBe("First Article");
          expect(res.body).toHaveProperty("content");
        });
    });

    test("returns 404 for non-existent article", function() {
      return request(app)
        .get("/api/articles/99999")
        .expect(404)
        .then(function(res) {
          expect(res.body).toHaveProperty("error");
        });
    });

    test("returns 400 for invalid ID format", function() {
      return request(app)
        .get("/api/articles/not-a-number")
        .expect(400);
    });
  });

  describe("POST /api/articles", function() {

    test("creates a new article", function() {
      var article = {
        title: "New Article",
        content: "New content here",
        status: "draft"
      };

      return request(app)
        .post("/api/articles")
        .send(article)
        .expect(201)
        .then(function(res) {
          expect(res.body).toHaveProperty("id");
          expect(res.body.title).toBe("New Article");
          expect(res.body.status).toBe("draft");
        });
    });

    test("validates required fields", function() {
      return request(app)
        .post("/api/articles")
        .send({ title: "" })
        .expect(400)
        .then(function(res) {
          expect(res.body.errors).toBeDefined();
        });
    });
  });

  describe("PUT /api/articles/:id", function() {

    test("updates article fields", function() {
      return request(app)
        .put("/api/articles/1")
        .send({ title: "Updated Title" })
        .expect(200)
        .then(function(res) {
          expect(res.body.title).toBe("Updated Title");
          expect(res.body.content).toBe("Content one");  // Unchanged
        });
    });
  });

  describe("DELETE /api/articles/:id", function() {

    test("deletes an article and returns 204", function() {
      return request(app)
        .delete("/api/articles/1")
        .expect(204)
        .then(function() {
          return request(app)
            .get("/api/articles/1")
            .expect(404);
        });
    });
  });
});

Common Issues and Troubleshooting

"listen EADDRINUSE: address already in use"

The app is calling listen() during tests:

Fix: Separate your Express app creation from the listen() call. Export app, not the server instance. Supertest creates its own ephemeral server.

Tests pass individually but fail when run together

Database state leaks between tests:

Fix: Use beforeEach to reset the database to a known state. Clear all tables and re-seed test data before every test. Use transactions with rollback for faster cleanup.

Async assertion does not fail the test

Assertions inside callbacks or promises are not caught by Jest:

Fix: Always return the promise from request(app).get(...). Or use the .then() chain to make assertions. Jest only catches assertion errors if the promise is returned.

POST request body is empty in the handler

The express.json() middleware is not configured:

Fix: Ensure app.use(express.json()) is called before your routes. Check that the test sends with the correct Content-Type: Supertest's .send() sets Content-Type: application/json automatically.

Best Practices

  • Separate app creation from server startup. Export the Express app from one file, call listen() in another. This lets Supertest create its own server per test.
  • Reset database state in beforeEach. Every test should start from a known state. Never depend on the order of test execution.
  • Test the full response — status, headers, and body. Verify status codes, Content-Type headers, and response body structure. A 200 with wrong data is still a bug.
  • Test error responses as thoroughly as success responses. 400, 401, 403, 404, 409, and 500 responses should all be tested with expected error messages.
  • Use test fixtures for consistent data. Define test users, articles, and other entities in fixture files. Reference them by name instead of hardcoding data in every test.
  • Keep integration tests focused. Each test should verify one behavior. A test for creating a user should not also verify listing users.
  • Use in-memory databases for speed. SQLite in-memory mode runs faster than PostgreSQL for integration tests. Use the real database for a smaller set of smoke tests.

References

Powered by Contentful