PAYATHON 2026

Java billing system 应选择哪种通用测试平台?

支付老李

结论

没有必要寻找一个”适用于所有 billing 业务的专用测试引擎”。更可行的做法是以 JUnit 5 为核心,围绕计费规则建立可扩展的场景测试层,再根据需要搭配这些工具:

  • AssertJ:断言金额、账单明细和集合。
  • Mockito:隔离支付网关、客户服务、抄表服务等外部依赖。
  • Testcontainers:基于真实数据库和消息队列开展集成测试。
  • WireMock:模拟支付、税务或第三方计费接口。
  • Cucumber 或 FitNesse:让业务人员通过表格或自然语言维护验收场景。
  • jqwik:验证”金额不得为负数""用量增加后费用不应下降”等通用性质。

这些工具本身都不是 billing engine。平台能否覆盖水费、电费、燃气费或其他账单,取决于测试模型是否把通用账单流程与各类计费规则分开。

为什么没有真正通用的 billing 测试框架

customer name、usage 等常见字段无法完整描述计费规则。不同业务可能涉及:

  • 固定费用、阶梯价格和分时价格;
  • 最低消费与封顶金额;
  • 税费、折扣、滞纳金和退款;
  • 月中开户、销户及按天分摊;
  • 费率生效日期和历史账单重算;
  • 电表回绕、估算读数和补录读数;
  • 不同币种、金额精度及舍入规则。

测试框架可以执行、组织和报告测试,却不能自行判断业务计算是否正确。测试中的预期结果仍需来自明确的费率规则、财务公式或已确认的样例账单。

推荐的平台结构

测试平台可以分成四层:

层次推荐工具主要用途
计费规则单元测试JUnit 5、AssertJ测试阶梯计价、税费、折扣、舍入等纯计算
性质与边界测试jqwik、JUnit 5 参数化测试自动生成用量,检查边界和业务不变量
组件集成测试Testcontainers、WireMock验证数据库、消息队列和外部接口
业务验收测试Cucumber 或 FitNesse用业务人员可读的场景验证完整账单

如果测试主要由 Java 开发人员维护,优先使用 JUnit 5 参数化测试。产品、财务或计费专家也需要审阅测试数据时,可以在外层加入 Cucumber 或 FitNesse,无需替换底层的 JUnit 测试。

建立可扩展的计费模型

不必为每类账单复制一套测试工程。可以定义统一的请求和计费策略,让水费、电费等规则分别实现同一个接口:

import java.math.BigDecimal;
import java.time.LocalDate;

public record BillingRequest(
        String customerId,
        BigDecimal usage,
        LocalDate periodStart,
        LocalDate periodEnd
) {
}

public record Bill(
        BigDecimal subtotal,
        BigDecimal tax,
        BigDecimal total
) {
}

public interface BillingPolicy {
    Bill calculate(BillingRequest request);
}

例如,WaterBillingPolicy 可以采用固定单价,ElectricityBillingPolicy 则可以实现阶梯价格。测试平台只依赖 BillingPolicy,增加新业务时不需要改动测试基础设施。

如果不同账单的数据差异很大,比如电力业务需要峰谷用量,而水费只需要总用量,就应为各领域定义类型明确的请求对象。不要为了形式上的”通用”把所有字段都放进 Map<String, Object>,否则编译器无法帮助发现字段拼写和类型错误。

使用数据驱动的参数化测试

下面的示例使用 JUnit 5 和 AssertJ 验证多个计费场景。金额只用于演示测试结构,不代表实际费率。

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.stream.Stream;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

class BillingPolicyTest {

    private final BillingPolicy policy = new WaterBillingPolicy(
            new BigDecimal("2.50"),
            new BigDecimal("0.06")
    );

    record BillingCase(
            String name,
            BigDecimal usage,
            BigDecimal expectedSubtotal,
            BigDecimal expectedTax,
            BigDecimal expectedTotal
    ) {
        @Override
        public String toString() {
            return name;
        }
    }

    static Stream<BillingCase> billingCases() {
        return Stream.of(
                new BillingCase(
                        "zero usage",
                        new BigDecimal("0"),
                        new BigDecimal("0.00"),
                        new BigDecimal("0.00"),
                        new BigDecimal("0.00")
                ),
                new BillingCase(
                        "normal usage",
                        new BigDecimal("12"),
                        new BigDecimal("30.00"),
                        new BigDecimal("1.80"),
                        new BigDecimal("31.80")
                )
        );
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("billingCases")
    void calculatesBill(BillingCase testCase) {
        BillingRequest request = new BillingRequest(
                "customer-1001",
                testCase.usage(),
                LocalDate.of(2026, 8, 1),
                LocalDate.of(2026, 8, 31)
        );

        Bill bill = policy.calculate(request);

        assertThat(bill.subtotal())
                .isEqualByComparingTo(testCase.expectedSubtotal());
        assertThat(bill.tax())
                .isEqualByComparingTo(testCase.expectedTax());
        assertThat(bill.total())
                .isEqualByComparingTo(testCase.expectedTotal());
    }
}

金额应使用 BigDecimal,不能使用 double。断言时还要说明是否关心小数位:

  • 30.0 和 30.00 的金额相同,但 scale 不同;
  • 只比较金额时,可以使用 AssertJ 的 isEqualByComparingTo;
  • 如果账单格式要求固定保留两位小数,还应单独断言 scale()。

把计费边界变成测试数据

阶梯计费很容易在边界处出错。假设费率在用量 100 和 200 时发生变化,至少要覆盖以下数据:

static Stream<BigDecimal> boundaryUsage() {
    return Stream.of(
            new BigDecimal("0"),
            new BigDecimal("99.99"),
            new BigDecimal("100.00"),
            new BigDecimal("100.01"),
            new BigDecimal("199.99"),
            new BigDecimal("200.00"),
            new BigDecimal("200.01")
    );
}

除价格阶梯外,还应测试:

  • 费率生效日前一天、生效当天和后一天;
  • 账期第一天、最后一天及闰年;
  • 零用量、负用量和异常大的用量;
  • 月中开户、暂停和销户;
  • 重复抄表或重复支付请求;
  • 退款、冲正和重新出账;
  • 多线程或消息重试时的幂等性。

可以定义通用业务不变量

除了逐笔核对预期金额,还可以验证适用于多个场景的规则:

assertThat(bill.subtotal()).isGreaterThanOrEqualTo(BigDecimal.ZERO);
assertThat(bill.tax()).isGreaterThanOrEqualTo(BigDecimal.ZERO);
assertThat(bill.total())
        .isEqualByComparingTo(bill.subtotal().add(bill.tax()));

如果没有封顶、补贴或特殊折扣,还可以检查:

  • 费率相同时,用量增加不应导致总费用下降;
  • 同一输入重复计算应得到相同结果;
  • 重试已完成的账单时,不能重复创建收费记录;
  • 各项账单明细之和必须等于账单总额。

这些前提要明确写入测试名称或测试数据。并非所有 billing 业务都满足单调性,例如跨档补贴或封顶规则就可能使这一性质失效。

开源与商业工具如何选择

对大多数 Java billing module,以下开源工具已经足够:

  • JUnit 5 + AssertJ;
  • Mockito;
  • Testcontainers;
  • WireMock;
  • 根据实际需要选用 Cucumber、FitNesse 或 jqwik。

商业平台更适合跨系统流程、低代码自动化、统一测试治理或合规报告。例如,测试可能需要同时操作 Web 页面、ERP、支付系统和桌面程序。选型时可以考察 Tricentis Tosca、Parasoft Jtest、SmartBear ReadyAPI 等产品,但要先确认它们是否支持现有 Java 技术栈、CI/CD、部署方式和授权预算。这些产品同样无法自动生成正确的计费预期值。

实施建议

可以先建立一个小型测试基线:

  1. 使用 JUnit 5 覆盖核心计费公式。
  2. 将费率边界、账期和舍入规则整理成参数化数据。
  3. 使用 Testcontainers 验证账单持久化、事务和迁移脚本。
  4. 使用 WireMock 模拟支付或客户系统。
  5. 只有业务人员确实需要直接维护场景时,才引入 Cucumber 或 FitNesse。
  6. 在 CI 中分别运行快速的单元测试和耗时较长的集成测试。

测试预期必须独立于被测实现。不要在测试代码中复制生产代码的计算逻辑,否则两边可能以相同的方式出错。复杂账单应以财务人员确认的样例、人工计算结果或经过审批的费率表作为测试依据。

备注:内容仅供参考。