Introduction
In our last lesson, you successfully set up Jest and Supertest, refactored the application for testability, and wrote your first integration test using request(app). You might have noticed that the test process started, ran, and exited cleanly without any extra configuration. This is because Supertest, when given an app object, cleverly handles the server startup and shutdown for you behind the scenes.
While convenient, this implicit behavior can be a problem in more complex setups. If a server isn't closed properly after tests, it leaves behind an "open handle"—a running process that prevents Jest from exiting cleanly. This is a common pitfall that can break automated testing workflows.
In this lesson, you will learn the explicit and professional pattern for managing the server lifecycle. We will use Jest's built-in beforeAll and afterAll hooks to guarantee that our test server starts exactly once before our tests run and shuts down cleanly afterward. This will ensure your test suite is robust, efficient, and free of resource leaks, a critical skill for production-level engineering and your exam.
1. The "Open Handles" Problem
When you run tests, especially with the --detectOpenHandles flag, Jest scans for any processes that haven't been terminated. An Express server listening on a port is a prime example. If it's still running after the tests complete, Jest will report it and may hang.

To prevent this, we must ensure our server is explicitly closed.
2. Using beforeAll and afterAll for Server Lifecycle Management
Jest provides "hooks" that allow you to run setup and teardown code. For managing a server, the two most important are:
beforeAll(fn): Runs a function once before any of the tests in a file execute. This is the perfect place to start our server.afterAll(fn): Runs a function once after all of the tests in a file have completed. This is where we will close our server.
This approach is more explicit and gives you complete control over the test environment.
3. Implementing Explicit Server Control
Let's refactor your productRoutes.test.js file to incorporate this pattern. You will need to make a few key changes to how the server is created and how Supertest makes requests.
- Import Node's
httpmodule: We need it to create a server instance from ourappobject. - Declare a
servervariable: This will hold our server instance so we can access it in bothbeforeAllandafterAll. - Implement
beforeAll: This hook will create the server usinghttp.createServer(app)and start it. We'll use thedonecallback to signal when the asynchronouslistenoperation is complete. - Implement
afterAll: This hook will callserver.close(), again usingdoneto handle the asynchronous shutdown. - Update
request()calls: Instead of passingappto Supertest, you will now pass the runningserverinstance.
Here is the updated structure for your src/routes/productRoutes.test.js file. Apply these changes to your code.
const request = require('supertest');
const http = require('http'); // 1. Import http
const app = require('../../app');
const Product = require('../models/productModel');
jest.mock('../models/productModel');
let server; // 2. Declare server variable
// 3. Start server before all tests
beforeAll((done) => {
server = http.createServer(app);
server.listen(done);
});
// 4. Close server after all tests
afterAll((done) => {
server.close(done);
});
describe('Product API', () => {
describe('GET /api/products', () => {
it('should return all products', async () => {
const mockProducts = [
{ _id: '1', name: 'Product 1', price: 100 },
{ _id: '2', name: 'Product 2', price: 200 },
];
Product.find.mockResolvedValue(mockProducts);
// 5. Update request to use the server instance
const res = await request(server).get('/api/products');
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual(mockProducts);
expect(Product.find).toHaveBeenCalledTimes(1);
});
});
// ... your other tests will go here
});
This pattern ensures a clean, isolated test run for each test file. The server starts once, all tests in the file run against it, and then it shuts down.
Unit Testing in NodeJS with Express, TypeScript, Jest and Supertest
The article 'Unit Testing in NodeJS' demonstrates this exact pattern of managing a test server's lifecycle. It's a great resource for seeing this technique in a slightly different context.
First, read the section 'Writing Unit Tests for Routes'. Notice how it emphasizes the need to isolate a testing server. It creates a test_setup.ts file to start and export the server. Then, look at the code block in the next section, which shows how test_server.close() is called within the afterAll hook in userRoutes.test.ts. This reinforces the teardown pattern you just implemented.
By passing the done callback to server.listen(done) and server.close(done), you are telling Jest to wait until these asynchronous operations are finished before moving on. This prevents race conditions where tests might run before the server is ready or where the test process exits before the server has fully shut down.
Run npm test again. Your test should pass just as before, but now you have a more robust and explicit setup that you are in full control of.
Conclusion
You have now mastered a fundamental skill for building professional-grade test suites: explicit lifecycle management. While Supertest's implicit handling is useful for quick tests, using beforeAll and afterAll provides the clarity and control needed for reliable automated testing at scale.
Key Takeaways:
- Forgetting to close a server leads to "open handles," which can cause Jest to hang.
- The
beforeAllhook is used to perform setup once per test file, like starting a server. - The
afterAllhook is used for teardown once per test file, ensuring resources like servers are cleanly closed. - When managing the server manually, you must pass the
serverinstance torequest(), not theappobject. - Using the
donecallback inbeforeAllandafterAllis essential for handling asynchronous setup and teardown operations.
Next Up:
With a solid setup for both mocking and server management in place, you are now ready to build out the full test suite for your API. In the next lesson, we will systematically write integration tests for all the remaining CRUD operations (POST, GET by ID, PUT, DELETE), focusing on both success cases and critical error handling scenarios like validation failures and "not found" errors.