【问题标题】:Can I "include" or exec a TypeScript file in a Jest test?我可以在 Jest 测试中“包含”或执行 TypeScript 文件吗?
【发布时间】:2021-06-05 01:55:47
【问题描述】:

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

我们有一套适用于所有服务类别的良好测试。但是,我们有一个 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}`);
});

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

我想在 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 构造填充到一个类中,以便可以使用简单的方法继承来替换部分以进行测试)。但我想先探索一下这个无更改选项。

【问题讨论】:

    标签: typescript jestjs background-process


    【解决方案1】:

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

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

    假设你做了类似的事情:

    // 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 访问其端点,但我不会真正推荐它。如果您将其保留在一个流程中并重构您的应用程序的创建、配置和销毁方式,您的生活将会轻松得多。它将变得更加简洁,更加灵活。

    【讨论】:

    • 哦,可爱 - 谢谢。我将在下周发布我的答案 - 我有一些基本的东西,实际上使用来自child_processspawn 在备用端口上启动我的index.ts。但是,我会试试你的 - 它看起来更整洁,没有子流程的繁琐?
    【解决方案2】:

    如果您想保持测试套件完全独立并且只使用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 中处于中等水平。希望您有更多的单元测试,专注于一一测试特定的类/文件/事物。

    【讨论】:

    • 谢谢!我认为您对测试金字塔的看法是正确的——是的,90% 的现有测试要低得多。它们仍然是集成测试,但它们是连接到模拟数据库的服务,任何调用上游 API 的东西都可以使用我们的 DI 系统模拟出来——我只是注入一个模拟类来重放虚假响应,而不是调用真实的服务。
    • 我通常是单元测试的忠实粉丝;在 PHP 中,我更处于自己的舒适区,我倾向于理所当然地进行单元测试。但是我对 TS 很陌生,所以我做出了务实的决定,只做集成测试。由于我们处于灵活的 Docker 环境中,因此在我们的案例中启动真实数据库(本地 DynamoDB 实例)非常容易,并且可以注入随机表名以确保并行 Jest 测试不会相互覆盖。
    【解决方案3】:

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

    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 侦听器,然后通过传递给 spawn() 的虚拟环境变量将该脚本的行为修改为测试模式。

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

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

    到目前为止,我的观察是,虽然这似乎可靠地打印了 PID,但有时侦听器仍然存在问题,在 4 秒的启动延迟后仍未准备好。从代码中可以看出,我打算实现一些重试代码,如果我要在这个方向上坚持下去,我肯定会这样做。然而,Alex 的解决方案让我印象深刻得多,所以我现在很可能会采取这个方向。

    【讨论】:

      猜你喜欢
      • 2014-12-23
      • 1970-01-01
      • 2019-07-17
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-09-17
      • 2018-03-13
      • 1970-01-01
      相关资源
      最近更新 更多