Implementing unit tests for React hooks that interact with a back-end can be challenging, especially when the back-end server is built with NextJS. As of Spring 2025, Copilot does not provide assistance for this task, so developers must rely on their own expertise. We hope that the insights shared in this document will save others in similar situations from spending excessive time searching through resources and attempting to integrate various libraries.
Initial state:
- A NextJS application where the NextJS server also is the application’s back-end server.
- A React hook to be tested that heavily interacts with the back-end server.
- Node test runner used as testing framework.
Custom server.ts
In our case the server.ts
file is customized to accommodate specific back-end logic and provide global data structures shared among Next.js server pages and route handlers.
In development mode, the server is run using the command:
"scripts": {
...
"dev": "cross-env NODE_ENV=development tsx watch --conditions=typescript --tsconfig tsconfig.server.json ./server.ts",
...
}
In production mode, the server is built and then executed as a JavaScript script using bare Node.js:
"build": "cross-env NODE_ENV=production next build && tsc --project tsconfig.server.json",
"start": "cross-env NODE_ENV=production node dist/server.js",
To test React hooks outside the browser environment, install the following libraries as development dependencies:
pnpm add @testing-library/dom @testing-library/react global-jsdom jsdom -D
Writing a unit test
For unit testing, a separate instance of the server should be launched before the test begins. The React hook is then tested, and upon completion of the test, the server instance is shut down.
Running node directly appears to be the main issue. Problems were encountered when running the server through pnpm run
or tsx
; the node test runner would hang at the end until Ctrl-C
was manually pressed in the terminal window.
Here are functions to start and safely shut down the server as a child process:
// Server setup
let serverProcess: ChildProcess | null = null;
/**
* Start the backend server before tests
*/
async function startServer(): Promise<void> {
return new Promise((resolve, reject) => {
console.log('Starting server...');
serverProcess = spawn('node', ['dist/server.js'], {
cwd: process.cwd(),
stdio: 'pipe',
shell: false,
env: {
...process.env,
NODE_ENV: 'production'
}
});
let serverReady = false;
const timeout = setTimeout(() => {
if (!serverReady) {
reject(new Error('Server failed to start within timeout'));
}
}, 30_000);
if (serverProcess.stdout) {
serverProcess.stdout.on('data', (data) => {
const output = data.toString();
console.log('Server stdout:', output);
// Look for server ready indicator
if (output.includes('Server is running on')) {
if (!serverReady) {
serverReady = true;
clearTimeout(timeout);
// Small delay to ensure server is fully ready
setTimeout(resolve, 2_000);
}
}
});
}
if (serverProcess.stderr) {
serverProcess.stderr.on('data', (data) => {
console.log('Server stderr:', data.toString());
});
}
serverProcess.on('error', (error) => {
console.error('Server process error:', error);
clearTimeout(timeout);
reject(error);
});
serverProcess.on('exit', (code) => {
console.log('Server process exited with code:', code);
clearTimeout(timeout);
if (!serverReady) {
reject(new Error(`Server exited with code ${code}`));
}
});
});
}
/**
* Stop the backend server after tests
*/
async function stopServer(): Promise<void> {
return new Promise((resolve) => {
console.log('Cleaning up test environment...');
if (serverProcess) {
console.log('Stopping server...');
// Set up exit handler before killing
const exitHandler = () => {
console.log('Server stopped');
serverProcess = null;
resolve();
};
serverProcess.once('exit', exitHandler);
// Try graceful shutdown first
console.log('Sending SIGTERM to server...');
serverProcess.kill('SIGTERM');
// Force kill after timeout
setTimeout(() => {
if (serverProcess && !serverProcess.killed) {
console.log('Force killing server...');
serverProcess.kill('SIGKILL');
// Give it a moment to clean up
setTimeout(() => {
if (serverProcess) {
serverProcess = null;
}
resolve();
}, 1_000);
}
}, 3_000);
} else {
resolve();
}
});
}
The unit test is as follows:
/**
* Wait for server to be ready by making health check requests
*/
async function waitForServer(): Promise<void> {
try {
const response = await fetch(`http://localhost:${process.env.PORT}`);
if (response.ok) {
console.log('Server is ready!');
} else {
throw new Error('Server failed to become ready');
}
} catch (error) {
// Server not ready yet
throw new Error('Server failed to become ready');
}
};
describe('useObject Hook Tests', async () => {
before(async () => {
await startServer();
});
after(async () => {
await stopServer();
});
describe('Test Server is Running', async () => {
it('should ensure the test environment is set up', async () => {
assert.ok(serverProcess, 'Server process should be running');
await waitForServer();
assert.strictEqual(serverProcess?.killed, false, 'Server process should not be killed');
});
});
describe('Test Entity', () => {
describe('Data Loading', () => {
it('should load data successfully when entityParam is provided', async () => {
const { result } = renderHook(() =>
useObject({
entityParam: EntityName.Test
})
);
// Initially should be in loading state
assert.strictEqual(result.current.state, 'loading');
assert.strictEqual(result.current.data.length, 0);
assert.strictEqual(result.current.object, null);
// Wait for data to load
await waitFor(() => {
assert.strictEqual(result.current.state, 'success');
}, { timeout: 10_000 });
// Verify final state
assert.strictEqual(result.current.state, 'success');
assert(Array.isArray(result.current.data));
assert(result.current.entity);
assert.strictEqual(result.current.entity.name, 'Test');
});
});
});
});
To conduct the test, it is necessary to initialize a simulated DOM beforehand. This is achieved by passing the –import
switch to the tsx utility. The command required is as follows (adjust for the actual .env location in your case):
"test": "pnpm run build && tsx --enable-source-maps --import=global-jsdom/register --test --env-file ../../.env.local"
All sources are compiled in this gist for your convenience.
Top comments (0)