您的位置:  首页 > 技术杂谈 > 正文

如何配置 PostgreSQL 用于集成测试

2024-05-07 16:00 https://my.oschina.net/u/6148470/blog/11093857 Bytebase 次阅读 条评论

原文 Setting up PostgreSQL for running integration tests

在进行测试时,实现性能和可靠性至关重要。在本文中,我将解释如何为测试设置 PostgreSQL,并讨论一些常见的需要避免的陷阱。

隔离性是首要目标

在我们深入细节之前,让我们先定义一下我们的目标:

隔离性 - 我们希望确保每个测试都在隔离环境中运行。至少,这意味着每个测试应该有自己的数据库。这样可以确保测试不会相互干扰,并且您可以并行运行测试而不会出现任何问题。

性能 - 我们希望确保为测试设置的 PostgreSQL 是快速的。慢的解决方案会导致 CI/CD 中运行测试成本过高。我们提出的解决方案必须能够让我们执行测试而不会引入太多额外开销。

本文的其余部分将重点关注我们尝试过什么、有效的是什么,以及无法奏效的是什么。

不奏效的

使用事务

我们尝试的第一种方法是使用事务。我们会在每次测试开始时启动一个事务,并在结束时回滚它。

基本思想如下示例所示:

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 提供了将 PostgreSQL 打包为 WASM 模块,可在 Node.js 中使用。这可能是一个不错的选择,尽管我们还没有尝试过。无论如何,pglite 当前对扩展的支持不足会成为我们的障碍。

使用 pg_tmp

我们尝试的另一种方法是使用 pg_tmp。pg_tmp 是一个工具,为每个测试创建一个临时的 PostgreSQL 实例。从理论上讲,pg_tmp 是一个很好的解决方案。它允许完全隔离测试。在实践中,它比我们能够容忍的要慢得多。使用pg_tmp,启动和填充数据库需要几秒钟时间,并且在运行数千个测试时这些额外开销会迅速累积起来。假设您有1000 个测试,并且每个测试需要 1 秒钟才能运行。如果您为创建新数据库添加 2 秒钟的额外开销,则将增加额外2000 秒(33分钟)的开销量。

如果你喜欢这种方法,你也可能可以使用 Docker容器。情况不同,Docker 容器甚至可能比 pg_tmp 更快。 integresql 是我在 HN 帖子中遇到的一个项目。它似乎是一个很好的选择,可以将创建新数据库的开销减少到约500 毫秒。它具有一种池化机制,可以进一步减少开销。我们决定不继续沿着这条路走,因为我们对使用模板数据库获得的隔离水平感到满意。

奏效的方案

经过尝试各种方法后,我们决定结合两种方法:模板数据库(Template Databases)和挂载内存磁盘(mounting a memory disk)。这种方法使我们能够在数据库级别上隔离每个测试,而不会引入太多额外开销或复杂性。

模版数据库(Template Databases)

模板数据库是一个用作创建新数据库模板的数据库。当您从模板数据库创建新数据库时,新数据库具有与模板数据库相同的架构。从模板数据库创建新数据库的步骤如下:

  1. 创建一个模板数据库 ALTER DATABASE <database_name> is_template=true;
  2. 从模板数据库创建一个新的数据库 CREATE DATABASE <new_database_name> TEMPLATE <template_database_name>;

使用模板数据库的主要优势在于您无需处理管理多个 PostgreSQL 实例。您可以创建副本数据库,并使每个测试在隔离环境中运行。

然而,单独使用模板数据库对我们的用例来说并不够快。从模板数据库创建新数据库所需的时间仍然过长,无法满足运行数千次测试的需要:

postgres=# CREATE DATABASE foo TEMPLATE contra;
CREATE DATABASE
Time: 1999.758 ms (00:02.000)

所以就需要内存挂载出马了

需要注意的模板数据库的另一个限制是在复制过程中不能连接到源数据库的其他会话。如果在启动时存在任何其他连接,CREATE DATABASE 将失败;在复制操作期间,将阻止对源数据库的新连接。使用互斥模式可以很容易地解决这个限制,但这是需要注意的一点。

挂载内存磁盘

拼图的最后一块是挂载内存磁盘。通过挂载内存磁盘,并在内存磁盘上创建模板数据库,我们可以显著减少创建新数据库时的开销。

我将在下一节讨论如何挂载内存磁盘,但首先,让我们看看它会带来多大的差异。

postgres=# CREATE DATABASE bar TEMPLATE contra;
CREATE DATABASE
Time: 87.168 ms

这是一个重大的改进,使得这种方法对我们的使用场景变得可行。

不用说,这种方法并非没有缺点。数据存储在内存中,这意味着它不是持久化的。如果数据库崩溃或服务器重新启动,则数据会丢失。然而,对于运行测试来说,这并不是问题。每次创建新数据库时,数据都会从模板数据库重新生成。

使用 Docker 挂一个内存盘

我们采用的方法是使用一个带有内存磁盘的 Docker 容器。以下是设置步骤:

$ 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 使用内存磁盘进行数据存储。最终结果是底层数据被存储在内存中,大大减少了创建新数据库的开销。

管理测试数据库

基本思路是在运行测试之前创建一个模板数据库,然后为每个测试从模板数据库创建一个新的数据库。以下是如何管理测试数据库的简化版本:

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 方法可用于在测试运行后清理数据库。

结论

这种设置使我们能够在多个分片上并行运行数千个测试,而不会出现任何问题。创建新数据库的开销很小,并且隔离是在数据库级别进行的。我们对这种设置提供的性能和可靠性感到满意。

编者按

文中介绍的方案利用了 PostgreSQL 独有的模版库功能。MySQL 上没有,但思路可以借鉴。在 MySQL 上我们可以:

  1. 创建数据库 CREATE DATABASE new_database;
  2. 导出数据库 mysqldump -u username -p existing_database > backup.sql
  3. 导入到新数据库 mysql -u username -p new_database < backup.sql

另外也可以看一下 TestContainer 的方案,这个产品也已经被 Docker 收购了。


💡 更多资讯,请关注 Bytebase 公号:Bytebase

展开阅读全文
  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接