JUnit测试框架

面向对象程序设计(Java)

掌握测试驱动开发,提升代码质量和可靠性

测试基础概念

📖 场景:计算器的噩梦

你写了一个计算器类,有个 add(int a, int b) 方法用来做加法:

public int add(int a, int b) {
    return a + b;
}

😱 没有单元测试会发生什么?

  • 每次修改代码后,都要手动打开计算器,一遍遍输入数字测试
  • 改了其他地方的代码,不确定会不会影响这个加法功能
  • 代码提交到生产环境,用户报错说"1+1=3",你一脸懵
  • 想重构代码,但不敢动,怕改出bug

😊 有了单元测试会发生什么?

  • 写好测试用例,一键运行,自动验证所有功能
  • 改代码后,跑一遍测试,立马知道有没有破坏原有功能
  • 测试代码就是活的文档,一看就知道这个方法怎么用
  • 想怎么重构就怎么重构,测试不通过就改,改到通过为止

什么是单元测试?

单元测试就是用代码测试代码。在Java中,通常是针对一个类或一个方法写测试,验证它是否按预期工作。

🎯 测试的基本原则

  • 独立性:测试之间不应相互依赖,每个测试应该能单独运行
  • 可重复性:多次运行应得到相同结果,不能依赖外部状态
  • 快速性:单元测试应该快速执行,几秒钟跑完好多个

JUnit简介

JUnit是什么?

JUnit是Java最流行的单元测试框架,自1997年诞生以来,已成为Java开发的标准工具。它提供了一套简单而强大的API来编写、运行和验证测试代码。

📊 JUnit 5的架构

JUnit 5由三个子模块组成,采用模块化设计:

模块 说明
JUnit Platform 测试引擎基础平台,支持多种测试引擎运行
JUnit Jupiter JUnit 5核心,包含新的编程模型和扩展模型
JUnit Vintage 兼容层,支持运行JUnit 3和JUnit 4的测试

🚀 版本对比

特性 JUnit 4 JUnit 5
包名 org.junit.* org.junit.jupiter.*
生命周期注解 @Before, @After, @BeforeClass, @AfterClass @BeforeEach, @AfterEach, @BeforeAll, @AfterAll
参数化测试 需要额外依赖JUnitParams 内置支持@ParameterizedTest
嵌套测试 不支持 支持@Nested
动态测试 不支持 支持@TestFactory
扩展模型 使用Runner和Rule 更灵活的Extension API
推荐程度 📅 旧项目兼容 🌟 新项目首选

⚙️ Maven依赖配置

<!-- JUnit 5 完整依赖(包含Platform、Jupiter、Vintage) -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

<!-- 仅运行测试所需(如果使用Maven Surefire插件) -->
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-launcher</artifactId>
    <version>1.10.0</version>
    <scope>test</scope>
</dependency>

第一个JUnit测试 (1)

让我们从一个简单的计算器类开始,编写第一个测试。

📝 被测试的类

// src/main/java/com/example/Calculator.java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }
}

这是我们要测试的简单计算器类,包含基本的算术运算方法。 接下来我们将为它编写第一个测试。

第一个JUnit测试 (2)

🧪 编写测试类

// src/test/java/com/example/CalculatorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    @Test
    void testAdd() {
        // Arrange(准备)
        Calculator calculator = new Calculator();
        int a = 3;
        int b = 5;
        int expected = 8;

        // Act(执行)
        int result = calculator.add(a, b);

        // Assert(验证)
        assertEquals(expected, result);
    }
}

🎯 AAA模式说明

  • Arrange:准备测试数据和对象
  • Act:执行被测试的方法
  • Assert:验证结果是否符合预期

在IDEA中,右键点击测试类或测试方法,选择"Run 'CalculatorTest'"即可执行测试。 绿色✅表示测试通过,红色❌表示测试失败。

常用断言方法

JUnit提供了丰富的断言方法来验证测试结果。以下是常用的断言:

🔢 数值和对象比较

@Test
void testAssertions() {
    // 相等性断言
    assertEquals(4, 2 + 2);
    assertEquals("hello", "HELLO".toLowerCase());

    // 不等性断言
    assertNotEquals(5, 2 + 2);
    assertNotEquals("hello", "HELLO");

    // 对象相等性(重写equals()方法)
    String str1 = new String("test");
    String str2 = new String("test");
    assertEquals(str1, str2); // 比较内容
    assertNotSame(str1, str2); // 不是同一个对象
}

assertEquals 比较内容,assertNotSame 比较对象引用地址。

更多断言方法

✅ 布尔条件断言

@Test
void testBooleanAssertions() {
    String text = "Hello World";

    // 条件断言
    assertTrue(text.contains("Hello"));
    assertFalse(text.isEmpty());

    // 空值断言
    String nullStr = null;
    String emptyStr = "";

    assertNull(nullStr);
    assertNotNull(emptyStr);
}

⚠️ 异常断言

@Test
void testException() {
    // 测试是否抛出指定异常
    assertThrows(IllegalArgumentException.class, () -> {
        divideByZero();
    });

    // 也可以获取异常对象进行进一步验证
    Exception exception = assertThrows(ArithmeticException.class, () -> {
        int result = 10 / 0;
    });

    assertEquals("/ by zero", exception.getMessage());
}

private void divideByZero() {
    throw new IllegalArgumentException("不能除以零");
}

测试生命周期

JUnit提供了多个注解来控制测试方法的执行顺序和资源管理。

🔄 生命周期注解

import org.junit.jupiter.api.*;

class LifecycleTest {

    @BeforeAll
    static void setupAll() {
        System.out.println("🔧 在所有测试开始前执行一次");
        // 适合初始化昂贵的资源(数据库连接等)
    }

    @BeforeEach
    void setUp() {
        System.out.println("⚡ 在每个测试方法前执行");
        // 适合准备测试数据
    }

    @Test
    void test1() {
        System.out.println("📝 执行测试1");
    }

    @AfterEach
    void tearDown() {
        System.out.println("🧹 在每个测试方法后执行");
        // 适合清理测试数据
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("🏁 在所有测试结束后执行一次");
        // 适合释放昂贵的资源
    }
}

最佳实践:使用@BeforeEach/@AfterEach保证测试独立性, 使用@BeforeAll/@AfterAll提高性能(如数据库连接)。

参数化测试 (1)

参数化测试允许我们使用不同的输入参数多次运行同一个测试方法。

🎯 @ValueSource 数据源

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

class ParameterizedTestExample {

    // 测试多个回文字符串
    // @ValueSource 会为每个数组元素运行一次测试
    // 相当于执行了4次独立的测试
    @ParameterizedTest
    @ValueSource(strings = {"racecar", "madam", "level", "rotor"})
    void testPalindromes(String word) {
        assertTrue(isPalindrome(word));
    }

    // 测试多个正整数
    // 测试5次,分别验证1, 2, 3, 4, 5都大于0
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void testPositiveNumbers(int number) {
        assertTrue(number > 0);
    }

    // 辅助方法:判断是否为回文
    private boolean isPalindrome(String word) {
        return word.equals(new StringBuilder(word).reverse().toString());
    }
}

@ValueSource 适合测试简单的单参数场景, 如字符串数组、数值数组等。

参数化测试 (2)

📋 @CsvSource 数据源

@ParameterizedTest
@CsvSource({
    "2, 3, 5",        // a=2, b=3, expected=5
    "0, 0, 0",        // a=0, b=0, expected=0
    "-1, 1, 0",       // a=-1, b=1, expected=0
    "10, -5, 5"       // a=10, b=-5, expected=5
})
void testAdd(int a, int b, int expected) {
    Calculator calculator = new Calculator();
    assertEquals(expected, calculator.add(a, b));
}

🏭 @MethodSource 数据源

@ParameterizedTest
@MethodSource("provideTestData")
void testMultiply(int a, int b, int expected) {
    Calculator calculator = new Calculator();
    assertEquals(expected, calculator.multiply(a, b));
}

// 提供测试数据的方法
// 必须返回Stream或Arguments[]等类型
static Stream provideTestData() {
    return Stream.of(
        Arguments.of(2, 3, 6),      // 2 * 3 = 6
        Arguments.of(0, 5, 0),      // 0 * 5 = 0
        Arguments.of(-2, 4, -8),    // -2 * 4 = -8
        Arguments.of(10, 10, 100)   // 10 * 10 = 100
    );
}

注意:使用参数化测试需要添加依赖:junit-jupiter-params

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.9.3</version>
    <scope>test</scope>
</dependency>

@CsvSource 适合多参数测试,@MethodSource 适合复杂的测试数据生成逻辑。

测试套件和分组

当测试数量增多时,我们需要组织和分类测试。JUnit提供了标签(@Tag)和测试套件功能。

🏷️ 使用@Tag分组测试

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("slow")  // 标记为慢速测试
class SlowTests {
    @Test
    @Tag("database")  // 可以有多个标签
    void testDatabaseConnection() {
        // 模拟耗时的数据库操作
        System.out.println("连接数据库测试...");
    }
}

@Tag("fast")
@Tag("unit")
class FastTests {
    @Test
    void testStringOperations() {
        assertEquals("HELLO", "hello".toUpperCase());
    }

    @Test
    void testMathOperations() {
        assertEquals(4, Math.addExact(2, 2));
    }
}

🎯 按标签运行测试

通过Maven运行

# 只运行快速测试
mvn test -Dgroups=fast

# 排除慢速测试
mvn test -DexcludedGroups=slow

# 运行数据库相关的测试
mvn test -Dgroups=database

# 组合条件:运行快速测试但排除数据库测试
mvn test -Dgroups=fast -DexcludedGroups=database

在IDEA中运行

<!-- 在pom.xml中配置JUnit Platform -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0</version>
    <configuration>
        <groups>fast</groups>  <!-- 只运行标记为fast的测试 -->
        <excludedGroups>slow</excludedGroups>  <!-- 排除慢速测试 -->
    </configuration>
</plugin>

使用标签可以有效管理大型项目的测试,在CI/CD中特别有用(如只运行快速测试)。 例如:在每次提交代码时只运行@Tag("fast")的单元测试, 在每天定时任务中运行包括@Tag("slow")在内的所有测试。

Mock测试 (1) - 为什么需要Mock

🎭 什么是Mock?

Mock(模拟)是指在测试中创建一个假的依赖对象,用来替代真实的组件。这个假对象可以模拟真实对象的行为,但完全受我们控制。

// 真实场景:UserService依赖UserRepository和EmailService
public class UserService {
    private UserRepository userRepository;  // 真实会访问数据库
    private EmailService emailService;    // 真实会发送邮件

    public void registerUser(User user) {
        userRepository.save(user);           // 写入数据库
        emailService.sendWelcome(user);      // 发送邮件
    }
}

// 测试时:我们不想真的写数据库、发邮件
// 所以我们创建Mock对象
UserRepository mockRepo = Mockito.mock(UserRepository.class);
EmailService mockEmail = Mockito.mock(EmailService.class);

Mock的核心特点:

  • 🎯 可控制:可以预定义方法的返回值
  • ⚡ 快速:没有真实IO操作,测试瞬间完成
  • 🔒 隔离:不依赖外部环境,测试结果稳定
  • 📊 可验证:可以检查方法是否被调用、调用了几次

🤔 为什么需要使用Mock?

场景 问题描述 Mock解决方案
外部依赖 依赖数据库、网络API、文件系统 创建Mock对象,避免真实调用
性能问题 依赖组件执行缓慢(如数据库查询) Mock对象瞬时响应,测试快速执行
不稳定因素 外部服务可能宕机或返回错误 Mock行为可控,测试结果稳定
成本问题 调用第三方API需要付费 Mock免费使用,降低测试成本

🎭 什么是Mock对象?

  • 行为可控制:可以预定义方法的返回值和异常
  • 调用可验证:可以验证方法是否被调用,调用次数和参数
  • 轻量级:不需要真实的依赖环境
  • 隔离性:确保测试只关注目标类的逻辑

Mock测试的核心思想是"隔离",让我们专注于测试目标类的逻辑,而不受外部依赖的影响。

Mock测试 (2) - Mock工具和数据生成

🛠️ Mock工具对比

工具 特点 优点 缺点
Mockito 最流行的Mock框架 API简洁、文档完善、社区活跃 不支持静态方法Mock
EasyMock 传统Mock框架 历史悠久、稳定可靠 API相对繁琐、学习曲线陡峭
PowerMock 扩展Mock能力 支持静态方法、私有方法、构造函数 依赖复杂、性能较慢

📦 Maven依赖

<!-- Mockito - 推荐首选 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.5.0</version>
    <scope>test</scope>
</dependency>

<!-- PowerMock - 需要Mock静态方法时使用 -->
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>2.0.9</version>
    <scope>test</scope>
</dependency>

📚 测试数据生成工具对比

工具 特点 适用场景
Faker 生成真实感数据 姓名、地址、邮箱等模拟数据
DataFactory 测试数据生成工厂 大量随机数据生成

📦 数据生成工具依赖

<!-- Faker - 生成真实感数据 -->
<dependency>
    <groupId>com.github.javafaker</groupId>
    <artifactId>javafaker</artifactId>
    <version>1.0.2</version>
</dependency>

💡 Faker使用示例

Faker faker = new Faker();
String name = faker.name().fullName();           // "张三"
String email = faker.internet().emailAddress(); // "zhangsan@example.com"
int age = faker.number().numberBetween(18, 65);  // 25-65之间的随机数

推荐优先使用 Mockito 进行Mock,配合 Faker 生成真实的测试数据。如需Mock静态方法,再考虑引入 PowerMock。

Mock测试 (3) - Mockito基础使用

� 待测试代码:OrderService

public class OrderService {
    private PaymentService paymentService;
    private InventoryService inventoryService;

    public OrderService(PaymentService paymentService, InventoryService inventoryService) {
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
    }

    public OrderResult createOrder(Order order) {
        // 检查库存
        inventoryService.checkStock(order.getProductId(), order.getQuantity());
        // 执行扣款
        PaymentResult paymentResult = paymentService.charge(order.getAmount());
        if (paymentResult.isSuccess()) {
            // 扣减库存
            inventoryService.reduceStock(order.getProductId(), order.getQuantity());
            return new OrderResult(true, "订单创建成功");
        }
        return new OrderResult(false, "支付失败");
    }
}

🔴 不使用Mock:测试困难重重

@Test
void testCreateOrder() {
    PaymentService realPayment = new RealPaymentService();
    InventoryService realInventory = new RealInventoryService();
    OrderService orderService = new OrderService(realPayment, realInventory);

    Order order = new Order("PROD001", 2, new BigDecimal("100.00"));

    OrderResult result = orderService.createOrder(order);

    assertTrue(result.isSuccess());

    // 问题:
    // 1. 真的扣钱了!❌
    // 2. 真的减少库存了!❌
    // 3. 测试慢(要连数据库、支付网关)❌
    // 4. 外部服务不稳定可能导致测试失败 ❌
    // 5. 每次测试都有副作用,数据难清理 ❌
}

🟢 使用Mockito:测试又快又稳

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.github.javafaker.Faker;
import static org.junit.jupiter.api.Assertions.*;

@Test
void testCreateOrder_Success() {
    Faker faker = new Faker();

    // 1. 创建Mock对象:模拟依赖服务
    PaymentService mockPayment = Mockito.mock(PaymentService.class);
    InventoryService mockInventory = Mockito.mock(InventoryService.class);

    // 2. 注入Mock对象到被测试的服务
    OrderService orderService = new OrderService(mockPayment, mockInventory);

    // 3. 使用Faker准备测试数据:生成真实感的订单信息
    // faker.number().digits(3):生成3位随机数字(如"123"、"456"),用于构建产品ID
    String productId = "PROD" + faker.number().digits(3);
    // faker.number().numberBetween(1, 10):生成1到10之间的随机整数,表示购买数量
    int quantity = faker.number().numberBetween(1, 10);
    // faker.commerce().price():生成商业价格(如"123.45"、"1,234.56")
    BigDecimal amount = new BigDecimal(faker.commerce().price().replace(",", ""));
    Order order = new Order(productId, quantity, amount);

    // 4. 配置Mock行为:当调用这些方法时,返回预设值
    Mockito.when(mockInventory.checkStock(productId, quantity)).thenReturn(true);
    Mockito.when(mockPayment.charge(amount))
            .thenReturn(new PaymentResult(true, "支付成功"));

    // 5. 执行被测试方法
    OrderResult result = orderService.createOrder(order);

    // 6. 验证结果:断言返回值符合预期
    assertTrue(result.isSuccess());
    assertEquals("订单创建成功", result.getMessage());

    // 7. 验证交互:确认Mock对象被正确调用
    Mockito.verify(mockInventory).checkStock(productId, quantity);
    Mockito.verify(mockInventory).reduceStock(productId, quantity);
    Mockito.verify(mockPayment).charge(amount);
}

Mockito的核心价值:隔离依赖、控制行为、验证调用。
测试只关注OrderService的业务逻辑,不关心支付和库存的具体实现。

Mock测试 (4) - 高级Mock技巧

🔍 常用Mock方法详解

1️⃣ mock() - 创建Mock对象

List mockList = Mockito.mock(List.class);

说明:创建一个假的对象,所有方法调用都返回默认值(null、0、false等)。
使用场景:当你不需要真实对象,只想控制其行为时使用。

2️⃣ when().thenReturn() - 定义返回值

Mockito.when(mockList.get(0)).thenReturn("Hello");
Mockito.when(mockList.size()).thenReturn(1);

说明:告诉Mock对象,当调用指定方法时返回什么值。
使用场景:模拟外部依赖的返回结果,测试不同的业务路径。
注意:参数必须精确匹配,"Hello" 和 "hello" 是不同的调用。

3️⃣ when().thenThrow() - 定义异常

Mockito.when(mockList.get(1)).thenThrow(new IndexOutOfBoundsException());

说明:让Mock对象在调用时抛出异常。
使用场景:测试异常处理逻辑,确保代码能正确处理错误情况。

4️⃣ 链式thenReturn() - 多次调用返回不同值

Mockito.when(mockList.get(0)).thenReturn("First").thenReturn("Second");
// 第一次调用返回"First",第二次及以后返回"Second"

说明:连续调用时返回不同的值。
使用场景:测试需要多次调用同一方法的场景(如重试机制)。

5️⃣ verify() - 验证方法调用

Mockito.verify(mockList).get(0);                         // 验证get(0)被调用1次
Mockito.verify(mockList, Mockito.times(2)).add(Mockito.any());  // 验证add()被调用2次
Mockito.verify(mockList, Mockito.never()).clear();       // 验证clear()从未被调用

说明:检查Mock对象的方法是否被调用,以及调用次数。
使用场景:验证业务逻辑是否按预期执行(如:订单创建时是否真的调用了支付服务)。
验证模式:times(n)、never()、atLeastOnce()、atMost(n)等。

💡 高级Mock技巧

1️⃣ spy() - 部分Mock,保留真实方法

List spyList = Mockito.spy(new ArrayList<>());
spyList.add("real");                                    // 调用真实方法
Mockito.when(spyList.size()).thenReturn(100);          // Mock size方法

说明:创建一个"间谍"对象,默认调用真实方法,但可以Mock特定方法。
使用场景:大部分逻辑用真实实现,只Mock少量依赖的方法。
注意:不要Mock被spy对象的其他方法调用,否则会调用真实方法而非Mock版本。

2️⃣ ArgumentCaptor - 参数捕获

ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
Mockito.verify(emailService).sendEmail(captor.capture(), Mockito.anyString());
String capturedEmail = captor.getValue();              // 获取捕获的参数

说明:捕获方法调用时的参数值,用于验证传入的参数是否符合预期。
使用场景:当你需要验证传递给方法的参数内容(如:发送的邮件地址、插入数据库的数据)。
注意:只能在verify()中使用,不能在when()中使用。

3️⃣ thenAnswer() - 自定义返回逻辑

Mockito.when(mockList.get(Mockito.anyInt())).thenAnswer(invocation -> {
    Integer index = invocation.getArgument(0);
    return "Item " + index;
});

说明:根据调用参数动态计算返回值,比固定的thenReturn更灵活。
使用场景:返回值依赖于输入参数,需要基于参数计算返回结果时。
注意:可以访问invocation对象获取所有参数和方法信息。

4️⃣ doNothing() / doThrow() - Void方法Mock

Mockito.doNothing().when(mockList).clear();                           // Void方法不执行任何操作
Mockito.doThrow(new RuntimeException()).when(mockList).clear();         // Void方法抛异常

说明:Mock没有返回值的方法(void方法)的行为。
使用场景:当调用void方法时不希望执行真实逻辑,或者让void方法抛出异常。
注意:必须在when()之前使用doXxx(),语法是`doXxx().when(mock).method()`而不是`when(mock.method()).doXxx()`。

Mock最佳实践:优先使用Mockito,配合Faker生成测试数据, 使用Builder模式创建复杂对象,让Mock测试既真实又高效!

Maven与JUnit集成 (1)

在实际开发中,我们通常使用Maven来管理项目依赖和构建过程。理解Maven如何与JUnit协作是重要的技能。

🔗 Maven与JUnit的关系

Maven构建生命周期:
validate → compile → test → package → verify → install → deploy
                          ↑
                      JUnit在这里执行

📁 Maven标准项目结构

my-project/
├── pom.xml                    # Maven配置文件
├── src/
│   ├── main/                 # 源代码
│   │   ├── java/
│   │   └── resources/
│   └── test/                 # 测试代码 (JUnit在这里运行)
│       ├── java/
│       └── resources/
└── target/                   # 编译输出目录
    ├── classes/              # 源代码编译结果
    └── test-classes/         # 测试代码编译结果

Maven会自动在 mvn test 阶段扫描并运行所有JUnit测试。

Maven与JUnit集成 (2)

🚀 在IDEA中运行Maven测试

IDEA Maven工具窗口操作:

方法1:使用Maven工具栏
1. 打开右侧Maven工具窗口
2. 展开 Lifecycle
3. 双击 "test" 命令
4. Maven会自动运行所有JUnit测试

方法2:使用快捷键
- Ctrl + Shift + F10 (运行所有测试)
- Ctrl + Shift + T (创建测试类)

⚙️ Maven surefire插件配置

<!-- pom.xml中的配置 -->
<build>
    <plugins>
        <!-- Maven Surefire Plugin - 负责运行单元测试 -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.1.2</version>
            <configuration>
                <includes>
                    <include>**/*Test.java</include>
                    <include>**/*Tests.java</include>
                </includes>
                <excludes>
                    <exclude>**/*IntegrationTest.java</exclude>
                </excludes>
            </configuration>
        </plugin>
    </plugins>
</build>

核心要点:Maven的mvn test命令会自动扫描并运行所有JUnit测试, 理解这个集成机制对于现代Java开发至关重要!

代码校验体系:不只靠“写测试”

真实项目里,“正确性”不是靠某一种手段兜底,而是靠多层防线叠加:

层次 手段 解决的问题
编译期 类型系统、编译器报错、IDE 检查 低成本提前发现问题(类型不匹配、空指针风险、未处理异常等)
运行期 参数校验、状态校验、断言(assert) 把“坏输入/坏状态”挡在边界处,减少隐蔽 Bug
测试期 JUnit 单元测试/集成测试 把需求变成可重复验证的规则,支持重构
线上期 日志、监控、告警 快速定位异常路径,降低 MTTR(修复平均时间)

一句话:断言/契约让“问题更早爆炸”,JUnit 让“爆炸可重复”,日志让“爆炸可定位”。

运行期校验 ①:assert(断言)

断言用于检查程序员认为“不可能发生”的内部不变式,默认关闭,适合开发/测试环境。

public final class Range {
    private final int start;
    private final int end;

    public Range(int start, int end) {
        assert start <= end : "start must be <= end";
        this.start = start;
        this.end = end;
    }

    public int size() {
        int size = end - start;
        assert size >= 0;
        return size;
    }
}

如何启用断言?

# 启用所有断言
java -ea com.example.Main

# 禁用所有断言(默认就是禁用)
java -da com.example.Main
  • 不要用断言做用户输入校验:生产环境可能没开 -ea
  • 用户/接口边界校验:用 IllegalArgumentException/NullPointerException(由 API 约定)更可靠

提示:你可以用 SomeClass.class.desiredAssertionStatus() 检查某个类的断言是否启用。

运行期校验 ②:契约式设计(Design by Contract)

把一个方法当成“合同”,通常包含三类条款:

  • 前置条件(Precondition):调用者必须满足
  • 后置条件(Postcondition):方法返回后必须成立
  • 不变式(Invariant):对象整个生命周期都必须成立
import java.util.Objects;

public final class BankAccount {
    private long balance;

    public void deposit(Long amount) {
        Objects.requireNonNull(amount, "amount");
        if (amount <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        long old = balance;
        balance += amount;
        if (balance < old) {
            throw new IllegalStateException("overflow");
        }
    }
}

经验法则:断言更像“开发者自检”,异常更像“对外契约”。对外接口优先用异常表达契约。

测试 + 日志:让可验证与可定位同时成立

TDD(红-绿-重构)

  1. 红:先写一个失败的测试,明确“我想要什么”
  2. 绿:写最少代码让测试通过
  3. 重构:在测试保护下优化结构
@Test
void parse_invalidInput_throwsException() {
    assertThrows(IllegalArgumentException.class,
        () -> Parser.parse("x=??"));
}

日志级别(让问题可定位)

  • ERROR:必须修的失败路径
  • WARN:异常但可继续
  • INFO:关键业务里程碑
  • DEBUG/TRACE:排查细节(默认不打开)
import java.util.logging.Level;
import java.util.logging.Logger;

public class App {
    private static final Logger LOG = Logger.getLogger(App.class.getName());

    void pay(long amount) {
        LOG.log(Level.INFO, "pay start amount={0}", amount);
    }
}

分工:测试用于“验证行为”,日志用于“定位路径”。不要用日志代替测试,也不要用测试去验证日志内容。

测试最佳实践

编写高质量、可维护的测试需要遵循一些重要的原则和模式。

📝 测试命名规范

// ❌ 不好的命名
@Test
void test1() { }
@Test
void testCalculator() { }

// ✅ 好的命名(遵循:methodName_condition_expectedResult)
@Test
void add_twoPositiveNumbers_returnsCorrectSum() { }
@Test
void divide_byZero_throwsArithmeticException() { }
@Test
void withdraw_insufficientBalance_throwsException() { }

// 或者使用 given_when_then 格式
@Test
void givenValidAmount_whenDeposit_thenBalanceIncreases() { }

🏗️ AAA模式(Arrange-Act-Assert)

@Test
void transferMoney_sufficientBalance_updatesBothAccounts() {
    // Arrange(准备)- 设置测试数据和初始状态
    Account fromAccount = new Account(1000);
    Account toAccount = new Account(500);
    Bank bank = new Bank();
    bank.addAccount(fromAccount);
    bank.addAccount(toAccount);

    // Act(执行)- 调用被测试的方法
    bank.transferMoney(fromAccount, toAccount, 300);

    // Assert(验证)- 检查结果是否符合预期
    assertEquals(700, fromAccount.getBalance());
    assertEquals(800, toAccount.getBalance());
}

好的测试不仅是质量的保障,更是代码的活文档。 遵循这些实践原则,让测试成为开发的有力工具!

1 / X