Learn
Java/16-maven-junit

Maven 与 JUnit 5

当代码超过一个文件,就需要构建工具(管理依赖、编译、测试、打包)。Java 生态最常用的就是 Maven(Gradle 也很流行,思路类似)。JUnit 则是测试的事实标准。

本章的 Playground 限制:Playground 只能跑 Main.java 单文件,没法真跑 Maven。所以 Playground 里我们用一个迷你测试运行器(上一章注解的延续)来体验 JUnit 风格的断言与参数化。

1. Maven 项目结构

my-app/
├── pom.xml                  # 项目模型:依赖、插件、构建配置
├── src/
│   ├── main/
│   │   ├── java/            # 业务代码
│   │   │   └── com/example/App.java
│   │   └── resources/       # 资源文件
│   └── test/
│       ├── java/            # 测试代码
│       │   └── com/example/AppTest.java
│       └── resources/
└── target/                  # 编译产物(git ignore)

2. 最小可用的 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
 
    <groupId>com.example</groupId>
    <artifactId>my-app</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
 
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <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>
ℹ️GAV 坐标
  • groupId:组织域名倒写(com.example)
  • artifactId:项目名(my-app)
  • version:版本(1.0.0)

3. 一个真正的 Java 类 + 对应的 JUnit 测试

src/main/java/com/example/Calculator.java:

package com.example;
 
public class Calculator {
    public int add(int a, int b)    { return a + b; }
    public int divide(int a, int b) {
        if (b == 0) throw new IllegalArgumentException("divisor cannot be 0");
        return a / b;
    }
}

src/test/java/com/example/CalculatorTest.java:

package com.example;
 
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
 
class CalculatorTest {
    @Test
    void add_returnsSum() {
        assertEquals(5, new Calculator().add(2, 3));
    }
 
    @Test
    void divide_throwsOnZero() {
        var ex = assertThrows(IllegalArgumentException.class,
            () -> new Calculator().divide(10, 0));
        assertEquals("divisor cannot be 0", ex.getMessage());
    }
}

4. 常用 JUnit 5 注解

注解作用
@Test标记测试方法
@BeforeEach每个测试前执行
@AfterEach每个测试后执行
@BeforeAll所有测试前执行一次(须 static)
@AfterAll所有测试后执行一次(须 static)
@Disabled跳过此方法
@DisplayName("...")自定义显示名(支持中文)
@Nested嵌套测试类

5. 常用断言

assertEquals(预期, 实际);
assertNotEquals(a, b);
assertTrue(cond);  assertFalse(cond);
assertNull(o);     assertNotNull(o);
assertSame(a, b);  // 同一对象
assertThrows(异常类, () -> ...);
assertAll(() -> ..., () -> ...);  // 多个断言一次性报告
assertTimeout(Duration.ofSeconds(1), () -> ...);

6. 单文件 Playground:迷你测试运行器

下面这个 Playground 自己实现了一个简化版的 JUnit——可以让你单文件地感受 @Test / @BeforeEach / 断言 / 异常检查 / 参数化测试的运行效果。

迷你 JUnit
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
 
public class Main {
    // ====== 注解 ======
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface Test {}
 
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface BeforeEach {}
 
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER)
    public @interface Value { String[] strs() default {}; int[] ints() default {}; }
 
    // ====== 断言 ======
    static int failures = 0;
    static void assertEq(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            failures++;
            throw new AssertionError("expected=" + expected + " actual=" + actual);
        }
    }
    static void assertTrue(boolean cond) {
        if (!cond) { failures++; throw new AssertionError("expected true"); }
    }
    static <T extends Throwable> T assertThrows(Class<T> type, Runnable r) {
        try { r.run(); failures++; throw new AssertionError("no exception"); }
        catch (Throwable t) {
            if (type.isInstance(t)) return type.cast(t);
            failures++; throw new AssertionError("wrong type: " + t);
        }
    }
 
    // ====== Calculator(被测对象) ======
    static class Calculator {
        int add(int a, int b) { return a + b; }
        int divide(int a, int b) {
            if (b == 0) throw new IllegalArgumentException("divisor 0");
            return a / b;
        }
    }
 
    Calculator calc;
 
    @BeforeEach
    void setUp() { calc = new Calculator(); }
 
    @Test void add_2_and_3() { assertEq(5, calc.add(2, 3)); }
    @Test void add_negative() { assertEq(-2, calc.add(-5, 3)); }
    @Test void divide_ok() { assertEq(4, calc.divide(20, 5)); }
    @Test void divide_by_zero() {
        assertThrows(IllegalArgumentException.class, () -> calc.divide(1, 0));
    }
 
    // ====== 运行器 ======
    public static void main(String[] args) throws Exception {
        Main tester = new Main();
        Class<?> c = tester.getClass();
        Method[] all = c.getDeclaredMethods();
 
        List<Method> before = new ArrayList<>();
        List<Method> tests  = new ArrayList<>();
        for (Method m : all) {
            if (m.isAnnotationPresent(BeforeEach.class)) before.add(m);
            if (m.isAnnotationPresent(Test.class))       tests.add(m);
        }
 
        int pass = 0, fail = 0;
        for (Method t : tests) {
            failures = 0;
            try {
                t.setAccessible(true);
                for (Method b : before) { b.setAccessible(true); b.invoke(tester); }
                t.invoke(tester);
                if (failures == 0) { pass++; System.out.println("[ok]   " + t.getName()); }
                else               { fail++; System.out.println("[fail] " + t.getName()); }
            } catch (Exception e) {
                fail++;
                System.out.println("[fail] " + t.getName() + ": " + e.getCause());
            }
        }
        System.out.printf("%n通过 %d / 失败 %d%n", pass, fail);
    }
}

7. 参数化测试

参数化测试
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
 
public class Main {
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface ParamTest {}
 
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface Case {
        int a(); int b(); int sum();
    }
 
    public static void main(String[] args) throws Exception {
        int pass = 0, fail = 0;
        for (Method m : Main.class.getDeclaredMethods()) {
            if (!m.isAnnotationPresent(ParamTest.class)) continue;
            for (Case c : m.getAnnotationsByType(Case.class)) {
                m.setAccessible(true);
                int got = (int) m.invoke(null, c.a(), c.b());
                if (got == c.sum()) {
                    pass++;
                    System.out.printf("  [ok]   %d + %d = %d%n", c.a(), c.b(), c.sum());
                } else {
                    fail++;
                    System.out.printf("  [fail] %d + %d = %d, got %d%n", c.a(), c.b(), c.sum(), got);
                }
            }
        }
        System.out.printf("%n通过 %d / 失败 %d%n", pass, fail);
    }
 
    @ParamTest @Case(a = 1, b = 2, sum = 3)  static int t1(int a, int b) { return a + b; }
    @ParamTest @Case(a = 5, b = 5, sum = 10) static int t2(int a, int b) { return a + b; }
    @ParamTest @Case(a = 0, b = 0, sum = 0)  static int t3(int a, int b) { return a + b; }
}
💡真正的 JUnit 5 参数化测试长这样
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5})
void isOdd(int n) {
    assertTrue(n % 2 == 1);
}
 
@ParameterizedTest
@MethodSource("cases")
void add(int a, int b, int expected) {
    assertEquals(expected, calc.add(a, b));
}
static Stream<Arguments> cases() {
    return Stream.of(
        Arguments.of(1, 2, 3),
        Arguments.of(5, 5, 10));
}

8. 常用 Maven 命令

mvn -v                          # 版本
mvn compile                     # 编译 main
mvn test                        # 编译 + 跑测试
mvn package                     # 打包成 jar
mvn clean                       # 删 target/
mvn dependency:tree             # 依赖树
mvn -DskipTests package         # 跳过测试打包

🎯 练习

补全迷你测试
// 任务:在 calculatorTest() 里加 3 条 @Case 参数化测试
// 测试 add 方法,覆盖正数 + 正数、正数 + 0、负数 + 正数 三种情况
// 期望输出:
//   [ok]   2 + 3 = 5
//   [ok]   7 + 0 = 7
//   [ok]   -4 + 9 = 5
 
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
 
public class Main {
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface ParamTest {}
 
    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    public @interface Case { int a(); int b(); int sum(); }
 
    static int add(int a, int b) { return a + b; }
 
    public static void main(String[] args) throws Exception {
        for (Method m : Main.class.getDeclaredMethods()) {
            if (!m.isAnnotationPresent(ParamTest.class)) continue;
            for (Case c : m.getAnnotationsByType(Case.class)) {
                int got = (int) m.invoke(null, c.a(), c.b());
                System.out.printf("[%s] %d + %d = %d (got %d)%n",
                    got == c.sum() ? "ok" : "fail", c.a(), c.b(), c.sum(), got);
            }
        }
    }
 
    @ParamTest
    // 补全 @Case 三条
    static int calculatorTest(int a, int b) { return add(a, b); }
}

小结

  • ✅ Maven = GAV 坐标 + 依赖管理 + 生命周期(compile / test / package)
  • ✅ 标准布局:src/main/java 与 src/test/java 分离
  • ✅ JUnit 5 注解:@Test / @BeforeEach / @AfterEach / @Disabled
  • ✅ 常用断言:assertEquals / assertThrows / assertAll
  • ✅ 参数化测试:@ParameterizedTest + @ValueSource / @MethodSource
  • ✅ 跑测试:mvn test;看依赖树:mvn dependency:tree

下一章 项目:Spring Boot REST API——从代码到 HTTP 服务。