1. 项目概述:为什么JUnit4与Hamcrest是Java单元测试的黄金搭档

如果你写过Java单元测试,那你肯定用过JUnit。但很多时候,光用JUnit的 assertEquals assertTrue ,写出来的测试代码会变得又臭又长,可读性极差。比如,你想验证一个返回的列表不为空、包含特定元素、并且元素数量大于5,用原生JUnit你得写好几个断言,测试失败时信息也不够清晰。这就是Hamcrest登场的时候了。这个项目标题“Java单元测试必备库JUnit4与Hamcrest实战指南”,直指一个核心痛点:如何写出表达力强、易于维护的单元测试。JUnit4提供了测试框架的骨架,而Hamcrest则提供了丰富、可组合的“匹配器”(Matcher),让断言读起来就像一句自然语言。这对组合不是简单的1+1,而是能让你从“能测试”进化到“写好测试”的关键。无论是刚入门的新手,还是被凌乱断言困扰的老手,掌握它都能让你的测试代码质量上一个台阶。

2. 环境搭建与基础配置

2.1 依赖引入:Maven与Gradle配置详解

万事开头难,但配置依赖其实很简单。现在Java项目管理基本离不开Maven或Gradle,我们分别来看。

对于Maven项目,在你的 pom.xml 文件的 <dependencies> 部分加入以下内容。注意,JUnit4和Hamcrest通常都放在 test 作用域,因为它们是测试专用库,不会打包进最终的生产代码。

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest-library</artifactId>
    <version>2.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest-core</artifactId>
    <version>2.2</version>
    <scope>test</scope>
</dependency>

这里有个细节: hamcrest-core 包含了最基础的匹配器,而 hamcrest-library 扩展了大量实用的匹配器(比如集合、数字、文本匹配)。通常你只需要声明 hamcrest-library ,因为它会依赖 hamcrest-core 。但有些老项目或者为了明确,两者都写上也没问题。版本方面,JUnit 4.13.2是4.x系列的最终版,非常稳定;Hamcrest 2.2也是主流选择。

如果你用的是Gradle(Kotlin DSL示例),在 build.gradle.kts dependencies 块里添加:

dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.hamcrest:hamcrest-library:2.2")
}

testImplementation 就等同于Maven的 test 作用域。配置完成后,记得刷新一下你的项目(Maven的Reimport,Gradle的Refresh),让IDE识别新依赖。

2.2 IDE集成与测试类创建规范

依赖搞定后,IDE(比如IntelliJ IDEA或Eclipse)会自动识别。创建一个测试类非常简单。通常的规范是,在 src/test/java 目录下,建立与生产代码对应的包结构。例如,你的生产类 com.example.service.UserService src/main/java 里,那么测试类 UserServiceTest 就应该放在 src/test/java/com/example/service 下。

在IDEA里,你可以直接在生产类名上按 Alt+Enter (Windows/Linux)或 Option+Enter (Mac),选择“Create Test”,它会帮你自动生成测试类骨架。这里有个实操心得: 测试类的命名强烈建议以 Test 结尾 ,比如 CalculatorTest 。这是Maven Surefire插件(负责执行测试)的默认约定,它能自动发现并运行这些测试。如果你命名为 CalculatorTestSuite 或者 TestCalculator ,可能需要额外配置插件,徒增烦恼。

生成的测试类大概长这样:

import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {
    @Test
    public void testAdd() {
        // TODO: 测试逻辑
    }
}

注意顶部的静态导入: import static org.junit.Assert.*; 。这是使用JUnit断言的标准方式。但接下来,我们会用Hamcrest的 assertThat 来替代它,所以这个导入后续可能会被替换或共存。

3. JUnit4核心机制与生命周期

3.1 注解驱动:@Test, @Before, @After, @BeforeClass, @AfterClass

JUnit4最大的进步就是从继承 TestCase 和命名约束(方法名必须 test 开头)变成了注解驱动。这几个注解构成了测试的生命周期。

  • @Test : 这是核心,标记一个方法为测试方法。方法必须是 public void ,可以带参数(但通常不带)。你可以通过 @Test(timeout=1000) 来设置超时(毫秒),如果测试执行超过这个时间就失败;也可以用 @Test(expected=NullPointerException.class) 来声明期望抛出某个异常。
  • @Before @After : 这对注解标记的方法会在 每一个 @Test 方法执行 之前 之后 运行。 @Before 通常用来初始化测试数据(比如每个测试都需要一个新的、干净的对象), @After 用来清理资源(比如关闭数据库连接、删除临时文件)。注意,即使 @Test 方法抛出异常, @After 方法也 保证会执行 ,这很重要。
  • @BeforeClass @AfterClass : 这两个注解标记的方法是 static 的,它们在 整个测试类 的所有测试方法执行 之前 之后 运行,且只运行一次。 @BeforeClass 适合做重量级的、一次性的初始化,比如建立数据库连接池; @AfterClass 则用来关闭这些全局资源。

来看一个完整的例子,假设我们测试一个简单的文件处理器:

import org.junit.*;
import java.io.*;

public class FileProcessorTest {
    private static File sharedResource;
    private FileProcessor processor;
    private File tempFile;

    @BeforeClass
    public static void setUpClass() {
        System.out.println("初始化全局资源,例如数据库连接");
        sharedResource = new File("/tmp/shared.log");
    }

    @AfterClass
    public static void tearDownClass() throws IOException {
        System.out.println("清理全局资源");
        if (sharedResource.exists()) {
            sharedResource.delete();
        }
    }

    @Before
    public void setUp() throws IOException {
        System.out.println("为每个测试准备独立环境");
        processor = new FileProcessor();
        tempFile = File.createTempFile("test", ".txt");
        // 写入一些初始数据到tempFile
    }

    @After
    public void tearDown() {
        System.out.println("清理每个测试的现场");
        if (tempFile.exists()) {
            tempFile.delete();
        }
        processor = null;
    }

    @Test
    public void testProcessFile() {
        // 使用tempFile和processor进行测试
        // ...
    }

    @Test(timeout = 500)
    public void testProcessShouldNotTimeout() {
        // 一个应该很快完成的测试
        // ...
    }
}

运行这个测试类,控制台输出的顺序会是: setUpClass -> setUp -> testProcessFile -> tearDown -> setUp -> testProcessShouldNotTimeout -> tearDown -> tearDownClass 。清晰的生命周期管理是编写稳定、独立测试用例的基石。

3.2 测试套件与忽略测试:@RunWith, @Suite, @Ignore

单个测试类没问题了,但项目有成百上千个测试类怎么办?JUnit4提供了组织能力。

  • @RunWith @Suite : 你可以创建一个“测试套件”来批量运行测试。创建一个空类,用 @RunWith(Suite.class) 注解,并通过 @Suite.SuiteClasses 指定要包含的测试类。

    import org.junit.runner.RunWith;
    import org.junit.runners.Suite;
    
    @RunWith(Suite.class)
    @Suite.SuiteClasses({
        CalculatorTest.class,
        UserServiceTest.class,
        PaymentServiceTest.class
    })
    public class AllUnitTestSuite {
        // 这个类本身没有内容,只是一个套件的容器
    }
    

    运行 AllUnitTestSuite ,就会依次运行它包含的所有测试类。这在CI/CD流水线中指定运行某一组测试时非常有用。

  • @Ignore : 如果一个测试暂时不想运行(比如对应的功能还没实现,或者已知有问题在修复中),你可以在 @Test 方法或整个测试类上加上 @Ignore 注解。被忽略的测试不会被执行,但会在测试报告中标记出来,提醒你后续处理。 切忌把 @Ignore 当成永久解决方案 ,它只是一个临时标记。

4. Hamcrest匹配器深度解析与应用

4.1 核心匹配器:对象、文本与数值断言

Hamcrest的强大在于它那一套描述性极强的匹配器。我们先从静态导入开始,这是流畅写法的关键:

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
// 注意:是 Matchers.* ,里面包含了所有核心匹配器

assertThat 是Hamcrest的断言入口,语法是 assertThat(actualValue, matcher) 。读起来就是“断言实际值符合某个匹配器”。我们来看几个最常用的:

  • 对象相等与身份 :

    • equalTo(obj) : 检查对象是否通过 equals() 方法相等。这是最常用的。
    • sameInstance(obj) is(obj) : 检查是否是同一个对象( == )。
    • notNullValue() / nullValue() : 检查是否为null或非null。
    String name = "Alice";
    assertThat(name, equalTo("Alice"));
    assertThat(name, is(notNullValue())); // `is`是一个装饰器,增加可读性,`is(notNullValue())`等价于`notNullValue()`
    assertThat(name, is(not("Bob"))); // `not`是逻辑非
    
  • 文本匹配 :

    • containsString(substring) : 包含子串。
    • startsWith(prefix) / endsWith(suffix) : 以...开头/结尾。
    • equalToIgnoringCase(string) : 忽略大小写相等。
    • matchesPattern(regex) : 匹配正则表达式。
    String message = "Hello, JUnit world!";
    assertThat(message, containsString("JUnit"));
    assertThat(message, startsWith("Hello"));
    assertThat("HELLO", equalToIgnoringCase("hello"));
    assertThat("123-45", matchesPattern("\\d{3}-\\d{2}"));
    
  • 数值比较 :

    • closeTo(expected, delta) : 对于浮点数( double float ),检查实际值是否在期望值的±delta范围内。这是处理浮点数精度问题的利器。
    • greaterThan(num) , greaterThanOrEqualTo(num) , lessThan(num) , lessThanOrEqualTo(num)
    double computedValue = 3.1415926;
    assertThat(computedValue, closeTo(3.14, 0.01)); // 允许0.01的误差
    assertThat(100, is(greaterThan(50)));
    assertThat(list.size(), greaterThanOrEqualTo(1));
    

4.2 集合与数组匹配:验证复杂数据结构

处理集合是单元测试的常事,Hamcrest提供了极其强大的集合匹配器。

  • 基础集合检查 :

    • hasItem(item) / hasItems(item1, item2, ...) : 集合中是否包含某个或某些元素。
    • empty() / emptyCollectionOf(Class) : 集合是否为空。
    • hasSize(int) : 集合大小。
    List<String> fruits = Arrays.asList("apple", "banana", "orange");
    assertThat(fruits, hasItem("banana"));
    assertThat(fruits, hasItems("apple", "orange"));
    assertThat(fruits, hasSize(3));
    assertThat(new ArrayList<>(), empty());
    
  • 迭代顺序与内容精确匹配 :

    • contains(element1, element2, ...) : 严格匹配 !它要求迭代项(如List)的元素顺序和数量必须完全一致。这是验证集合内容最严格的方式。
    • containsInAnyOrder(element1, element2, ...) : 包含所有指定元素,但顺序任意。
    List<Integer> numbers = Arrays.asList(1, 2, 3);
    assertThat(numbers, contains(1, 2, 3)); // 通过
    // assertThat(numbers, contains(1, 3, 2)); // 失败,顺序不对
    assertThat(numbers, containsInAnyOrder(3, 1, 2)); // 通过
    
  • 针对数组 :上述大部分匹配器对数组也适用,因为Hamcrest做了适配。你也可以用 array(contaning(...)) arrayWithSize(int)

  • Map匹配器 :

    • hasEntry(key, value) : Map是否包含特定键值对。
    • hasKey(key) / hasValue(value) : 是否包含键或值。
    Map<String, Integer> scores = new HashMap<>();
    scores.put("Alice", 95);
    scores.put("Bob", 87);
    assertThat(scores, hasEntry("Alice", 95));
    assertThat(scores, hasKey("Bob"));
    

4.3 组合匹配器与属性提取:应对复杂对象断言

这才是Hamcrest的精华所在——组合。你可以用逻辑操作符把简单的匹配器组合成复杂的条件,让断言表达复杂的业务逻辑。

  • 逻辑组合器 :

    • allOf(matcher1, matcher2, ...) : 逻辑“与”,所有匹配器都必须满足。
    • anyOf(matcher1, matcher2, ...) : 逻辑“或”,至少满足一个。
    • not(matcher) : 逻辑“非”。
    String id = "USER_12345";
    // 断言ID以"USER_"开头,并且长度大于8
    assertThat(id, allOf(startsWith("USER_"), hasLength(greaterThan(8))));
    // 断言状态码是成功(200)或者是重定向(301, 302)
    int statusCode = 301;
    assertThat(statusCode, anyOf(is(200), is(301), is(302)));
    // 断言结果不是错误状态
    assertThat(result, not(is(ERROR)));
    
  • Bean属性匹配 :这是处理自定义对象(POJO)的神器。你不需要手动调用getter再断言,可以直接匹配属性。

    • hasProperty(“propertyName”, matcher) : 检查对象是否具有指定名称的属性(通过getter或public字段),并且该属性的值符合给定的匹配器。
    public class User {
        private String name;
        private int age;
        // 省略构造器、getter/setter
        public String getName() { return name; }
        public int getAge() { return age; }
    }
    
    User user = new User("Tom", 30);
    // 传统JUnit方式:
    assertEquals("Tom", user.getName());
    assertTrue(user.getAge() > 18);
    // Hamcrest方式:
    assertThat(user, hasProperty("name", equalTo("Tom")));
    assertThat(user, hasProperty("age", greaterThan(18)));
    // 甚至可以组合:
    assertThat(user, allOf(
        hasProperty("name", equalTo("Tom")),
        hasProperty("age", greaterThan(18))
    ));
    

    这个功能在验证方法返回的复杂对象时特别有用,测试代码的可读性直线上升。它通过Java Beans的内省机制来查找 getName() isName() 这类方法,或者直接访问public字段。

5. 实战:从零构建一个可测试的服务层

5.1 设计一个可测试的领域模型

光说不练假把式,我们用一个简单的“用户积分服务”作为例子。假设我们有 User PointsTransaction 两个领域对象。

// User.java
public class User {
    private final String id;
    private final String username;
    private int points;
    // 构造器、getter省略...
    public void addPoints(int delta) {
        if (delta <= 0) {
            throw new IllegalArgumentException("增加的积分必须为正数");
        }
        this.points += delta;
    }
    public boolean deductPoints(int delta) {
        if (delta <= 0) {
            throw new IllegalArgumentException("扣除的积分必须为正数");
        }
        if (this.points >= delta) {
            this.points -= delta;
            return true;
        }
        return false;
    }
}

// PointsTransaction.java - 记录积分变动
public class PointsTransaction {
    private final String userId;
    private final int amount;
    private final TransactionType type; // enum: EARN, SPEND
    private final Instant createdAt;
    // 构造器、getter省略...
}

注意 User 类中方法的参数校验和业务逻辑(积分不足时扣除失败),这些都是我们测试的重点。

5.2 编写基于JUnit4与Hamcrest的单元测试

现在为 User 类编写测试。我们创建一个 UserTest 类。

import org.junit.Before;
import org.junit.Test;
import java.util.UUID;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

public class UserTest {
    private User testUser;

    @Before
    public void setUp() {
        // 在每个测试前创建一个新的、初始积分为100的用户
        testUser = new User(UUID.randomUUID().toString(), "testUser", 100);
    }

    @Test
    public void userShouldBeCreatedWithInitialPoints() {
        // 断言对象创建正确
        assertThat(testUser, allOf(
            hasProperty("username", equalTo("testUser")),
            hasProperty("points", is(100)) // `is(100)` 等价于 `equalTo(100)`
        ));
    }

    @Test
    public void addPointsShouldIncreasePoints() {
        // 执行
        testUser.addPoints(50);
        // 验证
        assertThat(testUser.getPoints(), is(150));
    }

    @Test(expected = IllegalArgumentException.class)
    public void addPointsWithNonPositiveShouldThrowException() {
        // 预期会抛出IllegalArgumentException
        testUser.addPoints(0); // 这里应该触发异常
        // 如果没抛出异常,测试会失败
    }

    @Test
    public void deductPointsShouldDecreasePointsWhenSufficient() {
        // 执行
        boolean success = testUser.deductPoints(30);
        // 验证状态和行为
        assertThat(success, is(true));
        assertThat(testUser.getPoints(), is(70));
    }

    @Test
    public void deductPointsShouldFailAndNotChangePointsWhenInsufficient() {
        // 执行
        boolean success = testUser.deductPoints(150); // 积分不足
        // 验证
        assertThat(success, is(false));
        assertThat(testUser.getPoints(), is(100)); // 积分应保持不变
    }

    @Test
    public void deductPointsWithNonPositiveShouldThrowException() {
        // 这里我们用try-catch来更精细地验证异常信息
        try {
            testUser.deductPoints(-10);
            // 如果上一行没抛异常,说明测试失败
            org.junit.Assert.fail("Expected IllegalArgumentException was not thrown");
        } catch (IllegalArgumentException e) {
            // 验证异常信息
            assertThat(e.getMessage(), containsString("扣除的积分必须为正数"));
        }
    }
}

这个测试类展示了多种场景:正常行为验证、异常测试(通过 @Test(expected) 和try-catch两种方式)、以及使用Hamcrest组合匹配器对对象属性进行声明式断言。 setUp 方法确保了每个测试的独立性。

5.3 集成测试示例:服务层与Mockito结合

单元测试通常要求隔离,对于 UserService 这种依赖 UserRepository (数据库访问)的服务,我们需要用Mockito这样的模拟框架来隔离外部依赖。这里展示JUnit4 + Hamcrest + Mockito的经典组合。

首先,添加Mockito依赖(Maven):

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.0.0</version>
    <scope>test</scope>
</dependency>

假设我们有如下服务:

public class UserPointsService {
    private final UserRepository userRepository;
    public UserPointsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    public boolean earnPoints(String userId, int points) {
        if (points <= 0) return false;
        User user = userRepository.findById(userId);
        if (user == null) return false;
        user.addPoints(points);
        return userRepository.save(user);
    }
}

对应的测试类:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class) // 使用Mockito的JUnit运行器
public class UserPointsServiceTest {
    @Mock
    private UserRepository mockUserRepository; // 模拟依赖

    @InjectMocks
    private UserPointsService userPointsService; // 被测试对象,自动注入mock

    @Test
    public void earnPointsShouldSuccessAndSaveUser() {
        // 1. 准备数据 (Given)
        String userId = "user-1";
        User mockUser = new User(userId, "Alice", 50);
        when(mockUserRepository.findById(eq(userId))).thenReturn(mockUser);
        when(mockUserRepository.save(any(User.class))).thenReturn(true);

        // 2. 执行操作 (When)
        boolean result = userPointsService.earnPoints(userId, 30);

        // 3. 验证行为与状态 (Then)
        assertThat(result, is(true));
        // 验证用户积分增加了
        assertThat(mockUser.getPoints(), is(80));
        // 验证repository的save方法被调用了一次,并且传入的用户积分是80
        verify(mockUserRepository).save(argThat(user ->
            user.getId().equals(userId) && user.getPoints() == 80
        ));
    }

    @Test
    public void earnPointsShouldReturnFalseWhenUserNotFound() {
        // Given
        when(mockUserRepository.findById(any())).thenReturn(null);

        // When
        boolean result = userPointsService.earnPoints("non-existent", 100);

        // Then
        assertThat(result, is(false));
        // 确保save方法没有被调用
        verify(mockUserRepository, never()).save(any());
    }
}

这里的关键点:

  1. @RunWith(MockitoJUnitRunner.class) :让Mockito管理测试中的Mock对象。
  2. @Mock :创建一个模拟的 UserRepository
  3. @InjectMocks :创建 UserPointsService 实例,并将 @Mock 标注的依赖自动注入进去。
  4. when(...).thenReturn(...) :设定模拟对象的行为。
  5. verify(...) :验证模拟对象的某个方法是否被调用,以及调用的参数。这里我们结合了Hamcrest的 argThat ,它接受一个Hamcrest匹配器(我们用了Lambda,它本质上是一个 ArgumentMatcher ),来对传入的参数进行更灵活的验证。这种组合让验证逻辑非常清晰有力。

6. 高级技巧与最佳实践

6.1 自定义匹配器:封装领域特定断言

当你的领域有复杂的验证逻辑时,重复的Hamcrest组合会显得冗长。这时可以创建自定义匹配器,让测试代码更简洁、更贴近业务语言。

例如,我们经常要验证一个 User 是否是“活跃用户”(假设规则是积分大于100且最近一个月有登录)。我们可以创建一个 IsActiveUser 匹配器。

import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import java.time.Instant;
import java.time.temporal.ChronoUnit;

public class IsActiveUser extends TypeSafeMatcher<User> {
    // 推荐:提供一个静态工厂方法,让使用更流畅
    public static IsActiveUser activeUser() {
        return new IsActiveUser();
    }

    @Override
    protected boolean matchesSafely(User user) {
        // 你的业务判断逻辑
        boolean hasEnoughPoints = user.getPoints() > 100;
        boolean recentLogin = user.getLastLoginTime() != null &&
                user.getLastLoginTime().isAfter(Instant.now().minus(30, ChronoUnit.DAYS));
        return hasEnoughPoints && recentLogin;
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("一个活跃用户(积分>100且近30天有登录)");
    }

    // 可选:提供失败时的描述信息
    @Override
    protected void describeMismatchSafely(User user, Description mismatchDescription) {
        mismatchDescription.appendText("用户" + user.getUsername())
                          .appendText(" 积分=" + user.getPoints());
        if (user.getLastLoginTime() == null) {
            mismatchDescription.appendText(", 从未登录");
        } else {
            long daysSinceLogin = ChronoUnit.DAYS.between(user.getLastLoginTime(), Instant.now());
            mismatchDescription.appendText(", 距离上次登录" + daysSinceLogin + "天");
        }
    }
}

使用这个自定义匹配器:

@Test
public void shouldReturnActiveUsers() {
    User activeUser = new User("id1", "Alice", 150);
    activeUser.setLastLoginTime(Instant.now().minus(10, ChronoUnit.DAYS));

    assertThat(activeUser, IsActiveUser.activeUser());
    // 读起来非常自然:“断言这个用户是活跃用户”
}

当断言失败时,控制台会输出清晰的描述,比如“期望是一个活跃用户...,但用户Alice 积分=50, 距离上次登录35天”,极大提升了测试失败信息的可调试性。

6.2 测试代码重构与可读性提升

好的测试代码和好的生产代码一样需要维护。一些提升可读性的技巧:

  1. 使用有意义的测试方法名 :不要用 test1 ,要用 deductPointsShouldFailWhenBalanceInsufficient 这样的句子,清晰地说明测试的场景和预期。
  2. 遵循Given-When-Then模式 :在测试方法内用空行或注释分隔“准备数据(Given)”、“执行操作(When)”、“验证结果(Then)”三个阶段。这让测试逻辑一目了然。
  3. 提取公共代码 :如果多个测试需要相同的准备步骤(比如创建一个复杂的测试对象),可以提取到 @Before 方法或者单独的工厂方法中。
  4. 利用Hamcrest的流畅性 :多使用 allOf anyOf not 来组合断言,让断言本身成为对业务规则的描述。
  5. 谨慎使用 @Ignore :被忽略的测试容易被人遗忘。如果测试失败,优先修复它;如果功能暂不需要,考虑删除测试代码,等需要时再写。

6.3 常见陷阱与性能考量

  1. 过度测试实现细节 :单元测试应该关注行为(输出、状态变化、交互),而不是内部实现。如果你发现测试因为一个私有方法的微小重构而大面积失败,那可能是在测试实现细节。这会让测试变得脆弱。
  2. 测试间的依赖 :确保每个测试都是独立的,不依赖其他测试的执行顺序或产生的数据。这就是为什么我们要在 @Before 中初始化,在 @After 中清理。JUnit并不保证测试方法的执行顺序。
  3. 耗时操作放在 @BeforeClass / @AfterClass :建立数据库连接、启动嵌入式服务器等操作应该放在 @BeforeClass 中,避免每个测试都重复执行,影响测试速度。
  4. 避免“测试一切”的冲动 :单元测试的目标是快速反馈。为每个公有方法都写测试,但优先覆盖核心业务逻辑、复杂分支和边界条件。Getter/Setter简单到一眼就能看对的,可以不测。
  5. 匹配器选择 :对于集合,想清楚你到底要测什么。 hasItem 是“包含”, contains 是“完全匹配且顺序一致”。用错了会导致测试行为与预期不符。
  6. 静态导入冲突 :如果你同时静态导入了JUnit的 Assert.* 和Hamcrest的 Matchers.* ,并且都用了 assertThat ,编译器会报错。建议只导入Hamcrest的 assertThat ,因为它的功能更强大。JUnit的老式断言可以逐步替换。

7. 迁移与升级:JUnit4到JUnit5的平滑过渡

虽然标题是JUnit4,但了解未来方向很重要。JUnit5(Jupiter)是新一代测试框架,它模块化更好,功能更强大。好消息是,JUnit5完全支持运行JUnit4的测试。所以迁移可以逐步进行。

核心变化

  • 包名和注解变化 org.junit -> org.junit.jupiter.api @Test 注解来自新的包, @Before 变成了 @BeforeEach @After 变成了 @AfterEach @BeforeClass / @AfterClass 变成了 @BeforeAll / @AfterAll (且方法必须是 static )。
  • 断言库 :JUnit5有自己的 Assertions 类,功能类似Hamcrest但更现代。不过, Hamcrest仍然可以在JUnit5中完美使用 !你只需要使用 org.hamcrest.MatcherAssert.assertThat 即可。
  • 更多特性 :JUnit5支持参数化测试、动态测试、嵌套测试、测试接口默认方法等高级功能。

混合使用 :在同一个项目中,你可以一部分测试用JUnit4写,一部分用JUnit5写。构建工具(如Maven的Surefire Plugin)需要同时配置两个测试引擎:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0-M7</version>
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.9.2</version>
        </dependency>
        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>5.9.2</version>
        </dependency>
    </dependencies>
</plugin>

junit-vintage-engine 就是用来运行JUnit4测试的。所以,掌握好JUnit4+Hamcrest这套组合,不仅现在有用,也是未来平滑升级到JUnit5的坚实基础。你可以先在新写的测试中尝试JUnit5,老测试慢慢重构,毫无压力。

更多推荐