You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Jest和Supertest测试Next.js API:POST路由请求对象异常

Next.js + Jose JWT认证API路由Jest测试问题

问题描述

我在用Jest测试基于Next.js和Jose库实现的JWT认证POST API路由时遇到问题:接口在浏览器和Postman中运行正常,但Jest环境下无法获取正确的request对象,调用request.json()抛出TypeError,且无法识别响应对象以校验body、状态码和请求头,测试超时失败。改用http库创建服务器测试,结果依旧。

相关代码

route.js API路由文件

import { SignJWT } from "jose";
import { NextResponse } from "next/server";
import { getJwtSecretKey } from "@/libs/auth";

export async function POST(request) {
    try {
        const body = await request.json();
        if (body.username === "test-name" && body.password === "password1234") {
            const token = await new SignJWT({
                username: body.username,
            })
                .setProtectedHeader({ alg: "HS256" })
                .setIssuedAt()
                .setExpirationTime("30s")
                .sign(getJwtSecretKey());
            const response = NextResponse.json(
                { success: true },
                { status: 200, headers: { "content-type": "application/json" } }
            );
            response.cookies.set({
                name: "token",
                value: token,
                path: "/",
            });
            // Temporary stub for user profile
            response.cookies.set({
                name: "user-name",
                value: body.username,
                path: "/api",
            });
            return response;
        }
        return NextResponse.json(
            { success: false },
            { status: 401, headers: { "content-type": "application/json" } }
        );
    } catch (error) {
        return console.error(error)
    }
}

初始测试文件

import request from 'supertest';
import { POST } from './route';
import { getJwtSecretKey } from '../../../libs/auth';
import * as Jose from "jose";

jest.mock('../../../libs/auth', () => ({
    getJwtSecretKey: jest.fn(),
}));

jest.mock('next/dist/server/web/exports/next-response.js', () => ({ Request: jest.fn() }));

jest.mock('jose', () => ({
    SignJWT: jest.fn(() => Jose.SignJWT)
}));

describe('POST API Endpoint', () => {
    it('should return a success response with a token for valid credentials', async () => {
        jest.useFakeTimers('legacy');
        getJwtSecretKey.mockReturnValue('your-secret-key');

        const validCredentials = {
            username: 'test-name',
            password: 'password1234',
        };
 
        const response = await request(POST)
            .post('/api/login')
            .set('Content-Type', 'application/json')
            .send(JSON.stringify(validCredentials));
        ;
        expect(response.status).toBe(200);
        expect(response.body).toEqual({ success: true });
        expect(response.headers['content-type']).toBe('application/json');
        expect(response.header['set-cookie']).toContain('token=');
    });
    // Other test cases here
});

测试结果

console.error
    TypeError: request.json is not a function
        at Server.json (C:\Users\smiss-dev\projects\auth-jwt\src\app\api\login\route.js:7:36)
        at Server.emit (node:events:517:28)
        at parserOnIncoming (node:_http_server:1107:12)
        at HTTPParser.parserOnHeadersComplete (node:_http_common:119:17)

      33 |         return NextResponse.json({ success: false });
      34 |     } catch (error) {
    > 35 |         console.error(error)
         |                 ^
      36 |     }
      37 | }

      at Server.error (src/app/api/login/route.js:35:17)

 FAIL  src/app/api/login/route.test.js (5.487 s)
  POST API Endpoint
    × should return a success response with a token for valid credentials (5002 ms)

  ● POST API Endpoint › should return a success response with a token for valid credentials

    thrown: "Exceeded timeout of 5000 ms for a test.
    Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

      15 |
      16 | describe('POST API Endpoint', () => {
    > 17 |     it('should return a success response with a token for valid credentials', async () => {
         |     ^
      18 |         jest.useFakeTimers('legacy');
      19 |         getJwtSecretKey.mockReturnValue('your-secret-key');
      20 |

      at it (src/app/api/login/route.test.js:17:5)
      at Object.describe (src/app/api/login/route.test.js:16:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        5.911 s, estimated 6 s
Ran all test suites matching /route/i.
Jest did not exit one second after the test run has completed.

改用http库的测试代码

import request from 'supertest';
import { POST } from './route';
import { getJwtSecretKey } from '../../../libs/auth';
import { createServer } from "http";
import * as Jose from "jose";

jest.mock('../../../libs/auth', () => ({
    getJwtSecretKey: jest.fn(),
}));

jest.mock('next/dist/server/web/exports/next-response.js', () => ({ Request: jest.fn() }));

jest.mock('jose', () => ({
    SignJWT: jest.fn(() => Jose.SignJWT)
}));

describe('POST API Endpoint', () => {
    let server;

    beforeAll(() => {
        server = createServer(POST);
        server.listen();
    });

    afterAll((done) => {
        server.close(done);
    });
    it('should return a success response with a token for valid credentials', async () => {
        jest.useFakeTimers('legacy');
        getJwtSecretKey.mockReturnValue('your-secret-key');

        const validCredentials = {
            username: 'test-name',
            password: 'password1234',
        };

        const response = await request(server)
            .post('/api/login')
            .set('Content-Type', 'application/json')
            .send(JSON.stringify(validCredentials));
        ;
        expect(response.status).toBe(200);
        expect(response.body).toEqual({ success: true });
        expect(response.headers['content-type']).toBe('application/json');
        expect(response.header['set-cookie']).toContain('token=');
    });
    // Other tests here
}); 

jest.config.js

const nextJest = require('next/jest')

const createJestConfig = nextJest({
    // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
    dir: './',
})

const customJestConfig = {
    setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
    testEnvironment: 'jest-environment-jsdom',
}

module.exports = createJestConfig(customJestConfig)

问题分析

  1. Request对象类型不匹配:Next.js App Router的API路由接收的是NextRequest对象,而非Node.js原生HTTP请求对象。直接将POST函数传给supertest或http.createServer,会传入原生req/res,没有NextRequest的json()方法,导致报错。
  2. Mock配置错误:错误mock了NextResponse,替换成仅含Request的对象,导致NextResponse.json无法正常工作;Jose的mock方式破坏了链式调用逻辑。
  3. Catch块无响应返回:route.js的catch块仅打印错误但不返回响应,导致请求挂起,触发测试超时。
  4. 测试环境不匹配:jest.config.js使用jest-environment-jsdom(浏览器环境),但API路由属于服务器端代码,应使用jest-environment-node。

解决方案

1. 修复route.js的catch块

确保错误时返回合法响应,避免请求挂起:

// ... 其他代码
catch (error) {
  console.error(error);
  return NextResponse.json(
    { success: false, error: '服务器内部错误' },
    { status: 500, headers: { "content-type": "application/json" } }
  );
}

2. 修改jest.config.js的测试环境

const nextJest = require('next/jest')

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-node', // 改为node环境
}

module.exports = createJestConfig(customJestConfig)

3. 编写正确的测试用例(直接测试POST函数,模拟NextRequest)

import { POST } from './route';
import { getJwtSecretKey } from '../../../libs/auth';
import { NextRequest } from 'next/server';

jest.mock('../../../libs/auth', () => ({
  getJwtSecretKey: jest.fn(),
}));

// 正确mock Jose的SignJWT,保留链式调用
jest.mock('jose', () => {
  const original = jest.requireActual('jose');
  return {
    ...original,
    SignJWT: jest.fn(() => ({
      setProtectedHeader: jest.fn().mockReturnThis(),
      setIssuedAt: jest.fn().mockReturnThis(),
      setExpirationTime: jest.fn().mockReturnThis(),
      sign: jest.fn().mockResolvedValue('mocked-jwt-token'),
    })),
  };
});

describe('POST /api/login', () => {
  it('有效凭证返回200和token', async () => {
    getJwtSecretKey.mockReturnValue('test-secret-key');

    // 模拟NextRequest对象
    const mockRequest = new NextRequest('http://localhost/api/login', {
      method: 'POST',
      body: JSON.stringify({
        username: 'test-name',
        password: 'password1234',
      }),
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const response = await POST(mockRequest);

    // 校验响应状态和body
    expect(response.status).toBe(200);
    const responseBody = await response.json();
    expect(responseBody).toEqual({ success: true });
    expect(response.headers.get('content-type')).toBe('application/json');

    // 校验Cookie
    const cookies = response.cookies.getAll();
    expect(cookies.some(c => c.name === 'token' && c.value === 'mocked-jwt-token')).toBe(true);
    expect(cookies.some(c => c.name === 'user-name' && c.value === 'test-name')).toBe(true);
  });

  it('无效凭证返回401', async () => {
    const mockRequest = new NextRequest('http://localhost/api/login', {
      method: 'POST',
      body: JSON.stringify({
        username: 'wrong-user',
        password: 'wrong-pass',
      }),
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const response = await POST(mockRequest);
    const responseBody = await response.json();
    
    expect(response.status).toBe(401);
    expect(responseBody).toEqual({ success: false });
  });
});

说明

这种方式直接测试API路由的POST函数,通过模拟NextRequest对象避免了与原生HTTP服务器的兼容问题,同时正确mock依赖库,确保测试逻辑独立可靠。

内容的提问来源于stack exchange,提问作者alex-bbk

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 21:48:11