使用Jest+Supertest测试Express+TypeScript时出现listen读取错误
问题:运行Jest与Supertest时,测试用例执行前触发app未定义错误
启动脚本运行服务器一切正常,但运行测试脚本时app为undefined,导致测试还没执行就报错。
背景
- 首次使用TypeScript和测试工具
- 参考教程分离服务器实例,计划在不同文件中运行多组测试
错误信息
FAIL src/components/users/user.test.ts ● Test suite failed to run TypeError: Cannot read properties of undefined (reading 'listen') 6 | 7 | dbConnection(); > 8 | export const server = app.listen(config.server.port, () => { | ^ 9 | logger.info(`Server is running on port: ${config.server.port}`); 10 | }); 11 | at Object.<anonymous> (src/index.ts:8:27) at Object.<anonymous> (src/library/exitHandler/exitHandler.ts:2:1) at Object.<anonymous> (src/library/errorHandler/errorHandler.ts:2:1) at Object.<anonymous> (src/middleware/validateSchema.ts:3:1) at Object.<anonymous> (src/components/users/routes.ts:2:1) at Object.<anonymous> (src/server.ts:2:1) at Object.<anonymous> (src/components/users/user.test.ts:2:1)
相关代码
user.test.ts(用户测试文件)
import request from 'supertest'; import app from '../../server'; describe('User registration', () => { it('POST /register --> return new user instance', async () => { await request(app) // 触发错误的位置 .post('/user/register') .send({ firstName: 'Thomas', lastName: 'Haek', email: 'thomashaek@gmail.com', password: '12345678aA', confirmPassword: '12345678aA' }) .expect(201) .then((response) => { expect(response.body).toEqual( expect.objectContaining({ _id: expect.any(String), firstName: expect.any(String), lastName: expect.any(String), email: expect.any(String), token: expect.any(String) }) ); }); }); });
server.ts(服务器文件)
import express, { Application } from 'express'; import userRouter from './components/users/routes'; import { routeErrorHandler } from './middleware/errorHandler'; import httpLogger from './middleware/httpLogger'; import './process'; const app: Application = express(); app.use(httpLogger); app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use('/user', userRouter); app.use(routeErrorHandler); export default app
index.ts(入口文件)
import { createHttpTerminator } from 'http-terminator'; import config from './config/config'; import dbConnection from './config/dbConnection'; import logger from './library/logger'; import app from './server' dbConnection(); export const server = app.listen(config.server.port, () => { logger.info(`Server is running on port: ${config.server.port}`); }); export const httpTerminator = createHttpTerminator({ server });
package.json脚本配置
"scripts": { "test": "env-cmd -f ./src/config/test.env jest --watchAll", "start": "env-cmd -f ./src/config/dev.env node build/index.js", },
tsconfig.json
{ "compilerOptions": { "outDir": "./build", "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "module": "commonjs", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "target": "es6", "noImplicitAny": true, "moduleResolution": "node", "sourceMap": true, }, "include": ["src/**/*"] }
jest.config.ts
import { Config } from 'jest'; /** @type {import('ts-jest').JestConfigWithTsJest} */ const config: Config = { preset: 'ts-jest', testEnvironment: 'node', roots: ['./src'], moduleFileExtensions: ['js', 'ts'], clearMocks: true, collectCoverage: true, coverageDirectory: 'coverage', coveragePathIgnorePatterns: ['/node_modules/', '/src/config/'], coverageProvider: 'v8', coverageReporters: ['json', 'text', 'lcov', 'clover'], verbose: true }; export default config;
exitHandler.ts(退出处理文件)
import mongoose from 'mongoose'; import { httpTerminator, server } from '../..'; import logger from '../logger'; class ExitHandler { public async handleExit(code: number, timeout = 5000): Promise<void> { try { logger.info(`Attempting graceful shutdown with code: ${code}`); setTimeout(() => { logger.info(`Forcing a shutdown with code: ${code}`); process.exit(code); }, timeout).unref(); if (server.listening) { logger.info('Terminating HTTP connections'); await httpTerminator.terminate(); await mongoose.connection.close(); } logger.info(`Exiting gracefully with code ${code}`); process.exit(code); } catch (error) { logger.error(error); logger.error( `Unable to shutdown gracefully... Forcing exit with code: ${code}` ); process.exit(code); } } } export const exitHandler = new ExitHandler();
已尝试方案
- 测试与启动脚本使用相同环境文件,无效
- 调整tsconfig和jest配置,无效
- 改用
module.exports = app替代export default app或export const server = app,无效 - 注释所有中间件和路由,仅导出app,无效
解决方案
问题核心是循环依赖:exitHandler.ts导入了index.ts的server,index.ts依赖server.ts,而server.ts关联的errorHandler.ts又导入了exitHandler.ts,导致模块加载时app未初始化就被调用listen方法。
修复步骤:
拆分服务器启动逻辑:仅在非测试环境启动服务器,避免测试时触发启动代码
修改index.ts:import { createHttpTerminator } from 'http-terminator'; import config from './config/config'; import dbConnection from './config/dbConnection'; import logger from './library/logger'; import app from './server' dbConnection(); let server: any = null; let httpTerminator: any = null; // 仅在非测试环境启动服务器 if (process.env.NODE_ENV !== 'test') { server = app.listen(config.server.port, () => { logger.info(`Server is running on port: ${config.server.port}`); }); httpTerminator = createHttpTerminator({ server }); } export { server, httpTerminator };修复exitHandler的依赖问题:将
server和httpTerminator改为参数传入,避免循环导入
修改exitHandler.ts:import mongoose from 'mongoose'; import logger from '../logger'; class ExitHandler { public async handleExit(code: number, timeout = 5000, server?: any, httpTerminator?: any): Promise<void> { try { logger.info(`Attempting graceful shutdown with code: ${code}`); setTimeout(() => { logger.info(`Forcing a shutdown with code: ${code}`); process.exit(code); }, timeout).unref(); if (server?.listening) { logger.info('Terminating HTTP connections'); await httpTerminator?.terminate(); await mongoose.connection.close(); } logger.info(`Exiting gracefully with code ${code}`); process.exit(code); } catch (error) { logger.error(error); logger.error( `Unable to shutdown gracefully... Forcing exit with code: ${code}` ); process.exit(code); } } } export const exitHandler = new ExitHandler();之后在处理退出信号的
process.ts中,导入server和httpTerminator并传入给exitHandler.handleExit即可。设置测试环境变量:在
test.env中添加NODE_ENV=test,确保index.ts不会启动服务器。
修改完成后,循环依赖被打破,测试时app能正常从server.ts导入,测试可以正常执行。
内容的提问来源于stack exchange,提问作者Anisul Haque
相关产品推荐
相关产品推荐

