3、JUnit4/5基础:JUnit注解详解、断言机制、测试套件、参数化测试、测试执行顺序

好,咱们进入正题。JUnit 这东西,说白了就是 Android 自动化测试的基石。不管你用 Espresso 还是 UI Automator,底层跑的都是 JUnit。我刚开始带团队的时候,发现很多同学写测试用例就是「能用就行」,注解乱用、断言瞎写,结果 CI 跑起来一片红,排查问题比写代码还累。今天我就把 JUnit4 和 JUnit5 的核心知识点掰开揉碎了讲清楚。

3.1 JUnit 注解详解:从 @Test 到 @DisplayName

注解是 JUnit 的灵魂。你想想看,没有注解,框架怎么知道哪个方法是测试方法?哪个方法要在测试前执行?

3.1.1 JUnit4 核心注解

注解 作用 执行时机
@Test 标记一个方法为测试方法 运行时
@Before 每个测试方法执行前运行 每个 @Test 前
@After 每个测试方法执行后运行 每个 @Test 后
@BeforeClass 所有测试方法执行前运行一次(静态方法) 类加载时
@AfterClass 所有测试方法执行后运行一次(静态方法) 类销毁时
@Ignore 忽略该测试方法 不执行

我在项目中遇到过一个问题:有个同事把数据库初始化的代码写在 @Before 里,结果每个测试用例都重新建表,跑 100 个用例花了 10 分钟。后来改成 @BeforeClass,一次初始化,30 秒跑完。嗯,这里要注意,@BeforeClass 必须是静态方法,而且不能依赖实例变量。

3.1.2 JUnit5 新注解

JUnit5 改了不少东西。我个人习惯用 JUnit5 写新项目,因为它的注解更语义化。

JUnit4 JUnit5 变化说明
@Test @Test 功能相同,但 JUnit5 的 @Test 来自 org.junit.jupiter.api
@Before @BeforeEach 名字更清晰:每个测试前执行
@After @AfterEach 名字更清晰:每个测试后执行
@BeforeClass @BeforeAll 所有测试前执行一次
@AfterClass @AfterAll 所有测试后执行一次
@Ignore @Disabled 语义更明确:禁用而非忽略
@DisplayName 给测试类或方法起中文名,报告更友好

重点:@DisplayName 是我最喜欢的新特性。以前看测试报告,一堆 testLoginWithValidCredentials 这种驼峰命名,眼睛都看花了。加上 @DisplayName("登录测试-有效凭证"),报告瞬间可读性拉满。

// JUnit5 示例
@DisplayName("用户登录模块测试")
class LoginTest {
    
    @BeforeEach
    void setUp() {
        // 每个测试前初始化
    }
    
    @Test
    @DisplayName("使用有效凭证登录应该成功")
    void testLoginWithValidCredentials() {
        // 测试逻辑
    }
    
    @Test
    @Disabled("该功能尚未实现,暂时跳过")
    void testLoginWithBiometric() {
        // 指纹登录测试
    }
}

3.2 断言机制:让测试自己会说话

断言是测试的「裁判」。没有断言,测试跑完你也不知道对不对。我见过最离谱的测试用例,整个方法就一行 System.out.println("测试通过")——这能叫测试?

3.2.1 JUnit4 断言

JUnit4 的断言都在 org.junit.Assert 类里,全是静态方法。

import static org.junit.Assert.*;

@Test
public void testAssertions() {
    // 基本断言
    assertEquals("值不相等", 4, 2 + 2);
    assertTrue("条件应为真", 5 > 3);
    assertFalse("条件应为假", 3 > 5);
    assertNull("应为空", null);
    assertNotNull("不应为空", "hello");
    
    // 数组断言
    int[] expected = {1, 2, 3};
    int[] actual = {1, 2, 3};
    assertArrayEquals("数组不相等", expected, actual);
    
    // 异常断言
    @Test(expected = IllegalArgumentException.class)
    public void testException() {
        throw new IllegalArgumentException("参数错误");
    }
}

避坑指南:我曾经在项目里用 assertEquals 比较两个浮点数,结果 0.1 + 0.2 不等于 0.3。为什么?浮点数精度问题。后来改用 assertEquals(0.3, 0.1 + 0.2, 0.0001),第三个参数是 delta 误差范围,这才搞定。

3.2.2 JUnit5 断言

JUnit5 把断言移到了 org.junit.jupiter.api.Assertions,并且增加了 lambda 支持。

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

@Test
void testAssertions() {
    // 基本断言(支持 lambda 消息)
    assertEquals(4, 2 + 2, () -> "计算错误:2+2 应该等于 4");
    
    // 分组断言
    assertAll("用户属性验证",
        () -> assertEquals("张三", user.getName()),
        () -> assertEquals(25, user.getAge()),
        () -> assertTrue(user.isActive())
    );
    
    // 异常断言(更优雅)
    assertThrows(IllegalArgumentException.class, () -> {
        new User(-1);
    }, "年龄为负数时应抛出异常");
    
    // 超时断言
    assertTimeout(Duration.ofMillis(100), () -> {
        // 必须在100ms内完成
        someMethod();
    });
}

我的习惯:assertAll 特别好用。以前写多个断言,第一个失败了后面的就不跑了,排查问题得反复跑。用 assertAll 会把所有失败都列出来,一次跑完所有断言,省时省力。

3.3 测试套件:批量执行的艺术

测试多了以后,你不可能一个个点着跑。测试套件就是用来批量组织测试的。

3.3.1 JUnit4 测试套件

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({
    LoginTest.class,
    RegisterTest.class,
    ProfileTest.class
})
public class AllTestsSuite {
    // 这个类不需要任何方法
}

跑这个套件,三个测试类会依次执行。我在 CI 流程里就是这么干的——把单元测试、集成测试、UI 测试分别打包成不同的套件,按阶段执行。

3.3.2 JUnit5 测试套件

JUnit5 用 @Suite 注解,需要额外依赖 junit-platform-suite-api

import org.junit.platform.suite.api.*;

@Suite
@SelectPackages("com.example.tests")  // 扫描整个包
@IncludeTags("smoke")                  // 只跑 smoke 标签的测试
@ExcludeTags("slow")                   // 排除慢测试
public class SmokeTestSuite {
    // 空类即可
}

实战经验:我习惯把测试分成三个套件:冒烟测试(核心功能,每次提交必跑)、回归测试(全量,每晚跑)、性能测试(每周跑一次)。这样 CI 流程既快又稳。

3.4 参数化测试:一个测试跑 N 组数据

你想想看,测试登录功能,你得测用户名正确、密码正确、用户名错误、密码错误、都错误……难道写 5 个测试方法?参数化测试就是干这个的。

3.4.1 JUnit4 参数化测试

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import java.util.Arrays;
import java.util.Collection;

@RunWith(Parameterized.class)
public class CalculatorTest {
    
    private int a;
    private int b;
    private int expected;
    
    // 构造函数注入参数
    public CalculatorTest(int a, int b, int expected) {
        this.a = a;
        this.b = b;
        this.expected = expected;
    }
    
    @Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{
            {1, 1, 2},
            {2, 3, 5},
            {5, 5, 10},
            {10, -5, 5}
        });
    }
    
    @Test
    public void testAdd() {
        assertEquals(expected, a + b);
    }
}

说实话,JUnit4 的参数化有点麻烦。你得写构造函数、写静态数据源方法,代码量不小。

3.4.2 JUnit5 参数化测试

JUnit5 就优雅多了。它用 @ParameterizedTest 配合各种数据源注解。

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

class CalculatorTest {
    
    // 方式一:直接写值
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void testPositive(int number) {
        assertTrue(number > 0);
    }
    
    // 方式二:CSV 格式
    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "5, 5, 10"
    })
    void testAdd(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
    
    // 方式三:从 CSV 文件读取
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
    void testFromFile(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
    
    // 方式四:枚举
    @ParameterizedTest
    @EnumSource(Month.class)
    void testMonth(Month month) {
        assertNotNull(month);
    }
    
    // 方式五:方法返回值
    @ParameterizedTest
    @MethodSource("dataProvider")
    void testFromMethod(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
    
    static Stream<Arguments> dataProvider() {
        return Stream.of(
            Arguments.of(1, 1, 2),
            Arguments.of(2, 3, 5)
        );
    }
}

我的建议:项目里数据量少用 @CsvSource,数据量多用 @CsvFileSource。我曾经把 200 组测试数据写在 @CsvSource 里,代码直接爆炸。后来改成 CSV 文件,清爽多了。

3.5 测试执行顺序:别让顺序坑了你

测试应该是独立的、无序的。但现实是,有时候你不得不控制顺序。比如集成测试里,你得先注册才能登录。

3.5.1 JUnit4 执行顺序

JUnit4 默认不保证执行顺序。想控制顺序?用 @FixMethodOrder。

import org.junit.FixMethodOrder;
import org.junit.runners.MethodSorters;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class OrderedTest {
    
    @Test
    public void testA_register() {
        // 先注册
    }
    
    @Test
    public void testB_login() {
        // 后登录
    }
    
    @Test
    public void testC_logout() {
        // 最后退出
    }
}

NAME_ASCENDING 按方法名排序。所以我把方法名写成 testA、testB、testC,按字母顺序执行。

3.5.2 JUnit5 执行顺序

JUnit5 用 @TestMethodOrder 注解,更灵活。

import org.junit.jupiter.api.*;

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedTest {
    
    @Test
    @Order(3)
    void testLogout() {
        // 第三步
    }
    
    @Test
    @Order(1)
    void testRegister() {
        // 第一步
    }
    
    @Test
    @Order(2)
    void testLogin() {
        // 第二步
    }
}

曾经踩过的坑:有次我依赖测试顺序,结果 CI 环境换了 JDK 版本,方法名的排序变了,测试全挂。后来我改成 @Order 注解,明确指定顺序,再也没出过问题。记住:依赖顺序的测试是坏味道,能避免就避免。

3.6 实战总结

好了,这一章的内容不少。我帮你理一下重点:

  • 注解:JUnit5 的 @BeforeEach/@AfterEach 比 JUnit4 的 @Before/@After 更语义化,推荐用新不用旧
  • 断言:assertAll 和 assertThrows 是 JUnit5 的杀手锏,能大幅提升测试质量
  • 套件:按冒烟、回归、性能分层组织,CI 流程更高效
  • 参数化:一个测试跑多组数据,@CsvFileSource 适合大数据量场景
  • 顺序:能用 @Order 就别依赖字母排序,明确指定最安全

下一章咱们会讲 Mockito 和 PowerMock,到时候这些 JUnit 知识全都会用上。你先把这些基础打牢,后面写起测试来才能得心应手。