- 隔离是首要目标
- 什么不起作用
- 使用事务
- 使用 SQLite
- 使用`pg_tmp`
- 什么有效
- 模板数据库
- 安装内存盘
- 使用带有内存磁盘的 Docker 容器
- 管理测试数据库
- 结论
在测试方面,实现性能和可靠性至关重要。在本文中,我将解释如何设置PostgreSQL进行测试并讨论一些需要避免的常见陷阱。
隔离是首要目标
在我们深入细节之前,让我们先定义我们的目标:
- 隔离——我们希望确保每个测试都是隔离运行的。至少,这意味着每个测试都应该有自己的数据库。这可确保测试不会相互干扰,并且您可以并行运行测试而不会出现任何问题。
- 性能– 我们希望确保为测试设置 PostgreSQL 的速度很快。对于在 CI/CD 管道中运行测试来说,缓慢的解决方案将导致成本过高。我们提出的解决方案必须允许我们在不引入太多开销的情况下执行测试。
本文的其余部分将重点介绍我们已经尝试过的内容、有效的内容以及无效的内容。
什么不起作用
使用事务
我们尝试的第一种方法是使用事务。我们将在每次测试开始时启动一个事务,并在结束时回滚它。
以下示例说明了基本思想:
代码语言:javascript复制test('calculates total basket value', async () => {
await pool.transaction(async (tx) => {
await tx.query(sql.unsafe`
INSERT INTO basket (product_id, quantity)
VALUES (1, 2)
`);
const total = await getBasketTotal(tx);
expect(total).toBe(20);
});
});
事务方法适用于简单的情况(例如,测试单个功能),但在处理测试多个组件之间的集成的测试时,它很快就会成为问题。由于连接池、嵌套事务和其他因素,使事务方法发挥作用所需的必要工作意味着我们不会复制应用程序的真实行为,即它不会提供我们所需的信心。
为了保持一致性,我们还希望避免混合测试方法。尽管使用事务足以满足某些测试的需要,但我们希望在所有测试中采用一致的方法。
使用 SQLite
我们尝试的另一种方法是使用 SQLite。 SQLite 是一种快速且易于设置的内存数据库。
与事务方法类似,SQLite 非常适合简单的情况。然而,在处理使用 PostgreSQL 特定功能的代码路径时,它很快就会成为问题。在我们的例子中,由于使用了各种 PostgreSQL 扩展、PL/pgSQL 函数和其他 PostgreSQL 特定的功能,我们无法使用 SQLite 进行测试。
pglite提供了打包为WASM 模块的 PostgreSQL ,可以在 Node.js 中使用。这可能是一个不错的选择,尽管我们还没有尝试过。无论如何,目前缺乏对扩展的支持对我们来说是一个障碍。
使用pg_tmp
我们尝试的另一种方法是使用pg_tmp
.pg_tmp
是一个为每个测试创建临时 PostgreSQL 实例的工具。
理论上pg_tmp
是一个很好的解决方案。它允许完全隔离测试。实际上,速度比我们可以容忍的要慢得多。使用 时pg_tmp
,启动和填充数据库需要几秒钟的时间,并且当运行数千个测试时,这种开销会迅速增加。
假设您有 1000 个测试,每个测试需要 1 秒来运行。如果您为创建新数据库增加 2 秒的开销,则会额外增加 2000 秒(33 分钟)的开销。
如果您喜欢这种方法,您也可以使用 Docker 容器。根据许多因素,Docker 容器可能比pg_tmp
.
integresql是我在HN线程中遇到的一个项目。这似乎是一个很好的替代方案,可以将创建新数据库的开销减少到大约 500 毫秒。它有一个池机制,可以让您进一步减少开销。我们决定不再继续这条道路,因为我们对使用模板数据库获得的隔离级别感到满意。
什么有效
在尝试了各种方法之后,我们决定结合两种方法:模板数据库和挂载内存盘。这种方法使我们能够在数据库级别隔离每个测试,而不会引入太多开销或复杂性。
模板数据库
模板数据库是用作创建新数据库的模板的数据库。当您从模板数据库创建新数据库时,新数据库具有与模板数据库相同的架构。从模板数据库创建新数据库的步骤如下:
- 创建模板数据库 (
ALTER DATABASE <database_name> is_template=true;
) - 从模板数据库创建新数据库 (
CREATE DATABASE <new_database_name> TEMPLATE <template_database_name>;
)
使用模板数据库的主要优点是您无需费力管理多个 PostgreSQL 实例。您可以创建副本数据库并单独运行每个测试。
然而,模板数据库本身对于我们的用例来说不够快。从模板数据库创建新数据库所需的时间对于运行数千个测试来说仍然太长:
代码语言:javascript复制postgres=# CREATE DATABASE foo TEMPLATE contra;
这就是内存安装的用武之地。
需要注意的模板数据库的另一个限制是,在复制源数据库时,没有其他会话可以连接到源数据库。CREATE DATABASE
如果启动时存在任何其他连接,则会失败;在复制操作期间,将阻止与源数据库的新连接。这是一个很容易使用互斥模式来解决的限制,但需要注意。
安装内存盘
最后一个难题是安装存储盘。通过挂载内存盘,并在内存盘上创建模板数据库,可以显着减少创建新数据库的开销。
我将在下一节中讨论如何安装内存磁盘,但首先让我们看看它会产生多大的差异。
代码语言:javascript复制postgres=# CREATE DATABASE bar TEMPLATE contra;
这是一个重大改进,使得该方法对于我们的用例来说是可行的。
不用说,这种方法并非没有缺点。数据存储在内存中,这意味着它不是持久的。如果数据库崩溃或者服务器重启,数据就会丢失。然而,对于运行测试来说,这不是问题。每次创建新数据库时,都会从模板数据库重新创建数据。
使用带有内存磁盘的 Docker 容器
我们选择的方法是使用带有内存磁盘的 Docker 容器。设置方法如下:
代码语言:javascript复制$ docker run
-p 5435:5432
--tmpfs /var/lib/pg/data
-e PGDATA=/var/lib/pg/data
-e POSTGRES_PASSWORD=postgres
--name contra-database
--rm
postgres:14
在上面的命令中,我们创建了一个 Docker 容器,其内存磁盘安装在/var/lib/pg/data
.我们还将PGDATA
环境变量设置为 ,/var/lib/pg/data
以确保 PostgreSQL 使用内存磁盘来存储数据。最终结果是底层数据存储在内存中,这显着减少了创建新数据库的开销。
管理测试数据库
基本思想是在运行测试之前创建一个模板数据库,然后为每个测试从模板数据库创建一个新数据库。以下是管理测试数据库的简化版本:
代码语言:javascript复制import {
createPool,
sql,
stringifyDsn,
} from 'slonik';
type TestDatabase = {
destroy: () => Promise<void>;
getConnectionUri: () => string;
name: () => string;
};
const createTestDatabasePooler = async (connectionUrl: string) => {
const pool = await createPool(connectionUrl, {
connectionTimeout: 5_000,
// This ensures that we don't attempt to create multiple databases in parallel.
maximumPoolSize: 1,
});
const createTestDatabase = async (
templateName: string,
): Promise<TestDatabase> => {
const database = 'test_' uid();
await pool.query(sql.typeAlias('void')`
CREATE DATABASE ${sql.identifier([database])}
TEMPLATE ${sql.identifier([templateName])}
`);
return {
destroy: async () => {
await pool.query(sql.typeAlias('void')`
DROP DATABASE ${sql.identifier([database])}
`);
},
getConnectionUri: () => {
return stringifyDsn({
...parseDsn(connectionUrl),
databaseName: database,
password: 'unsafe_password',
username: 'contra_api',
});
},
name: () => {
return database;
},
};
};
return () => {
return createTestDatabase('contra_template');
};
};
const getTestDatabase = await createTestDatabasePooler();
此时,您可以getTestDatabase
为每个测试创建一个新的数据库。该destroy
方法可用于在测试运行后清理数据库。
结论
这种设置允许我们在多个分片上并行运行数千个测试,而不会出现任何问题。创建新数据库的开销很小,并且隔离是在数据库级别的。我们对此设置提供的性能和可靠性感到满意。