Skip to content

PostgreSQL 单元测试

单元测试概述

PostgreSQL 单元测试是验证数据库对象(如函数、存储过程、触发器、视图等)功能正确性的测试方法。通过单元测试,可以确保数据库对象在各种情况下都能正常工作,提高数据库代码的质量和可靠性。

单元测试的重要性

  • 确保功能正确性:验证数据库对象在各种输入条件下的输出是否符合预期
  • 提高代码质量:通过测试驱动开发(TDD)方法,编写更健壮、可维护的代码
  • 减少回归问题:在修改数据库对象后,通过单元测试快速发现潜在问题
  • 便于重构:有了单元测试的保障,可以放心地重构数据库代码
  • 提高开发效率:自动化测试可以节省手动测试的时间和精力

单元测试框架

1. pgTAP

pgTAP 是一个用于 PostgreSQL 的测试框架,提供了丰富的断言函数,便于编写和运行单元测试。

安装 pgTAP

Ubuntu/Debian

bash
sudo apt-get update
sudo apt-get install -y postgresql-15-pgtap

CentOS/RHEL

bash
sudo yum install -y postgresql15-contrib
sudo yum install -y pgtap

从源码安装

bash
git clone https://github.com/theory/pgtap.git
cd pgtap
make
sudo make install

启用 pgTAP

sql
-- 在数据库中创建扩展
CREATE EXTENSION pgtap;

基本语法

sql
-- 测试函数是否存在
SELECT plan(1);
SELECT has_function('public', 'add_two_numbers', ARRAY['integer', 'integer'], 'add_two_numbers 函数应该存在');
SELECT finish();

2. PL/pgSQL 内置测试

使用 PL/pgSQL 编写简单的单元测试,无需安装额外的扩展。

基本语法

sql
CREATE OR REPLACE FUNCTION test_add_two_numbers()
RETURNS VOID AS $$
DECLARE
    result INTEGER;
BEGIN
    -- 测试正常情况
    result := add_two_numbers(1, 2);
    IF result <> 3 THEN
        RAISE EXCEPTION '测试失败:add_two_numbers(1, 2) 应该返回 3,实际返回 %', result;
    END IF;
    
    -- 测试边界情况
    result := add_two_numbers(0, 0);
    IF result <> 0 THEN
        RAISE EXCEPTION '测试失败:add_two_numbers(0, 0) 应该返回 0,实际返回 %', result;
    END IF;
    
    -- 测试负数
    result := add_two_numbers(-1, -2);
    IF result <> -3 THEN
        RAISE EXCEPTION '测试失败:add_two_numbers(-1, -2) 应该返回 -3,实际返回 %', result;
    END IF;
    
    RAISE NOTICE '所有测试通过!';
END;
$$ LANGUAGE plpgsql;

-- 运行测试
SELECT test_add_two_numbers();

3. pg_prove

pg_prove 是一个用于运行 pgTAP 测试的命令行工具,类似于 Perl 的 prove 工具。

安装 pg_prove

bash
# 安装 Perl 和 DBI 模块
sudo apt-get install -y perl libdbi-perl libdbd-pg-perl

# 安装 pg_prove
cpan TAP::Parser::SourceHandler::pgTAP

运行测试

bash
# 运行单个测试文件
pg_prove -d mydb test_functions.sql

# 运行多个测试文件
pg_prove -d mydb test/*.sql

# 详细输出
pg_prove -v -d mydb test_functions.sql

编写单元测试

测试函数

使用 pgTAP 测试函数

sql
-- test_functions.sql
BEGIN;

-- 计划执行 3 个测试
SELECT plan(3);

-- 测试 1:检查函数是否存在
SELECT has_function('public', 'add_two_numbers', ARRAY['integer', 'integer'], 'add_two_numbers 函数应该存在');

-- 测试 2:测试正常情况
SELECT is(add_two_numbers(1, 2), 3, 'add_two_numbers(1, 2) 应该返回 3');

-- 测试 3:测试边界情况
SELECT is(add_two_numbers(0, 0), 0, 'add_two_numbers(0, 0) 应该返回 0');

-- 完成测试
SELECT finish();

ROLLBACK;

使用 PL/pgSQL 测试函数

sql
CREATE OR REPLACE FUNCTION test_add_two_numbers()
RETURNS TABLE(
    test_name TEXT,
    status TEXT,
    message TEXT
) AS $$
DECLARE
    result INTEGER;
BEGIN
    -- 测试 1:正常情况
    result := add_two_numbers(1, 2);
    IF result = 3 THEN
        RETURN QUERY SELECT 'test_normal_case'::TEXT, 'PASS'::TEXT, 'add_two_numbers(1, 2) 返回 3'::TEXT;
    ELSE
        RETURN QUERY SELECT 'test_normal_case'::TEXT, 'FAIL'::TEXT, format('add_two_numbers(1, 2) 应该返回 3,实际返回 %s', result)::TEXT;
    END IF;
    
    -- 测试 2:边界情况
    result := add_two_numbers(0, 0);
    IF result = 0 THEN
        RETURN QUERY SELECT 'test_boundary_case'::TEXT, 'PASS'::TEXT, 'add_two_numbers(0, 0) 返回 0'::TEXT;
    ELSE
        RETURN QUERY SELECT 'test_boundary_case'::TEXT, 'FAIL'::TEXT, format('add_two_numbers(0, 0) 应该返回 0,实际返回 %s', result)::TEXT;
    END IF;
    
    -- 测试 3:负数情况
    result := add_two_numbers(-1, -2);
    IF result = -3 THEN
        RETURN QUERY SELECT 'test_negative_case'::TEXT, 'PASS'::TEXT, 'add_two_numbers(-1, -2) 返回 -3'::TEXT;
    ELSE
        RETURN QUERY SELECT 'test_negative_case'::TEXT, 'FAIL'::TEXT, format('add_two_numbers(-1, -2) 应该返回 -3,实际返回 %s', result)::TEXT;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- 运行测试
SELECT * FROM test_add_two_numbers();

测试触发器

使用 pgTAP 测试触发器

sql
-- test_triggers.sql
BEGIN;

SELECT plan(2);

-- 准备测试数据
CREATE TEMP TABLE test_users (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 创建触发器函数
CREATE OR REPLACE FUNCTION set_created_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.created_at := CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 创建触发器
CREATE TRIGGER before_insert_users
BEFORE INSERT ON test_users
FOR EACH ROW
EXECUTE FUNCTION set_created_at();

-- 测试 1:检查触发器是否存在
SELECT has_trigger('public', 'test_users', 'before_insert_users', 'before_insert_users 触发器应该存在');

-- 测试 2:测试触发器功能
INSERT INTO test_users (name) VALUES ('Test User');
SELECT results_eq(
    'SELECT COUNT(*) FROM test_users WHERE created_at IS NOT NULL',
    'SELECT 1::bigint',
    '插入用户时应该自动设置 created_at 字段'
);

SELECT finish();

ROLLBACK;

测试视图

使用 pgTAP 测试视图

sql
-- test_views.sql
BEGIN;

SELECT plan(2);

-- 准备测试数据
CREATE TEMP TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(10, 2) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE
);

INSERT INTO products (name, price, is_active) VALUES
('Product A', 10.00, TRUE),
('Product B', 20.00, FALSE),
('Product C', 30.00, TRUE);

-- 创建视图
CREATE VIEW active_products AS
SELECT id, name, price
FROM products
WHERE is_active = TRUE;

-- 测试 1:检查视图是否存在
SELECT has_view('public', 'active_products', 'active_products 视图应该存在');

-- 测试 2:测试视图功能
SELECT results_eq(
    'SELECT COUNT(*) FROM active_products',
    'SELECT 2::bigint',
    'active_products 视图应该只返回 is_active 为 TRUE 的产品'
);

SELECT finish();

ROLLBACK;

运行单元测试

使用 psql 运行测试

bash
# 运行单个测试文件
psql -d mydb -f test_functions.sql

# 运行多个测试文件
for file in test/*.sql; do psql -d mydb -f $file; done

使用 pg_prove 运行测试

bash
# 基本用法
pg_prove -d mydb test_functions.sql

# 详细输出
pg_prove -v -d mydb test_functions.sql

# 运行所有测试文件
pg_prove -d mydb test/*.sql

# 使用特定的 PostgreSQL 用户
pg_prove -U postgres -d mydb test_functions.sql

# 使用特定的端口
pg_prove -p 5433 -d mydb test_functions.sql

集成到 CI/CD 流程

可以将单元测试集成到 CI/CD 流程中,如 GitHub Actions、GitLab CI 等。

示例:GitHub Actions 配置

yaml
# .github/workflows/test.yml
name: PostgreSQL Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Install dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y postgresql-15-pgtap libdbi-perl libdbd-pg-perl
        cpan TAP::Parser::SourceHandler::pgTAP
    
    - name: Run tests
      run: |
        # 创建扩展
        psql -h localhost -U postgres -d testdb -c "CREATE EXTENSION pgtap;"
        # 运行测试
        pg_prove -h localhost -U postgres -d testdb test/*.sql

单元测试最佳实践

1. 测试用例设计

  • 覆盖各种情况:包括正常情况、边界情况、异常情况
  • 独立性:每个测试用例应该独立运行,不依赖其他测试用例的结果
  • 原子性:每个测试用例只测试一个功能点
  • 可重复性:测试用例应该可以重复运行,每次得到相同的结果

2. 测试数据管理

  • 使用临时表:在测试中使用临时表,避免影响生产数据
  • 清理测试数据:测试完成后,清理测试数据
  • 使用事务:将测试包裹在事务中,测试完成后回滚,避免影响数据库状态

3. 测试命名规范

  • 测试文件命名:使用 test_ 前缀,如 test_functions.sqltest_triggers.sql
  • 测试函数命名:使用 test_ 前缀,如 test_add_two_numbers()
  • 测试用例命名:清晰描述测试内容,如 test_normal_casetest_boundary_case

4. 测试结果报告

  • 详细的测试结果:包含测试用例名称、状态(通过/失败)、失败原因
  • 测试覆盖率:统计测试覆盖的代码比例
  • 测试执行时间:记录每个测试用例的执行时间,便于性能分析

5. 测试维护

  • 定期更新测试:当数据库对象发生变化时,及时更新相应的测试用例
  • 删除过时的测试:移除不再需要的测试用例
  • 重构测试代码:保持测试代码的可读性和可维护性

常见问题 (FAQ)

1. 如何测试带有副作用的函数?

对于带有副作用的函数(如修改数据库状态的函数),可以:

  • 使用事务包裹测试,测试完成后回滚
  • 使用临时表作为测试数据
  • 在测试前后检查数据库状态的变化

2. 如何测试异常情况?

使用 pgTAP 的 throws_ok 函数测试异常情况:

sql
-- 测试除以零的异常
SELECT throws_ok(
    'SELECT 1 / 0',
    '22012',
    'division by zero',
    '除以零应该抛出异常'
);

3. 如何测试存储过程?

存储过程的测试与函数类似,可以:

  • 测试存储过程的执行结果
  • 测试存储过程对数据库状态的影响
  • 测试存储过程的异常处理

4. 如何衡量测试覆盖率?

PostgreSQL 没有内置的测试覆盖率工具,但可以使用以下方法:

  • 手动统计测试覆盖的代码比例
  • 使用第三方工具,如 pgcov
  • 根据测试用例的数量和质量评估测试覆盖率

5. 如何测试大型数据库应用?

对于大型数据库应用,可以:

  • 模块化测试:将测试分为多个模块,如函数测试、触发器测试、视图测试等
  • 分层测试:从底层函数到上层应用逻辑逐层测试
  • 集成测试:测试不同模块之间的交互
  • 性能测试:测试数据库应用的性能表现

6. 如何在测试中模拟外部依赖?

可以使用以下方法模拟外部依赖:

  • 创建模拟函数,替代外部函数的调用
  • 使用视图或临时表模拟外部数据源
  • 使用 PL/pgSQL 的 EXECUTE 动态执行 SQL,根据测试环境调整执行逻辑

7. 如何自动化测试?

将单元测试集成到 CI/CD 流程中,实现自动化测试:

  • 使用 pg_prove 或其他测试运行器自动化运行测试
  • 在代码提交或合并时自动触发测试
  • 生成测试报告,便于查看测试结果

8. 如何调试失败的测试?

当测试失败时,可以:

  • 查看详细的错误信息,了解失败原因
  • 使用 RAISE NOTICERAISE DEBUG 在测试中输出调试信息
  • 在测试中添加临时的查询,检查数据库状态
  • 使用 psql 手动运行测试用例,逐步调试

总结

PostgreSQL 单元测试是确保数据库代码质量和可靠性的重要手段。通过选择合适的测试框架(如 pgTAP),编写全面的测试用例,并将测试集成到 CI/CD 流程中,可以提高数据库开发的效率和质量。

在编写单元测试时,需要注意测试用例的设计、测试数据的管理、测试结果的报告等方面,遵循最佳实践,确保测试的有效性和可维护性。

随着 PostgreSQL 应用的不断发展,单元测试将越来越重要,成为数据库开发流程中不可或缺的一部分。