我可以 "include" 或在 Jest 测试中执行 TypeScript 文件吗?

Can I "include" or exec a TypeScript file in a Jest test?

我有一个 TypeScript 项目,运行使用 Express 作为后端 RESTful API。它的设计非常重对象,因此可以实例化大量 classes 并在 运行 时间和测试服务 classes 时相互注入。

我们对所有服务 classes 进行了一套很好的测试。但是,我们有一个 index.ts 将所有这些结合在一起,并且目前可以逃避测试自动化。我正在考虑多种方法来对此进行测试,以便端点和轻量级控制器免受回归影响。 (而不是列出我所有的想法可能会导致一个过于宽泛的问题,我现在将专注于一个具体的想法)。

让我展示一个我的前端控制器的例子(src/index.ts):

/* Lots of imports here */

const app = express();
app.use(express.json());
app.use(cors());

app.options('*', cors());

/* Lots of settings from env vars here */

// Build some modules
const verificationsTableName = 'SV-Verifications';
const verificationsIndexName = 'SV-VerificationsByUserId';
const getVerificationService = new GetVerification(
    docClient,
    verificationsTableName,
    verificationsIndexName,
    timer,
    EXPIRY_LENGTH,
);
const writeVerifiedStatusService = new WriteVerifiedStatus(
    docClient,
    verificationsTableName,
    timer,
    getVerificationService,
);

/* Some code omitted for brevity */

// Create some routes
GetVerificationController.createRoutes(getVerificationService, app);
FinishVerificationController.createRoutes(finishVerificationService, app);
addPostStartVerification(startVerification, app);

IsVerifiedController.createValidationRoutes(di2.createOverallFeatureFlagService(), getVerificationService, app);

app.listen(PORT, () => {
    console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
});

你明白了 - classes 是使用依赖注入组装的,我们从环境变量中获取一些配置,然后启动 HTTP 侦听器。需要注意的主要一点是此文件不包含或导出任何 classes 或函数。

我想在 Jest 测试套件中 运行 这个文件,像这样:

describe('Test endpoint wiring', () => {
    beforeEach(() => {
        // Set up lots of env vars
        // How to run `src/index.ts` here?
    });

    afterEach(() => {
        // Tear down the server here
    });

    test('First endpoint test', () => {
        // Run a test against an endpoint
    });
});

我想知道我是否可以在这里做一些 await exec('node command')?我希望它在后台 运行 以便在服务器启动后测试 运行 。理想情况下,这将构成 Jest 中异步线程的一部分,但如果这不可能,那么直接生成进程可能就可以了。

如果有一种可靠的方法可以在每次测试结束时杀死它,那就太好了(我想保持 PID 并发送停止信号是可以的)。

修改 index.ts 并非不可能(实际上我想将所有这些 DI 构造塞入 class,以便可以使用简单的方法替换部分以用于测试目的遗产)。但我想先探讨一下这个不改变的选项。

如果您想保持测试套件完全独立并且仅使用 fetch 测试 HTTP 端点,就像用户一样,您可以使用 concurrently 来实现这一点。

concurrently -s first --kill-others \"yarn run serve\" \"yarn run tests:integration\"

将此命令添加到我的 package.json 时,我可以配置 serve 部分来设置快速服务器,并配置 tests:integration 脚本来实际测试端点。 -s first 使 concurrently 的返回状态与第一个退出的进程相同,并且 --kill-others 在其中一个进程完成后终止所有进程。

看到我把这些称为 集成 测试了吗?在我看来,这类测试更容易出错,它们将解决方案的这一部分作为一个整体进行测试,因此它们在 test pyramid 中处于中间水平。希望你有更多的单元测试,专注于一个一个地测试特定的 classes/files/things。

如果事情很难测试,这通常意味着您需要重构以改进封装。您需要能够在测试套件中多次创建、执行、检查和拆卸事物 运行。这意味着在所需文件的根级别执行的代码根本不可测试。

这里没有任何魔法可以使这项工作*,您只需要稍微重构一下您的代码。如果你做对了,它也会让你更容易推断出应用程序在生产环境中是如何启动的。

假设您做了更像的事情:

// Maybe move these to lib/constants.ts or something and import them instead.
const verificationsTableName = 'SV-Verifications';
const verificationsIndexName = 'SV-VerificationsByUserId';

export function boot(): Express {
  const app = createExpressApp()
  setupServices(app)
  startApp(app)
  return app
}

export function createExpressApp() {
  const app = express();
  // setup express app here
  return app
}

export function setupServices(app: Express) {
  setupGetVerificationService(app)
  setupFinishVerificationService(app)
  // call function that setup other services here.
}

export function setupGetVerificationService(app: Express) {
  const getVerificationService = new GetVerification(/* ... */)
  GetVerificationController.createRoutes(getVerificationService, app);
}

export function setupFinishVerificationService(app: Express) {
  const writeVerifiedStatusService = new WriteVerifiedStatus(/* ... */)
  FinishVerificationController.createRoutes(finishVerificationService, app)
}

export function startApp(app: Express) {
  app.listen(PORT, () => {
    console.log(`⚡️[server]: Server is running at http://localhost:${PORT}`);
  });
}

export function stopApp(app: Express) {
  app.close();
}

现在,在您的 index.ts 中启动您的应用程序,您可以简单地:

import { boot } from './initialize-app.ts'

boot()

现在您可以根据需要测试应用程序设置的每个步骤:

describe('Test endpoint wiring', () => {
    let app: Express
    beforeEach(() => {
        // Set up lots of env vars
        app = createExpressApp()
        setupServices(app)
        startApp(app)
    });

    afterEach(() => {
        // Tear down the server here
        stopApp(app)
    });

    test('First endpoint test', () => {
        // Run a test against an endpoint
    });
});

有了这种结构,您现在甚至可以只创建一个服务子集来单独测试每个服务,这也可能有助于发现服务在不应该相互依赖的地方出现的问题。作为奖励,您的测试时间也会缩短。


* 是的,您 可以 将您的服务器作为一个单独的进程进行管理,并通过 HTTP 访问其端点,但我真的不推荐这样做。如果您将其保留在一个流程中并重构您的应用程序的创建、配置和销毁方式,您的生活将会轻松得多。它会更简洁,也更灵活。

我已经为我的问题草拟了一个基于流程的答案:

import { ChildProcess, spawn } from 'child_process';
import fetch from 'node-fetch';

describe('Test endpoint wiring', () => {
    let listenerProcess: ChildProcess;

    beforeEach(async () => {
        // @todo Don't use absolute paths here
        const runner = '/root/app/node_modules/.bin/ts-node';
        const listener = '/root/app/src/index.ts';
        listenerProcess = spawn(runner, [listener], {
            stdio: 'ignore',
            detached: true,
            env: {
                PORT: '9001',
                NODE_PATH: '/root/app/src',
            },
        });
        listenerProcess.unref();
        console.log('PID: ', listenerProcess.pid);

        // @todo Add a retry loop to this
        await new Promise((resolve, reject) => {
            function later(delay) {
                return new Promise(function (resolve) {
                    setTimeout(resolve, delay);
                });
            }

            later(4000)
                .then(() => {
                    console.log('Trying spawned listener');
                    return fetch('http://localhost:9001/');
                })
                .then(() => {
                    console.log('Listener seems to be up');
                    resolve(null);
                })
                .catch((error) => {
                    // Ignore errors (e.g. can't connect)
                    console.log('Cannot connect');
                });

            console.log('Entered promise handler');
        });
    });

    afterEach(() => {
        console.log('Do a task kill here');
        listenerProcess.kill();
    });

    test('First endpoint test', async () => {
        // FIXME Just demo code
        try {
            const response = await fetch('http://localhost:9001/');
            console.log('HTTP response code:', response.status);
            //console.log(response.headers);
            console.log(await response.text()); // Not sure why this needs another await
        } catch (e) {
            console.error('Error has occurred:', e);
        }
    });
});

你可以看到它使用全局 listenerProcess 来包含生成的 index.ts 侦听器,然后该脚本的行为被传递给 [= 的虚拟环境变量修改为测试模式13=].

我用了this answer来帮助断开进程与测试父进程的连接,这样就没有挂了。您可以看到我在 9001 上旋转了监听器(9000 用于我的开发者,通常 运行 在同一个容器中)。在实践中,我会将此端口设置为 9001 + random,以避免任何并行测试设置冲突的侦听器。

如你所见,我也在每次测试后终止了进程。这里的想法是每个测试都有一个干净的监听器,以防测试之间的监听架构中保留任何工件。

到目前为止,我的观察是,虽然这似乎可靠地打印了 PID,但有时侦听器仍然存在问题,在四秒的启动延迟后仍未准备好。从代码中可以看出,我打算实现一些重试代码,如果我坚持这个方向,我一定会这样做。但是,Alex 的解决方案让我印象深刻,因此我现在倾向于朝这个方向发展。