与 Jest 一起使用
在测试使用 Jest 进行测试并使用 MikroORM 进行数据库测试的你自己的代码时,需要考虑一些事项。
¥When testing your own code that uses Jest for the tests, and MikroORM for the database, there are some things to consider.
使用假计时器
¥Using fake timers
Jest 允许使用 计时器模拟,这在测试应用对时间敏感的任务的逻辑时可能是一个非常有用的功能。确保数据库中的数据基于时间的准确性也是此测试的一部分。
¥Jest allows using timer mocks, which can be a very useful feature when testing your application's logic for time sensitive tasks. Making sure the data in your database is accurate based on time is also part of this testing.
但需要注意的一点是,Jest 伪造的函数之一是 process.nextTick()
,而这个函数又被 MikroORM 的一些依赖使用。
¥One caveat to be aware of though is that one of the functions faked by Jest is process.nextTick()
, and this function is in turn used by some of MikroORM's dependencies.
如果你知道你的代码对事件循环的滴答声的通过不敏感(而可能只对系统时钟敏感),你可以安全地将 Jest 设置为不伪造此功能,方法是使用:
¥If you know your code is not sensitive to the passage of the event loop's ticks (and is instead maybe only sensitive to the system's clock), you can safely set Jest to not fake this function by using:
jest.useFakeTimers({ doNotFake: ['nextTick'] });
这样,你仍然可以使用 Jest 的其余计时器模拟 API 来控制系统的时钟,以及你的代码或 MikroORM 中使用的任何计时器(最值得注意的是结果缓存)。
¥This way, you can still use the rest of Jest's timer mock API to control the system's clock, and any timers in use by your code or in MikroORM (most notably, result caches).
如果你需要更好地控制测试代码中的微任务队列,请继续阅读本节的其余部分。
¥If you need better control in your test code in relation to the micro-task queue, keep reading the rest of this section.
依赖对 process.nextTick()
的已知用法
¥Known usages of process.nextTick()
by dependencies
-
所有使用连接池的 DB 客户端都通过一种算法来实现这一点,该算法允许它们在下一个滴答声中获得它们请求的连接,或者如果在请求时没有空闲连接,则排队。与 类似,你还可以控制用于填充查询的 子句。
¥All DB clients that use connection pool do so by an algorithm that lets them obtain the connection they requested at the next tick, or be queued up if no connection is free at the time of the request. Similarly, on release, if there's a request in the queue, it gets scheduled to get the released connection at the next tick, or else the connection is put back in the pool.
-
MongoDB 客户端没有不使用连接池的选项。即使你设置了一个连接池,客户端仍将访问该池,因此需要
process.nextTick()
。¥The MongoDB client has no option of not using connection pool. Even if you set a pool of one connection, the client will still reach for that pool, thus needing
process.nextTick()
. -
MySQL/MariaDB 客户端除了在池和池集群中使用
process.nextTick()
之外,还在最终确定查询结果时使用它。参见 mysql2 的来源¥The MySQL/MariaDB client, in addition to using
process.nextTick()
in pool and pool clusters, also uses it when finalizing results from queries. See the source of mysql2 -
除了在连接池中使用
process.nextTick()
之外,PostgreSQL 客户端还会在使用非原生客户端处理错误时使用它。任何服务器错误(包括错误查询、读取超时等)或从用户提供的回调抛出的异常都会在下一个滴答中(重新)抛出。如果你没有使用连接池,也没有使用原始 SQL 查询,你应该能够安全地使用伪造的process.nextTick()
并仅在手动推进时间时处理未捕获的异常。¥The PostgreSQL client, in addition to using
process.nextTick()
in a connection pool, also uses it when handling errors with its non-native client. Any server errors (including f.e. wrong queries, read timeouts, etc.) or exceptions thrown from user supplied callbacks get (re)thrown in the next tick. If you are not using a connection pool, and are not using raw SQL queries, you should be able to safely use a fakedprocess.nextTick()
and handle uncaught exceptions only when you manually advance time. -
SQLite 客户端不支持连接池,但它具有缓存数据库实例的概念(参见 sqlite3 文档),它会在下一个滴答时交出缓存的数据库实例。MikroORM 不使用此功能,这意味着将 MikroORM 与 SQLite 一起使用始终是安全的。但是,如果你直接获取 SQLite3 客户端并尝试使用缓存实例,你将遇到 SQLite 对
process.nextTick()
的唯一使用。¥The SQLite client does not support connection pool, but it does have the concept of cached database instances (see sqlite3 docs), which hands off the cached database instance at the next tick. This feature is not used by MikroORM, which means it is always safe to use MikroORM with SQLite. However, if you get the SQLite3 client directly and try to use a cached instance, you will run into SQLite's only use of
process.nextTick()
.
仅在需要时允许真正的 process.nextTick()
¥Allowing a real process.nextTick()
only when required
如果你确实知道你需要伪造的 process.nextTick()
,因为你的代码对微任务队列很敏感,但你还将 MikroORM 与连接池或 MySQL 混合使用,那么你将需要模拟需要 process.nextTick()
的部分,以便它们仅在关键操作期间使用真实函数,并在关键操作之后恢复模拟。
¥If you do know that you do need a faked process.nextTick()
because your code is sensitive to the micro-tasks queue, and yet you also have MikroORM in the mix with a connection pool or MySQL, you will need to mock the parts that require process.nextTick()
so that they use the real function only during that critical operation, and restore back the mock after that critical operation.
所以最后,你的应用和 MikroORM 相关代码(预刷新钩子、自定义类型的 JS 到 DB 转换等)可以安排下一个 tick 所需的内容,但实际上不会运行这些回调。这些回调只会在查询期间执行(并且可能会安排新的 process.nextTick()
回调,这些回调可能会或可能不会执行),在查询结果出现之前。查询结果出来后,你将再次无法执行预定的回调,直到你手动提前时间。值得注意的是,这还包括在任何 MikroORM 相关代码(后刷新钩子、自定义类型的 DB 到 JS 转换等)期间安排的回调。
¥So in the end, your application and MikroORM related code (pre-flush hooks, custom types' JS to DB conversion, etc.) can schedule what is needed to for the next tick, but not have those callbacks actually run. Those callbacks would only be executed (and may schedule new process.nextTick()
callbacks that may or may not get executed) during the query, before the query results are in. After the query results are in, you would once again not have scheduled callbacks executing, until you manually advance time. Notably, this includes also callbacks scheduled during any MikroORM related code (post-flush hooks, custom types' DB to JS conversion, etc.).
要实现这一点,你可以使用类似这样的代码片段(在撰写本文时,已测试可与 Jest 版本一起使用):
¥To accomplish this, you can use something like this snippet of code (tested to work with the version of Jest at the time of this writing):
export function wrappedSpy<const T extends {}, const M extends jest.FunctionPropertyNames<Required<T>>>(
object: T,
method: T[M] extends jest.Func ? M : never,
hooks: Readonly<{
beforeOriginal?: (...args: jest.ArgsType<jest.FunctionProperties<Required<T>>[T[M] extends jest.Func ? M : never]>) => void,
afterOriginal?: (result: ReturnType<T[M] extends jest.Func ? T[M] : never> extends Promise<infer R> ? R : ReturnType<T[M] extends jest.Func ? T[M] : never>) => void,
errorOriginal?: (error?: unknown) => void,
}>
) {
const originalSpy = jest.spyOn(object, method);
const mockImpl: Parameters<typeof originalSpy.mockImplementationOnce>[0] = (...args) => {
hooks.beforeOriginal?.(...args);
try {
const result = (object[method] as Function).apply(originalSpy.mock.contexts.at(-1), args);
if (result instanceof Promise) {
result.then((v) => {
hooks.afterOriginal?.(v);
return v;
}).catch((e) => {
hooks.errorOriginal?.(e);
}).finally(() => {
originalSpy.mockImplementationOnce(mockImpl!);
});
} else {
hooks.afterOriginal?.(result);
originalSpy.mockImplementationOnce(mockImpl!);
}
return result;
} catch (e) {
hooks.errorOriginal?.(e);
originalSpy.mockImplementationOnce(mockImpl!);
throw e;
}
};
originalSpy.mockImplementationOnce(mockImpl);
return originalSpy;
}
const finallyHook = () => {
jest.useFakeTimers({ doNotFake: [], now: jest.now() });
};
export const fakeTimersHooks = {
beforeOriginal: () => {
jest.useFakeTimers({ doNotFake: ['nextTick'], now: jest.now() });
},
afterOriginal: finallyHook,
errorOriginal: finallyHook,
} as const satisfies Parameters<typeof wrappedSpy>[2];
使用 MySQL/MariaDB
¥With MySQL/MariaDB
如果你使用 MySQL 或 MariaDB,还请添加以下内容以模拟使用 process.nextTick()
的各个方法:
¥If you're using MySQL or MariaDB, also add this to mock the individual methods that use process.nextTick()
:
import { resolve, dirname } from 'node:path';
import { fakeTimersHooks, wrappedSpy } from './nextTickFixer';
export function enableFakeTimersWithMikroOrm() {
const mysqlDir = dirname(require.resolve('mysql2'));
return {
mocks: [
wrappedSpy(require(resolve(mysqlDir, 'lib/commands/query.js')).prototype, 'done', executeHooks),
wrappedSpy(require(resolve(mysqlDir, 'lib/commands/ping.js')).prototype, 'pingResponse', executeHooks),
wrappedSpy(require(resolve(mysqlDir, 'lib/commands/register_slave.js')).prototype, 'registerResponse', executeHooks),
wrappedSpy(require(resolve(mysqlDir, 'lib/pool.js')).prototype, 'getConnection', executeHooks),
wrappedSpy(require(resolve(mysqlDir, 'lib/pool.js')).prototype, 'releaseConnection', executeHooks),
wrappedSpy(require(resolve(mysqlDir, 'lib/pool_cluster.js')).prototype, 'end', executeHooks),
],
mockRestore: function () {
let mock: jest.SpyInstance | undefined;
while (mock = this.mocks.pop()) {
mock.mockRestore();
}
}
};
}
使用 PostgreSQL
¥With PostgreSQL
如果你使用的是 PostgreSQL,请考虑将 pg-native
添加为依赖,以启用错误处理而无需额外的模拟。或者,检查你的测试产生了哪些错误,它们从哪里被抛出,并模拟来自 pg/client.js
的适当方法。
¥If you are using PostgreSQL, consider adding pg-native
as a dependency, to enable error handling without extra mocks. Alternatively, inspect which errors your tests produce, where they get thrown from, and mock the appropriate methods from pg/client.js
.
无论如何,如果你使用连接池,你还需要添加以下内容:
¥Regardless, if you are using connection pools, you will also need to add this:
import Pool from 'pg-pool';
import { fakeTimersHooks, wrappedSpy } from './nextTickFixer';
export function enableFakeTimersWithMikroOrm() {
return {
mocks: [
wrappedSpy(Pool.prototype, 'connect', executeHooks),
],
mockRestore: function () {
let mock: jest.SpyInstance | undefined;
while (mock = this.mocks.pop()) {
mock.mockRestore();
}
}
};
}
使用 MongoDB
¥With MongoDB
如果你使用的是 MongoDB,请添加它以模拟使用 process.nextTick()
的 Mongo 客户端的所有单独方法。
¥If you are using MongoDB, add this to mock all individual methods of the Mongo client that use process.nextTick()
.
import { Topology } from 'mongodb/lib/sdam/topology';
import { ConnectionPool } from 'mongodb/lib/cmap/connection_pool';
import { fakeTimersHooks, wrappedSpy } from './nextTickFixer';
function enableFakeTimersWithMikroOrm() {
return {
mocks: [
wrappedSpy(ConnectionPool, 'constructor', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'checkIn', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'checkOut', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'clear', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'destroyConnection', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'ensureMinPoolSize', fakeTimersHooks),
wrappedSpy(ConnectionPool.prototype, 'processWaitQueue', fakeTimersHooks),
wrappedSpy(Topology.prototype, 'serverUpdateHandler', fakeTimersHooks),
wrappedSpy(Topology.prototype, 'selectServer', fakeTimersHooks),
],
mockRestore: function () {
let mock: jest.SpyInstance | undefined;
while (mock = this.mocks.pop()) {
mock.mockRestore();
}
}
};
}
固定模拟的使用
¥Usage of fixed mocks
在你的测试中,在调用任何查询之前先调用 enableFakeTimersWithMikroOrm
。你可以在返回的对象上调用 mockRestore()
来重新启用实时计时器的使用(或确保如果调用查询,测试将冻结而不是继续)。例如
¥In your tests, call enableFakeTimersWithMikroOrm
before you call any queries. You can call mockRestore()
on the returned object to re-enable real timers use (or ensure that if queries are called, the test would freeze, rather than continue). e.g.
import { initORM } from './db';// See "Project Setup"
import { enableFakeTimersWithMikroOrm } from './fakeTimersFixer'; // different based on your driver; see above
test(() => {
const orm = initORM({
//your test config
});
jest.useFakeTimers();
const ormMock = enableFakeTimersWithMikroOrm();
// write your tests normally
ormMock.restoreMock();
jest.useRealTimers();
});