← Back to DevBytes

Testing RESTify Components: From Unit to E2E Tests

Testing RESTify Components: From Unit to E2E Tests

Building a REST API with RESTify is fast and straightforward, but shipping it without a solid test suite is a gamble. As your application grows, manual testing through Postman or curl becomes unsustainable. This tutorial walks you through a complete testing strategy for RESTify applications — from isolated unit tests of individual handlers to full end-to-end tests that exercise your entire HTTP stack.

Why Testing RESTify Components Matters

RESTify encourages a modular architecture where routes, controllers, middleware, and services are separated. This separation is a perfect fit for layered testing. A robust test suite gives you the confidence to refactor, the ability to catch regressions before they reach production, and living documentation of how your API behaves under different inputs.

Without tests, small changes — like modifying a validation rule or adjusting a query — can silently break downstream consumers. With tests, you get immediate feedback and a safety net that scales with your codebase.

The Testing Pyramid for RESTify

Before diving into code, it helps to understand the three layers of testing we will cover:

We will use mocha as the test runner, chai for assertions, sinon for stubs and mocks, and supertest for HTTP-level testing. Install them as dev dependencies:

npm install --save-dev mocha chai chai-http sinon supertest

Setting Up a Sample RESTify Application

To make the examples concrete, let's build a small user management API. Create a file named app.js:

const restify = require('restify');
const { getUsers, getUserById, createUser } = require('./controllers/userController');

function createServer() {
  const server = restify.createServer({ name: 'user-api' });

  server.use(restify.plugins.bodyParser());
  server.use(restify.plugins.queryParser());

  server.get('/users', getUsers);
  server.get('/users/:id', getUserById);
  server.post('/users', createUser);

  return server;
}

module.exports = { createServer };

Notice that we export a createServer factory instead of starting the server directly. This pattern is essential for testing — it lets each test create a fresh server instance without binding to a port.

Unit Testing Controllers

Unit tests focus on the smallest pieces of logic. Let's look at a controller that depends on a service layer. Create controllers/userController.js:

const userService = require('../services/userService');

async function getUsers(req, res, next) {
  try {
    const users = await userService.findAll();
    res.send(200, users);
  } catch (err) {
    next(err);
  }
}

async function getUserById(req, res, next) {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) {
      return res.send(404, { error: 'User not found' });
    }
    res.send(200, user);
  } catch (err) {
    next(err);
  }
}

async function createUser(req, res, next) {
  try {
    const { name, email } = req.body;
    if (!name || !email) {
      return res.send(400, { error: 'name and email are required' });
    }
    const user = await userService.create({ name, email });
    res.send(201, user);
  } catch (err) {
    next(err);
  }
}

module.exports = { getUsers, getUserById, createUser };

Now let's write unit tests for this controller. We stub the service layer so the tests run fast and remain isolated from the database. Create test/unit/userController.test.js:

const chai = require('chai');
const sinon = require('sinon');
const { expect } = chai;
const userController = require('../../controllers/userController');
const userService = require('../../services/userService');

describe('userController', () => {
  let req, res, next;

  beforeEach(() => {
    req = { params: {}, body: {} };
    res = {
      send: sinon.spy(),
    };
    next = sinon.spy();
  });

  afterEach(() => {
    sinon.restore();
  });

  describe('getUsers', () => {
    it('should return 200 and a list of users', async () => {
      const fakeUsers = [
        { id: '1', name: 'Alice', email: 'alice@example.com' },
        { id: '2', name: 'Bob', email: 'bob@example.com' },
      ];
      sinon.stub(userService, 'findAll').resolves(fakeUsers);

      await userController.getUsers(req, res, next);

      expect(res.send.calledOnce).to.be.true;
      expect(res.send.firstCall.args[0]).to.equal(200);
      expect(res.send.firstCall.args[1]).to.deep.equal(fakeUsers);
      expect(next.called).to.be.false;
    });

    it('should call next with error when service throws', async () => {
      const error = new Error('Database connection failed');
      sinon.stub(userService, 'findAll').rejects(error);

      await userController.getUsers(req, res, next);

      expect(next.calledOnceWith(error)).to.be.true;
      expect(res.send.called).to.be.false;
    });
  });

  describe('getUserById', () => {
    it('should return 404 when user does not exist', async () => {
      req.params.id = '999';
      sinon.stub(userService, 'findById').resolves(null);

      await userController.getUserById(req, res, next);

      expect(res.send.firstCall.args[0]).to.equal(404);
      expect(res.send.firstCall.args[1].error).to.equal('User not found');
    });

    it('should return 200 and the user when found', async () => {
      req.params.id = '1';
      const fakeUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
      sinon.stub(userService, 'findById').resolves(fakeUser);

      await userController.getUserById(req, res, next);

      expect(res.send.firstCall.args[0]).to.equal(200);
      expect(res.send.firstCall.args[1]).to.deep.equal(fakeUser);
    });
  });

  describe('createUser', () => {
    it('should return 400 when name is missing', async () => {
      req.body = { email: 'alice@example.com' };

      await userController.createUser(req, res, next);

      expect(res.send.firstCall.args[0]).to.equal(400);
      expect(userService.create.called).to.be.false;
    });

    it('should return 201 and the created user', async () => {
      req.body = { name: 'Alice', email: 'alice@example.com' };
      const createdUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
      sinon.stub(userService, 'create').resolves(createdUser);

      await userController.createUser(req, res, next);

      expect(res.send.firstCall.args[0]).to.equal(201);
      expect(res.send.firstCall.args[1]).to.deep.equal(createdUser);
    });
  });
});

These tests verify the controller's branching logic — success paths, validation failures, and error propagation — without touching the database or starting a server. They run in milliseconds.

Unit Testing Middleware

Middleware functions are another great candidate for unit testing. Suppose you have an authentication middleware in middleware/auth.js:

const jwt = require('jsonwebtoken');
const config = require('../config');

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.send(401, { error: 'Missing or invalid authorization header' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = jwt.verify(token, config.jwtSecret);
    req.user = payload;
    next();
  } catch (err) {
    return res.send(401, { error: 'Invalid token' });
  }
}

module.exports = { authenticate };

The unit test stubs jwt.verify to avoid depending on real tokens:

const chai = require('chai');
const sinon = require('sinon');
const { expect } = chai;
const { authenticate } = require('../../middleware/auth');
const jwt = require('jsonwebtoken');

describe('authenticate middleware', () => {
  let req, res, next;

  beforeEach(() => {
    req = { headers: {} };
    res = { send: sinon.spy() };
    next = sinon.spy();
  });

  afterEach(() => {
    sinon.restore();
  });

  it('should return 401 when no authorization header is present', () => {
    authenticate(req, res, next);
    expect(res.send.firstCall.args[0]).to.equal(401);
    expect(next.called).to.be.false;
  });

  it('should return 401 when token is invalid', () => {
    req.headers.authorization = 'Bearer invalidtoken';
    sinon.stub(jwt, 'verify').throws(new Error('jwt malformed'));

    authenticate(req, res, next);
    expect(res.send.firstCall.args[0]).to.equal(401);
    expect(next.called).to.be.false;
  });

  it('should call next and attach user payload when token is valid', () => {
    req.headers.authorization = 'Bearer validtoken';
    const payload = { id: '1', role: 'admin' };
    sinon.stub(jwt, 'verify').returns(payload);

    authenticate(req, res, next);
    expect(req.user).to.deep.equal(payload);
    expect(next.calledOnce).to.be.true;
    expect(res.send.called).to.be.false;
  });
});

Integration Testing with a Real Server

Integration tests verify that your controllers, middleware, and routing work together correctly. We use supertest to make real HTTP requests against a RESTify server instance. Create test/integration/users.test.js:

const chai = require('chai');
const request = require('supertest');
const { expect } = chai;
const { createServer } = require('../../app');
const userService = require('../../services/userService');
const sinon = require('sinon');

describe('Users API (integration)', () => {
  let server;

  beforeEach(() => {
    server = createServer();
  });

  afterEach(() => {
    sinon.restore();
    server.close();
  });

  describe('GET /users', () => {
    it('should return a JSON array of users', async () => {
      const fakeUsers = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
      sinon.stub(userService, 'findAll').resolves(fakeUsers);

      const res = await request(server).get('/users');

      expect(res.status).to.equal(200);
      expect(res.body).to.be.an('array');
      expect(res.body).to.have.lengthOf(1);
      expect(res.body[0].name).to.equal('Alice');
    });
  });

  describe('GET /users/:id', () => {
    it('should return 404 for a non-existent user', async () => {
      sinon.stub(userService, 'findById').resolves(null);

      const res = await request(server).get('/users/999');

      expect(res.status).to.equal(404);
      expect(res.body.error).to.equal('User not found');
    });

    it('should return the user when found', async () => {
      sinon.stub(userService, 'findById').resolves({
        id: '1', name: 'Alice', email: 'alice@example.com'
      });

      const res = await request(server).get('/users/1');

      expect(res.status).to.equal(200);
      expect(res.body.id).to.equal('1');
    });
  });

  describe('POST /users', () => {
    it('should return 400 when required fields are missing', async () => {
      const res = await request(server)
        .post('/users')
        .send({ name: 'Alice' })
        .set('Content-Type', 'application/json');

      expect(res.status).to.equal(400);
      expect(res.body.error).to.include('required');
    });

    it('should create a user and return 201', async () => {
      sinon.stub(userService, 'create').resolves({
        id: '1', name: 'Alice', email: 'alice@example.com'
      });

      const res = await request(server)
        .post('/users')
        .send({ name: 'Alice', email: 'alice@example.com' })
        .set('Content-Type', 'application/json');

      expect(res.status).to.equal(201);
      expect(res.body.name).to.equal('Alice');
    });
  });
});

Notice that supertest accepts the RESTify server object directly — no need to call server.listen(). Supertest handles the underlying HTTP communication for you.

End-to-End Testing

E2E tests treat the application as a black box. They start the real server, hit real endpoints, and often interact with a real (but disposable) database. This is where you catch issues that unit and integration tests miss — things like middleware ordering, serialization quirks, and database schema mismatches.

For E2E tests, it's best to use a separate test database. Here's an example using MongoDB with mongodb-memory-server for an in-memory database:

npm install --save-dev mongodb-memory-server

Create test/e2e/setup.js to manage the database lifecycle:

const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

before(async () => {
  mongoServer = await MongoMemoryServer.create();
  const uri = mongoServer.getUri();
  await mongoose.connect(uri);
});

after(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

afterEach(async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    await collections[key].deleteMany({});
  }
});

Now write the E2E test in test/e2e/userFlow.test.js. This time we do not stub the service — we let the request flow all the way through to the database:

const chai = require('chai');
const request = require('supertest');
const { expect } = chai;
const { createServer } = require('../../app');
const User = require('../../models/User');

describe('User flow (E2E)', () => {
  let server;

  before(() => {
    server = createServer();
    server.listen(0); // bind to a random available port
  });

  after(() => {
    server.close();
  });

  it('should create a user, retrieve it, and list it', async () => {
    // Step 1: Create the user
    const createRes = await request(server)
      .post('/users')
      .send({ name: 'Alice', email: 'alice@example.com' })
      .set('Content-Type', 'application/json');

    expect(createRes.status).to.equal(201);
    expect(createRes.body).to.have.property('id');
    const userId = createRes.body.id;

    // Step 2: Retrieve the user by ID
    const getRes = await request(server).get(`/users/${userId}`);
    expect(getRes.status).to.equal(200);
    expect(getRes.body.name).to.equal('Alice');
    expect(getRes.body.email).to.equal('alice@example.com');

    // Step 3: List all users
    const listRes = await request(server).get('/users');
    expect(listRes.status).to.equal(200);
    expect(listRes.body).to.have.lengthOf(1);
    expect(listRes.body[0].id).to.equal(userId);
  });

  it('should return 404 when fetching a deleted user', async () => {
    const user = await User.create({ name: 'Bob', email: 'bob@example.com' });
    await User.findByIdAndDelete(user._id);

    const res = await request(server).get(`/users/${user._id}`);
    expect(res.status).to.equal(404);
  });
});

This test exercises the full request lifecycle: HTTP parsing, routing, controller logic, service layer, and database persistence. It catches integration bugs that stubs would hide.

Testing Error Handling

RESTify lets you register a custom error handler. Testing it ensures your API returns consistent error responses. Here's a typical setup in app.js:

server.on('restifyError', (req, res, err, callback) => {
  const status = err.statusCode || 500;
  res.send(status, {
    error: err.name || 'InternalServerError',
    message: err.message || 'An unexpected error occurred',
  });
  return callback();
});

Test it by forcing an error in a route:

it('should format errors consistently', async () => {
  sinon.stub(userService, 'findAll').rejects(
    Object.assign(new Error('Connection timeout'), { statusCode: 503 })
  );

  const res = await request(server).get('/users');

  expect(res.status).to.equal(503);
  expect(res.body).to.have.property('error');
  expect(res.body).to.have.property('message');
  expect(res.body.message).to.equal('Connection timeout');
});

Best Practices

Organizing Your Test Suite

A clean directory structure keeps tests maintainable as the project grows:

project/
├── controllers/
├── middleware/
├── services/
├── models/
├── app.js
└── test/
    ├── unit/
    │   ├── userController.test.js
    │   └── auth.test.js
    ├── integration/
    │   └── users.test.js
    └── e2e/
        ├── setup.js
        └── userFlow.test.js

Configure mocha to run each layer separately or all together in your package.json:

{
  "scripts": {
    "test": "mocha 'test/**/*.test.js'",
    "test:unit": "mocha 'test/unit/**/*.test.js'",
    "test:integration": "mocha 'test/integration/**/*.test.js'",
    "test:e2e": "mocha 'test/e2e/**/*.test.js'"
  }
}

Conclusion

Testing RESTify components doesn't require exotic tools — just a clear strategy and the right layering. Unit tests give you fast, precise feedback on individual functions. Integration tests confirm that your routes, middleware, and controllers cooperate correctly. E2E tests provide confidence that the entire system behaves as expected from the client's perspective. By exporting a server factory, mocking at the boundaries, and keeping your test pyramid balanced, you can build a RESTify API that is safe to refactor and pleasant to maintain. Start with unit tests for your controllers and middleware, add integration tests for your routes, and sprinkle in E2E tests for your most critical flows — your future self will thank you.

— Ad —

Google AdSense will appear here after approval

← Back to all articles