DEV Community

Cover image for Unit Testing React Hooks with NextJS Back-End: Challenges and Solutions
Andrej Kirejeŭ
Andrej Kirejeŭ

Posted on

Unit Testing React Hooks with NextJS Back-End: Challenges and Solutions

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:

  1. A NextJS application where the NextJS server also is the application’s back-end server.
  2. A React hook to be tested that heavily interacts with the back-end server.
  3. 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",
  ...
}
Enter fullscreen mode Exit fullscreen mode

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",
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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');
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

All sources are compiled in this gist for your convenience.

Top comments (0)