Testing

API Testing with Supertest and Jest

A practical guide to testing Express.js REST APIs with Supertest and Jest covering CRUD endpoints, authentication, file uploads, error handling, and test organization.

API Testing with Supertest and Jest

API tests verify that your endpoints accept the right input, return the right output, and handle errors correctly. They run against the real Express application but skip the network layer — no server needs to be running on a port. Supertest plugs directly into your Express app and sends HTTP requests through it.

This is the most valuable layer of testing for API development. Unit tests verify individual functions. E2E tests verify complete user flows. API tests verify the contract between your frontend and backend — the layer where most bugs in web applications live.

Prerequisites

  • Node.js installed (v16+)
  • An Express.js application
  • Jest configured for the project

Setup

npm install --save-dev supertest jest

The key insight: Supertest does not need a running server. It takes your Express app directly:

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

// This works without app.listen()
request(app).get("/api/users").expect(200);

Express App Structure

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

app.use(express.json());
app.use("/api/users", require("./routes/users"));
app.use("/api/articles", require("./routes/articles"));

// Export the app without calling listen()
module.exports = app;
// server.js — separate file for starting the server
var app = require("./app");
var port = process.env.PORT || 3000;
app.listen(port, function() {
  console.log("Server running on port " + port);
});

Testing GET Endpoints

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

describe("GET /api/users", function() {
  beforeEach(function() {
    return db.clear("users").then(function() {
      return db.insertMany("users", [
        { name: "Alice", email: "[email protected]", role: "admin" },
        { name: "Bob", email: "[email protected]", role: "user" },
        { name: "Carol", email: "[email protected]", role: "user" }
      ]);
    });
  });

  test("returns all users", function() {
    return request(app)
      .get("/api/users")
      .expect(200)
      .expect("Content-Type", /json/)
      .then(function(res) {
        expect(res.body).toHaveLength(3);
        expect(res.body[0]).toHaveProperty("name");
        expect(res.body[0]).toHaveProperty("email");
      });
  });

  test("filters users by role", function() {
    return request(app)
      .get("/api/users?role=admin")
      .expect(200)
      .then(function(res) {
        expect(res.body).toHaveLength(1);
        expect(res.body[0].name).toBe("Alice");
      });
  });

  test("paginates results", function() {
    return request(app)
      .get("/api/users?page=1&limit=2")
      .expect(200)
      .then(function(res) {
        expect(res.body.data).toHaveLength(2);
        expect(res.body.total).toBe(3);
        expect(res.body.page).toBe(1);
        expect(res.body.totalPages).toBe(2);
      });
  });

  test("returns empty array when no users match", function() {
    return request(app)
      .get("/api/users?role=superadmin")
      .expect(200)
      .then(function(res) {
        expect(res.body).toEqual([]);
      });
  });
});

describe("GET /api/users/:id", function() {
  var testUser;

  beforeEach(function() {
    return db.clear("users").then(function() {
      return db.insert("users", { name: "Alice", email: "[email protected]" });
    }).then(function(user) {
      testUser = user;
    });
  });

  test("returns a single user", function() {
    return request(app)
      .get("/api/users/" + testUser.id)
      .expect(200)
      .then(function(res) {
        expect(res.body.name).toBe("Alice");
        expect(res.body.email).toBe("[email protected]");
      });
  });

  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");
      });
  });

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

Testing POST Endpoints

describe("POST /api/users", function() {
  beforeEach(function() {
    return db.clear("users");
  });

  test("creates a new user", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Dave", email: "[email protected]" })
      .expect(201)
      .expect("Content-Type", /json/)
      .then(function(res) {
        expect(res.body.id).toBeDefined();
        expect(res.body.name).toBe("Dave");
        expect(res.body.email).toBe("[email protected]");
        expect(res.body.createdAt).toBeDefined();
      });
  });

  test("returns 400 when name is missing", function() {
    return request(app)
      .post("/api/users")
      .send({ email: "[email protected]" })
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("name");
      });
  });

  test("returns 400 when email is missing", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Dave" })
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("email");
      });
  });

  test("returns 400 for invalid email format", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Dave", email: "not-an-email" })
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("email");
      });
  });

  test("returns 409 when email already exists", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "Dave", email: "[email protected]" })
      .expect(201)
      .then(function() {
        return request(app)
          .post("/api/users")
          .send({ name: "Other Dave", email: "[email protected]" })
          .expect(409);
      })
      .then(function(res) {
        expect(res.body.error).toContain("already exists");
      });
  });

  test("trims whitespace from inputs", function() {
    return request(app)
      .post("/api/users")
      .send({ name: "  Dave  ", email: "  [email protected]  " })
      .expect(201)
      .then(function(res) {
        expect(res.body.name).toBe("Dave");
        expect(res.body.email).toBe("[email protected]");
      });
  });
});

Testing PUT/PATCH Endpoints

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

  beforeEach(function() {
    return db.clear("users").then(function() {
      return db.insert("users", {
        name: "Alice",
        email: "[email protected]",
        role: "user"
      });
    }).then(function(user) {
      testUser = user;
    });
  });

  test("updates user fields", function() {
    return request(app)
      .put("/api/users/" + testUser.id)
      .send({ name: "Alice Updated", role: "admin" })
      .expect(200)
      .then(function(res) {
        expect(res.body.name).toBe("Alice Updated");
        expect(res.body.role).toBe("admin");
        expect(res.body.email).toBe("[email protected]"); // Unchanged
      });
  });

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

  test("validates email format on update", function() {
    return request(app)
      .put("/api/users/" + testUser.id)
      .send({ email: "bad-email" })
      .expect(400);
  });

  test("ignores unknown fields", function() {
    return request(app)
      .put("/api/users/" + testUser.id)
      .send({ name: "Updated", hackField: "malicious" })
      .expect(200)
      .then(function(res) {
        expect(res.body.hackField).toBeUndefined();
      });
  });
});

Testing DELETE Endpoints

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

  beforeEach(function() {
    return db.clear("users").then(function() {
      return db.insert("users", { name: "Alice", email: "[email protected]" });
    }).then(function(user) {
      testUser = user;
    });
  });

  test("deletes a user", function() {
    return request(app)
      .delete("/api/users/" + testUser.id)
      .expect(204)
      .then(function() {
        // Verify user is actually deleted
        return request(app)
          .get("/api/users/" + testUser.id)
          .expect(404);
      });
  });

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

  test("is idempotent", function() {
    return request(app)
      .delete("/api/users/" + testUser.id)
      .expect(204)
      .then(function() {
        return request(app)
          .delete("/api/users/" + testUser.id)
          .expect(404);
      });
  });
});

Testing Authentication

describe("Protected endpoints", function() {
  var token;

  beforeEach(function() {
    return db.clear("users").then(function() {
      return request(app)
        .post("/api/auth/register")
        .send({
          name: "Test User",
          email: "[email protected]",
          password: "securePassword123"
        });
    }).then(function() {
      return request(app)
        .post("/api/auth/login")
        .send({
          email: "[email protected]",
          password: "securePassword123"
        });
    }).then(function(res) {
      token = res.body.token;
    });
  });

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

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

  test("returns user profile with valid token", function() {
    return request(app)
      .get("/api/users/me")
      .set("Authorization", "Bearer " + token)
      .expect(200)
      .then(function(res) {
        expect(res.body.email).toBe("[email protected]");
        expect(res.body.password).toBeUndefined();
      });
  });

  test("returns 403 when user lacks permission", function() {
    return request(app)
      .delete("/api/admin/users/1")
      .set("Authorization", "Bearer " + token) // Regular user token
      .expect(403)
      .then(function(res) {
        expect(res.body.error).toContain("permission");
      });
  });
});

Testing File Uploads

var path = require("path");

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

  test("rejects files over size limit", function() {
    return request(app)
      .post("/api/upload")
      .attach("file", path.join(__dirname, "fixtures", "large-file.bin"))
      .expect(413);
  });

  test("rejects non-image files", function() {
    return request(app)
      .post("/api/upload")
      .attach("file", path.join(__dirname, "fixtures", "test.txt"))
      .expect(400)
      .then(function(res) {
        expect(res.body.error).toContain("image");
      });
  });
});

Testing Error Handling

describe("Error handling", function() {
  test("returns 404 for unknown routes", function() {
    return request(app)
      .get("/api/nonexistent")
      .expect(404)
      .then(function(res) {
        expect(res.body.error).toBe("Not found");
      });
  });

  test("returns 400 for malformed JSON", function() {
    return request(app)
      .post("/api/users")
      .set("Content-Type", "application/json")
      .send("{invalid json}")
      .expect(400);
  });

  test("returns 415 for unsupported content type", function() {
    return request(app)
      .post("/api/users")
      .set("Content-Type", "text/xml")
      .send("<user><name>Dave</name></user>")
      .expect(415);
  });

  test("handles server errors gracefully", function() {
    // Force a database error
    jest.spyOn(db, "findAll").mockRejectedValue(new Error("Connection lost"));

    return request(app)
      .get("/api/users")
      .expect(500)
      .then(function(res) {
        expect(res.body.error).toBe("Internal server error");
        // Should not expose internal error details
        expect(res.body.stack).toBeUndefined();
        db.findAll.mockRestore();
      });
  });
});

Testing Response Headers

describe("Response headers", function() {
  test("sets correct content type", function() {
    return request(app)
      .get("/api/users")
      .expect("Content-Type", /application\/json/);
  });

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

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

  test("sets no-cache for dynamic content", function() {
    return request(app)
      .get("/api/users")
      .expect("Cache-Control", /no-cache/);
  });

  test("OPTIONS returns allowed methods", function() {
    return request(app)
      .options("/api/users")
      .expect(204)
      .expect("Access-Control-Allow-Methods", /GET.*POST/);
  });
});

Test Organization

Shared Setup Helper

// test/helpers/setup.js
var db = require("../../src/db");

var testData = {};

function setupDatabase() {
  return db.connect(process.env.TEST_DATABASE_URL || "postgresql://test:test@localhost:5432/test");
}

function clearDatabase() {
  return db.query("TRUNCATE users, articles, comments RESTART IDENTITY CASCADE");
}

function seedUser(overrides) {
  var data = Object.assign({
    name: "Test User",
    email: "test-" + Date.now() + "@example.com",
    role: "user"
  }, overrides || {});

  return db.insert("users", data);
}

function seedArticle(userId, overrides) {
  var data = Object.assign({
    title: "Test Article",
    content: "Test content",
    authorId: userId,
    status: "published"
  }, overrides || {});

  return db.insert("articles", data);
}

function getAuthToken(app, credentials) {
  var request = require("supertest");

  return request(app)
    .post("/api/auth/login")
    .send(credentials)
    .then(function(res) {
      return res.body.token;
    });
}

module.exports = {
  setupDatabase: setupDatabase,
  clearDatabase: clearDatabase,
  seedUser: seedUser,
  seedArticle: seedArticle,
  getAuthToken: getAuthToken,
  testData: testData
};

Test File Structure

test/
  helpers/
    setup.js          # Database setup and seeding
    fixtures/         # Test files (images, CSVs, etc.)
      test-image.png
      test.csv
  api/
    users.test.js     # User endpoint tests
    articles.test.js  # Article endpoint tests
    auth.test.js      # Authentication tests
    upload.test.js    # File upload tests
  jest.config.js      # Test configuration

Jest Configuration

// test/jest.config.js
module.exports = {
  testMatch: ["**/test/**/*.test.js"],
  setupFilesAfterSetup: ["./test/helpers/setup.js"],
  testTimeout: 10000,
  maxWorkers: 1  // Run serially when using a shared database
};

Complete Working Example

// test/api/articles.test.js
var request = require("supertest");
var app = require("../../src/app");
var setup = require("../helpers/setup");

describe("Articles API", function() {
  var testUser, adminUser, userToken, adminToken;

  beforeAll(function() {
    return setup.setupDatabase();
  });

  beforeEach(function() {
    return setup.clearDatabase()
      .then(function() {
        return setup.seedUser({ name: "Author", email: "[email protected]", role: "user" });
      })
      .then(function(user) {
        testUser = user;
        return setup.seedUser({ name: "Admin", email: "[email protected]", role: "admin" });
      })
      .then(function(user) {
        adminUser = user;
        return setup.getAuthToken(app, { email: "[email protected]", password: "test" });
      })
      .then(function(token) {
        userToken = token;
        return setup.getAuthToken(app, { email: "[email protected]", password: "test" });
      })
      .then(function(token) {
        adminToken = token;
      });
  });

  describe("GET /api/articles", function() {
    beforeEach(function() {
      return Promise.all([
        setup.seedArticle(testUser.id, { title: "First Article", status: "published" }),
        setup.seedArticle(testUser.id, { title: "Second Article", status: "published" }),
        setup.seedArticle(testUser.id, { title: "Draft Article", status: "draft" })
      ]);
    });

    test("returns only published articles for anonymous users", function() {
      return request(app)
        .get("/api/articles")
        .expect(200)
        .then(function(res) {
          expect(res.body).toHaveLength(2);
          var statuses = res.body.map(function(a) { return a.status; });
          expect(statuses).not.toContain("draft");
        });
    });

    test("returns all articles for admin users", function() {
      return request(app)
        .get("/api/articles?includeDrafts=true")
        .set("Authorization", "Bearer " + adminToken)
        .expect(200)
        .then(function(res) {
          expect(res.body).toHaveLength(3);
        });
    });

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

  describe("POST /api/articles", function() {
    test("creates an article for authenticated users", function() {
      return request(app)
        .post("/api/articles")
        .set("Authorization", "Bearer " + userToken)
        .send({
          title: "New Article",
          content: "Article content here",
          status: "draft"
        })
        .expect(201)
        .then(function(res) {
          expect(res.body.title).toBe("New Article");
          expect(res.body.authorId).toBe(testUser.id);
        });
    });

    test("rejects unauthenticated article creation", function() {
      return request(app)
        .post("/api/articles")
        .send({ title: "New Article", content: "Content" })
        .expect(401);
    });

    test("validates required fields", function() {
      return request(app)
        .post("/api/articles")
        .set("Authorization", "Bearer " + userToken)
        .send({ content: "No title provided" })
        .expect(400)
        .then(function(res) {
          expect(res.body.error).toContain("title");
        });
    });
  });

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

    beforeEach(function() {
      return setup.seedArticle(testUser.id, { title: "To Delete" })
        .then(function(a) { article = a; });
    });

    test("author can delete own article", function() {
      return request(app)
        .delete("/api/articles/" + article.id)
        .set("Authorization", "Bearer " + userToken)
        .expect(204);
    });

    test("admin can delete any article", function() {
      return request(app)
        .delete("/api/articles/" + article.id)
        .set("Authorization", "Bearer " + adminToken)
        .expect(204);
    });

    test("other users cannot delete the article", function() {
      return setup.seedUser({ email: "[email protected]" })
        .then(function() {
          return setup.getAuthToken(app, { email: "[email protected]", password: "test" });
        })
        .then(function(otherToken) {
          return request(app)
            .delete("/api/articles/" + article.id)
            .set("Authorization", "Bearer " + otherToken)
            .expect(403);
        });
    });
  });
});

Common Issues and Troubleshooting

"listen EADDRINUSE" error when running tests

Your app calls app.listen() in the same file that exports the app:

Fix: Separate app.js (creates and exports the Express app) from server.js (calls app.listen()). Supertest does not need a listening server.

Tests pass individually but fail when run together

Database state leaks between tests:

Fix: Use beforeEach (not beforeAll) to reset the database. Run tests serially with --runInBand or maxWorkers: 1 when sharing a database. Use unique data per test to avoid collisions.

Async tests timeout instead of failing

A Promise is not returned or the done callback is not called:

Fix: Always return the Supertest chain from test functions. Use return request(app).get(...) not just request(app).get(...). Increase the Jest timeout with jest.setTimeout(10000) for slow integration tests.

Response body is empty or undefined

The response was not JSON or the content type header is wrong:

Fix: Ensure your Express routes set Content-Type: application/json. Use res.json() instead of res.send() for JSON responses. Check that body parsing middleware is configured.

Best Practices

  • Separate app creation from server startup. Export the Express app for testing. Call listen() in a separate entry point file.
  • Reset the database before each test. Use beforeEach with truncation or transaction rollback. Never assume the database is in a specific state.
  • Test the complete request/response cycle. Status codes, response body, headers, and content type. A test that only checks status 200 misses many bugs.
  • Test error cases more than success cases. Invalid input, missing authentication, permission denied, not found, conflict — these are where bugs hide.
  • Use helper functions for common setup. Token generation, user seeding, and database cleanup should be shared utilities, not duplicated in every test file.
  • Run API tests serially when sharing a database. Parallel tests against the same database cause unpredictable failures. Use --runInBand in Jest.
  • Assert on specific values, not just existence. expect(res.body.name).toBe("Alice") catches more bugs than expect(res.body.name).toBeDefined().
  • Keep tests independent. Each test should work regardless of the order it runs in or whether other tests pass or fail.

References

Powered by Contentful