Java测试入门:如何为一段代码编写单元测试

Java测试入门:如何为一段代码编写单元测试

Java测试入门:如何为一段代码编写单元测试

单元测试的目标不是证明程序“绝对正确”,而是用可重复的方式检查一小段代码在明确输入下是否产生预期结果。测试写得好,能够在修改代码后及时发现回归问题,也能帮助开发者把需求中的规则说清楚。测试写得不好,则可能只是在重复实现细节,既难维护,也容易让人误以为系统已经得到充分验证。

本文使用 Java、Maven 和 JUnit 5,从一个简单的折扣计算类开始,说明如何编写、运行和维护单元测试,并讨论边界条件、异常验证、参数化测试、常见误区以及测试在安全工作中的适用范围。

一、什么情况下适合写单元测试

单元测试适合验证一个相对独立的行为。例如,给定订单金额和折扣率,检查计算出的应付金额;给定空字符串,检查格式化方法是否按约定处理;给定不合法参数,检查方法是否拒绝输入。这里的“单元”不一定是一个方法,也可以是一个职责清晰的类或一小段协作代码。

通常值得优先测试的代码包括:

  • 包含业务规则、计算逻辑或状态转换的代码。
  • 容易出现边界错误的代码,例如金额、日期、集合索引和数量限制。
  • 被多个调用方复用,改动后影响范围较大的代码。
  • 曾经发生过缺陷、修复后需要防止问题复现的代码。
  • 输入和输出明确,能够在不启动完整应用的情况下验证的代码。

反过来,如果代码只是简单地把一次调用转发给另一个对象,单独为每一行写测试未必有价值。测试数量多,不等于测试质量高。应优先验证有意义的行为和风险,而不是机械地追求行覆盖率。

二、准备 Maven 和 JUnit 5

下面的示例使用 Java 17 和 JUnit 5。将生产代码放在 src/main/java,测试代码放在 src/test/java。Maven 的标准目录结构如下:

discount-demo/
├── pom.xml
└── src/
    ├── main/
    │   └── java/
    │       └── example/
    │           └── DiscountCalculator.java
    └── test/
        └── java/
            └── example/
                └── DiscountCalculatorTest.java

在项目根目录创建以下 pom.xml。JUnit Jupiter 提供 JUnit 5 的测试 API;Surefire 插件负责在 Maven 的测试阶段发现并运行测试。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>example</groupId>
    <artifactId>discount-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.version>5.10.2</junit.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.2.5</version>
            </plugin>
        </plugins>
    </build>
</project>

保存后,在项目根目录运行 mvn test。首次运行时,Maven 会下载依赖;随后编译主代码和测试代码,并执行测试。若命令提示找不到 Maven,需要先安装 Maven 并确认它使用的 JDK 版本满足项目要求。

三、实现一个可测试的类

示例类接收以分为单位的订单金额,以及范围为 0 到 1 的折扣率,返回折后金额,结果四舍五入到分。使用整数表示金额,可以避免直接用二进制浮点数表示货币时产生的精度误差。真实财务系统的金额规则通常更复杂,可能需要使用 BigDecimal 并明确舍入模式;这里选择整数只是为了让示例规则简洁。

package example;

public class DiscountCalculator {

    /**
     * @param amountCents 原金额,单位为分,不得为负数
     * @param discountRate 折扣率,范围为 0 到 1,包含两端
     * @return 折后金额,单位为分,按四舍五入取整
     */
    public long applyDiscount(long amountCents, double discountRate) {
        if (amountCents < 0) {
            throw new IllegalArgumentException("amountCents must not be negative");
        }
        if (!Double.isFinite(discountRate)
                || discountRate < 0.0
                || discountRate > 1.0) {
            throw new IllegalArgumentException(
                    "discountRate must be finite and between 0 and 1");
        }

        double result = amountCents * (1.0 - discountRate);
        return Math.round(result);
    }
}

方法先检查输入,再进行计算。对折扣率,代码拒绝 NaN 和正负无穷,因为这些值不是有效的折扣率。若省略检查,部分特殊浮点值可能以不直观的方式参与计算。参数约束应由需求决定;示例中的 0 和 1 是有效折扣率,分别表示不打折和全额折扣。

这个实现还有一个刻意简化之处:把较大的 long 金额转换并参与 double 运算,可能损失精度。若金额范围可能很大,或业务要求严格的十进制精度,应改用 BigDecimal,为舍入规则和允许范围制定明确约定,再针对这些约定编写测试。不能仅凭示例测试通过,就推断该实现适用于所有金额规模。

四、编写第一个单元测试

JUnit 5 使用 @Test 标记测试方法。测试通常包含三部分:准备输入(Arrange)、执行被测行为(Act)、检查结果(Assert)。测试名称应描述行为和预期,便于失败时快速定位问题。

package example;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class DiscountCalculatorTest {

    private final DiscountCalculator calculator = new DiscountCalculator();

    @Test
    void appliesTwentyPercentDiscount() {
        long result = calculator.applyDiscount(1000, 0.20);

        assertEquals(800, result);
    }
}

这个测试验证 1000 分打八折后得到 800 分。测试代码不需要 public 修饰符;JUnit Jupiter 能发现包级别的测试类和方法。实际项目中,应让测试类与对应生产类保持清晰的命名关系,例如 DiscountCalculatorTest 测试 DiscountCalculator。

断言应直接表达要验证的结果。不要在测试里计算出一个“预期值”时照搬生产代码的同一套公式,否则生产代码和测试可能犯相同的错误。例如,期望值可以根据已知业务例子直接写为 800,而不是再写一次同样的折扣计算表达式。

五、边界条件和非法输入

只测试一个普通输入通常不够。错误往往出现在规则边缘:零金额、零折扣、百分之百折扣、负数,以及刚刚超出允许范围的值。测试边界时,应先从方法契约中列出合法范围和不合法范围,再逐项覆盖,而不是随意挑选几个数字。

JUnit 5 的参数化测试适合对多组输入应用同一条规则。下面使用 @MethodSource 提供合法输入和预期结果:

package example;

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

import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class DiscountCalculatorTest {

    private final DiscountCalculator calculator = new DiscountCalculator();

    static Stream<org.junit.jupiter.params.provider.Arguments> validCases() {
        return Stream.of(
                org.junit.jupiter.params.provider.Arguments.of(0L, 0.0, 0L),
                org.junit.jupiter.params.provider.Arguments.of(1000L, 0.0, 1000L),
                org.junit.jupiter.params.provider.Arguments.of(1000L, 1.0, 0L),
                org.junit.jupiter.params.provider.Arguments.of(1000L, 0.20, 800L),
                org.junit.jupiter.params.provider.Arguments.of(101L, 0.50, 51L)
        );
    }

    @ParameterizedTest
    @MethodSource("validCases")
    void calculatesDiscount(long amount, double rate, long expected) {
        assertEquals(expected, calculator.applyDiscount(amount, rate));
    }

    @Test
    void rejectsNegativeAmount() {
        assertThrows(
                IllegalArgumentException.class,
                () -> calculator.applyDiscount(-1, 0.20)
        );
    }

    @Test
    void rejectsRateAboveOne() {
        assertThrows(
                IllegalArgumentException.class,
                () -> calculator.applyDiscount(1000, 1.01)
        );
    }

    @Test
    void rejectsNanRate() {
        assertThrows(
                IllegalArgumentException.class,
                () -> calculator.applyDiscount(1000, Double.NaN)
        );
    }

    @Test
    void rejectsInfiniteRate() {
        assertThrows(
                IllegalArgumentException.class,
                () -> calculator.applyDiscount(1000, Double.POSITIVE_INFINITY)
        );
    }
}

此处的测试类展示了完整的常见写法,可以直接替换前面的测试类。@ParameterizedTest 会针对数据源中的每一组参数运行一次测试;@MethodSource 引用同类中的静态方法。assertThrows 检查指定代码是否抛出预期类型的异常,如果没有抛出,或者抛出其他类型,测试就会失败。

测试异常时,把被测调用放在 lambda 中,只包住需要验证的操作。若先执行可能抛异常的代码,再调用 assertThrows,测试可能在断言之前就中断;若把过多逻辑塞进 lambda,测试也可能因为无关代码抛出同类型异常而误通过。通常验证异常类型已经足以表达契约;只有异常消息本身属于稳定、重要的对外约定时,才需要额外断言消息内容。

示例还测试了 101 分打五折的结果为 51 分,体现了 Math.round 的取整规则。边界测试不仅检查输入是否越界,也应检查合法边缘值的结果。对于浮点计算,测试数据应避免对二进制浮点数的表示特性作不必要假设;本例只使用规则明确的折扣率,并以整数分作为断言值。

六、运行测试并理解结果

在根目录执行:

mvn test

如果所有测试通过,Maven 会显示测试总数和成功状态。测试失败时,应查看报告中失败测试的名称、断言预期值与实际值,以及堆栈信息。Maven 通常会在 target/surefire-reports 下生成测试报告。不要只看命令最后是否返回成功,也不要忽略被跳过的测试或测试发现数量为零的情况;这些情况可能意味着测试没有按预期执行。

运行单个测试类可以使用:

mvn -Dtest=DiscountCalculatorTest test

开发过程中,先运行相关测试可以缩短反馈时间;提交前仍应运行项目规定的完整验证流程。单元测试通过只说明这些测试用例通过,不代表编译之外的打包、集成、部署或运行环境检查都已完成。

七、测试边界:什么是单元,什么不是

上面的计算类没有访问数据库、文件系统、网络或系统时钟,因此测试结果容易重复,也不依赖外部环境。若测试需要启动真实数据库、连接远程服务或依赖共享文件,它验证的可能已经是集成行为,而不只是一个孤立单元。

集成测试同样重要,只是目的不同。它检查多个组件能否正确协作,例如应用能否连接数据库、SQL 映射是否正确、HTTP 接口是否按协议返回结果。此类测试可以保留在测试套件中,但应明确其运行成本和外部依赖,避免把它们当作快速、稳定的单元测试。

遇到外部依赖时,常见做法是通过接口隔离依赖,并在单元测试中使用替身对象或模拟对象。例如,测试服务层的业务分支时,可以替换消息发送器,检查服务是否尝试发送指定内容,而不真正连接消息平台。模拟对象用于隔离协作者,不应成为测试本身的主要目标:如果测试只验证“调用了某个方法”,却没有检查用户可见的结果或重要状态,往往价值有限。

不要为了让测试方便而把生产代码拆成许多没有业务意义的小方法,也不要因为某个方法是 private 就反射调用它进行测试。通常应通过公开行为验证内部实现;如果私有逻辑复杂到很难验证,可以评估它是否承担了独立职责,是否应该成为一个清晰的协作类。

八、常见错误及改进方法

1. 只测正常情况

只测试一个“看起来合理”的输入,容易漏掉零值、最大最小值、空值和非法值。改进方法是先写出参数的合法范围,再测试范围内的边缘值和至少一个越界值。若规则有多个分支,每个分支都应考虑是否需要独立用例。

2. 测试之间相互依赖

一个测试先写入共享数据,另一个测试再读取,会让执行顺序和并行运行影响结果。每个测试应尽量自行准备所需状态,并在执行后保持隔离。JUnit 不保证测试方法按源码顺序执行;依赖顺序通常是设计缺陷,不应通过强行排序来掩盖。

3. 断言过少或没有断言

测试调用了方法,不等于验证了行为。如果没有断言,代码即使返回错误结果,测试也可能通过。应至少检查关键输出、状态变化或约定的异常。一个测试可以有多个相关断言,但不宜把互不相关的行为都塞进同一个测试方法。

4. 断言实现细节

如果测试依赖私有字段名称、内部调用顺序或具体算法步骤,重构可能在行为不变时也导致大量测试失败。优先验证公开契约:输入如何影响输出、状态是否满足约定、错误输入如何处理。只有顺序本身是业务要求时,才应验证调用顺序。

5. 使用不稳定的外部资源

测试依赖当前时间、随机数、网络服务或开发者本机文件时,可能出现偶发失败。可以把时间、随机数生成器和外部服务作为依赖传入,以便测试中使用固定值或替身;对于确实需要真实外部资源的集成测试,则应显式管理环境和清理工作。

6. 测试与实现照抄同一公式

如果测试用与生产代码相同的计算方式生成期望值,两个地方可能同时出错。应使用根据业务规则独立推导的结果、已确认的示例或权威规格来设定断言。对复杂算法,可增加性质测试或与独立实现交叉验证,但这并不意味着任意多写几组数据就能证明算法无误。

7. 追求覆盖率而忽略有效性

代码覆盖率能提示哪些代码没有被测试执行,却不能说明执行后的结果是否被正确验证。执行到一行代码但没有有效断言,仍可能漏掉缺陷。覆盖率应作为发现盲区的线索,而不是质量保证或发布安全性的证明。

九、单元测试与安全工作

单元测试可以帮助验证安全相关的输入规则和权限分支。例如,测试非法参数是否被拒绝、未经授权的操作是否失败、输出是否遵守脱敏规则。对于输入验证,应同时检查合法输入仍可正常处理,避免安全加固意外破坏正常业务。

不过,单元测试不能代替安全审查,也不能保证不存在漏洞。它通常不会自动发现依赖库中的已知漏洞、错误的部署配置、并发竞争、运行环境暴露、密码管理问题或复杂攻击链。安全性还需要根据风险组合使用代码审查、依赖漏洞扫描、静态分析、动态测试、渗透测试和运行监控等措施。测试某个拒绝分支通过,只能说明该用例下程序按预期响应,不能据此宣称系统整体安全。

测试数据也应遵守数据保护要求。不要把真实用户的密码、令牌、身份证号等敏感信息写入仓库中的测试资源或日志。可以使用专门构造的虚构数据;需要验证脱敏时,应断言输出符合脱敏规则,而不是把真实敏感数据用于测试。

十、为既有代码补测试的实用步骤

  1. 先找到可观察的行为。从调用方实际依赖的输入、输出、状态变化和异常开始,不必一上来理解所有内部细节。
  2. 确认规则来源。根据需求文档、接口契约或业务方确认结果,避免把当前实现的行为误当成正确规则。
  3. 先写一个能复现问题的测试。对于缺陷修复,先让测试在旧代码上失败,再修改实现并确认测试通过。
  4. 补充边界与非法输入。关注空值、零值、范围边缘、重复调用和状态转换,具体范围由接口契约决定。
  5. 让测试独立、可重复。每次运行都应能得到一致结果,不依赖测试执行顺序或开发者机器上的偶然状态。
  6. 测试失败时先判断层次。区分断言失败、编译错误、测试未发现和外部依赖不可用,不要一看到红色结果就修改生产代码。

结语

为一段 Java 代码编写单元测试,核心不是套用某种固定模板,而是把行为约定变成可以自动重复的检查。先选择职责明确、输入输出清晰的代码,针对正常路径、边界值和非法输入编写有意义的断言,再用 Maven 持续运行测试。保持测试独立,避免过度依赖实现细节,并清楚区分单元测试、集成测试和安全验证各自能回答的问题,测试才会成为可靠的开发工具,而不是一份容易失真的“通过证明”。

© 版权声明

相关文章