从 JUnit 深入理解 Java 注解与反射机制

从 JUnit 深入理解 Java 注解与反射机制

参考资料:

  1. 编写JUnit测试
  2. 详解介绍JUnit单元测试框架(完整版)
  3. deepseek
  4. 封面来自 qwen-image
  5. 个人项目 github 项目地址

overview

  • 本文会涉及:
    • 什么是 JUnit
    • JUnit 特性简介
    • JUnit 如何使用到了 Java 的反射机制注解
    • 自己实现一个极简版的 MyJUnit
  • 本文不深入讨论:
    • JUnit 测试用例具体的编写方法与实践建议
    • Java 的反射机制是如何实现的

什么是 JUnit

  • JUnit 是一种主流的 Java 单元测试框架, 在理解 JUnit 之前, 我们要先了解什么是"单元测试", 什么是"单元测试框架", 然后才可以理解什么是 JUnit
  • 单元测试 :
    就是针对最小的功能单元编写测试代码. Java 程序的最小功能单元是方法, 所以, 对 Java 程序进行单元测试就是针对单个 Java 方法进行的测试
  • 测试驱动开发:
    TDD, Test Driven Develop, 即测试驱动开发, 也是我们常说的 测试先行 . TDD 的优势很多, 包括但不限于:
    1. 可以促进开发者对需求进行初步检验
    2. 可以促进开发者对设计进行初步检验
    3. 可以促进开发者提前构思代码, 有助于写出高质量代码
  • JUnit:
    JUnit 是一个开源的Java语言的单元测试框架, 专门针对 Java 语言设计, 使用最广泛. JUnit 是事实上的单元测试的标准框架, 任何 Java 开发者都应当学习并使用 JUnit 编写单元测试.

JUnit 特性简介

  1. 注解驱动(Annotation-driven):
    这是 JUnit4 和 5 的核心. 通过注解来配置测试行为, 使得代码非常声明式, 清晰易懂
    • @Test: 可以标记一个方法为测试方法
    • @BeforeEach (JUnit 5) / @Before (JUnit 4):在每个测试方法之前执行。用于初始化公共资源(如创建对象、连接数据库)。这体现了设置/拆除(Setup/Teardown) 模式。
    • @AfterEach (JUnit 5) / @After (JUnit 4):在每个测试方法之后执行。用于清理资源(如关闭连接、删除文件)。
    • @BeforeAll (JUnit 5) / @BeforeClass (JUnit 4):在所有测试方法执行之前执行一次(方法必须是 static)。适用于昂贵且可共享的初始化,如启动 Docker 容器。
    • @AfterAll (JUnit 5) / @AfterClass (JUnit 4):在所有测试方法执行之后执行一次(方法必须是 static)。
    • @Disabled (JUnit 5) / @Ignore (JUnit 4):忽略该测试方法,不执行。
  2. 断言(Assertions):
    是测试的"灵魂", 用于验证代码的行为是否符合预期. 断言失败意味着测试失败
    • assertEquals(expected, actual)
    • assertTrue(condition)
    • assertNull(object)
  3. 异常测试:
    • JUnit 4:使用 @Test(expected = Exception.class)
    • JUnit 5:使用更强大的 assertThrows()
  4. 参数化测试

当写下注解@Test的时候, 实际上发生了什么?

  • 我们下面的讲解都会基于下面这一个简单的例子, 从 @Test 这个注解开始逐步切入

    java 复制代码
    // 一个测试类
    class TestClass{
        // 一个测试用例
        @Test
        void testAdd1(){
            assertEquals(4, 2 + 2);
        }
    }

注解意味着什么? 注解在每个阶段的作用?

注解的基本概念

注解实际上是给代码贴的"标签"或"元数据", 他们本身不包含业务逻辑, 但可以被其他程序读取并采取相应行动.

注解的生命周期:从源码到字节码

接下来, 我们要进一步理解@Test在 Java '编译+运行' 两个阶段发挥的作用

  • Java 注解在两个阶段发挥作用:
    • 编译阶段:注解信息被写入字节码文件
    • 运行阶段:通过反射机制读取并处理注解
      J a v a 源码 → 注解标记进字节码 → 字节码 ( . c l a s s 文件 ) → 通过反射机制识别注解并运行测试用例 → 执行测试用例 Java源码 \rightarrow^{注解标记进字节码}\rightarrow 字节码(.class文件) \rightarrow^{通过反射机制识别注解并运行测试用例}\rightarrow 执行测试用例 Java源码→注解标记进字节码→字节码(.class文件)→通过反射机制识别注解并运行测试用例→执行测试用例
注解真的进入字节码了吗?
  • 最简单的办法就是深入 .class 看一看,通过反编译 .class 文件可以验证注解确实被保留在字节码中:
    下面我们借用实现好的 MyJUnit 小项目, 然后看看字节码有没有额外信息
bash 复制代码
# 编译为字节码 / 直接用IDE运行
javac *.java
# 观察字节码(.class)文件具体内容
javap *.class > classcontent.txt

下面是 TestClass.class 文件带有注解的字节码内容:

java 复制代码
...
Constant pool:
...
  #16 = Utf8               Lcom/example/myjunit/annotations/MyTest;
...
{
  public com.example.myjunit.core.TestClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #8                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/myjunit/core/TestClass;

  public void testAddRight();
    descriptor: ()V
    flags: ACC_PUBLIC
    /* ######################## 这里就是注解信息 ########################### */
    RuntimeVisibleAnnotations:
      0: #16() 
      // 对应到常量池 Lcom/example/myjunit/annotations/MyTest; 这就对应我们的 `@MyTest` 注解
    /* ######################## 这里就是注解信息 ########################### */
    Code:
      stack=2, locals=1, args_size=1
         0: iconst_4
         1: iconst_4
         2: invokestatic  #17                 
         5: return
      LineNumberTable:
        line 9: 0
        line 10: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcom/example/myjunit/core/TestClass;

  public void testAddWrong();
    descriptor: ()V
    flags: ACC_PUBLIC
    /* ######################## 这里就是注解信息 ########################### */
    RuntimeVisibleAnnotations:
      0: #16() 
      // 对应到常量池 Lcom/example/myjunit/annotations/MyTest; 这就对应我们的 `@MyTest` 注解
    /* ######################## 这里就是注解信息 ########################### */
    Code:
      stack=2, locals=1, args_size=1
         0: iconst_5
         1: iconst_4
         2: invokestatic  #17                 // Method com/example/myjunit/assertions/MyAssert.assertEquals:(II)V
         5: return
      LineNumberTable:
        line 14: 0
        line 15: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcom/example/myjunit/core/TestClass;
}
SourceFile: "TestClass.java"

给自己实现一个 MyJUnit

java 复制代码
// 一个测试类
class TestClass{
    // 一个测试用例
    @Test
    void testAdd1(){
        assertEquals(4, 2 + 2);
    }
}

我们先尝试打通流程, 更多的注解和断言后续添加, 所以我们在最开始只需要考虑 一个注解@Test一个函数assertEquals()

text 复制代码
my-junit-framework/
└── src/
    └── main/
       └── java/
           └── com/
               └── example/
                   └── myjunit/
                       ├── annotations/          # 注解定义
                       │   └── MyTest.java
                       ├── core/                 # 核心实现
                       │   ├── MyJUnit.java      # 运行函数
                       │   └── TestRunner.java   # 运行类
                       └── assertions/           # 断言工具
                           └── MyAssert.java
  1. 注解的定义:

    java 复制代码
    // my-junit-framework\src\main\java\com\example\myjunit\annotations\MyTest.java
    package com.example.myjunit.annotations;
    
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    // @Retention 指定注解的生命周期,这里是 RUNTIME,表示注解会保留到运行时,可以通过反射读取
    @Retention(RetentionPolicy.RUNTIME)
    // @Target 指定注解可以应用的目标,这里是 METHOD,表示只能用于方法
    @Target(ElementType.METHOD)
    public @interface MyTest {
        // 定义一个空注解,用于标记测试方法
    }
  2. 断言方法的定义:

    java 复制代码
    // my-junit-framework\src\main\java\com\example\myjunit\assertions\MyAssert.java
    package com.example.myjunit.assertions;
    
    public class MyAssert {
        // 自定义断言方法,用于比较两个整数是否相等
        public static void assertEquals(int expected, int actual) {
            if (expected == actual) {
                return; // 如果相等,测试通过,直接返回
            } else {
                // 如果不相等,抛出 AssertionError,测试失败
                throw new AssertionError("Assertion failed: expected [" + expected + "] but found [" + actual + "]");
            }
        }
    }
  3. 运行器

    java 复制代码
    package com.example.myjunit.core;
    import com.example.myjunit.annotations.*;
    import com.example.myjunit.assertions.*;
    import java.lang.reflect.*;
    import java.util.*;
    import java.util.concurrent.*;
    
    public class MyTestRunner {
        private final Class<?> testClass; // 测试类的 Class 对象
        private final TestResult result = new TestResult(); // 测试结果(未实现)
    
        // 构造函数,接收一个测试类的 Class 对象
        public MyTestRunner(Class<?> testClass) {
            this.testClass = testClass;
        }
    
        // 运行测试方法
        public void runOnce() {
            // 获取测试类中声明的所有方法
            Method[] methods = testClass.getDeclaredMethods();
            List<Method> testMethods = new ArrayList<>();
            for (Method method : methods) {
                // 检查方法是否被 @MyTest 注解标记
                if (method.isAnnotationPresent(MyTest.class)) {
                    testMethods.add(method); // 将测试方法添加到列表
                }
            }
    
            // 遍历所有测试方法并执行
            for (Method testMethod : testMethods) {
                try {
                    // 创建测试类的实例
                    Object testInstance = testClass.getDeclaredConstructor().newInstance();
                    testMethod.setAccessible(true); // 确保方法可访问
                    testMethod.invoke(testInstance); // 调用测试方法
                    System.out.println("Test " + testMethod.getName() + " passed.");
                } catch (Exception e) {
                    // 捕获异常并输出失败信息
                    System.out.println("Test " + testMethod.getName() + " failed: " + e.getCause());
                } catch (AssertionError e) {
                    // 捕获断言错误并输出失败信息
                    System.out.println("Test " + testMethod.getName() + " failed: " + e.getMessage());
                }
            }
            return;
        }
    }
  4. 测试用例类

    java 复制代码
    package com.example.myjunit.core;
    import com.example.myjunit.annotations.MyTest;
    import com.example.myjunit.assertions.MyAssert;
    
    public class TestClass {
        // 测试方法,验证 2 + 2 是否等于 4
        @MyTest
        public void testAddRight() {
            MyAssert.assertEquals(4, 2 + 2); // 断言通过
        } 
    
        // 测试方法,验证 2 + 2 是否等于 5
        @MyTest
        public void testAddWrong() {
            MyAssert.assertEquals(5, 2 + 2); // 断言失败
        }
    }
  5. 主函数

    java 复制代码
    package com.example.myjunit.core;
    
    public class MyJUnit {
        public static void main(String[] args) {
            // 创建运行器实例,传入测试类
            MyTestRunner runner = new MyTestRunner(TestClass.class);
            // 执行测试
            runner.runOnce();
        }
    }
相关推荐
As331001016 分钟前
IDM 下载失败排查指南:全面解析与解决方案
开发语言·php·idm
冷月半明23 分钟前
网关饿晕了:Spring Cloud Gateway 内存告急 500 错误现场抓妖记
java·后端
2025年一定要上岸27 分钟前
【数据结构】-4-顺序表(上)
java·开发语言·数据结构
JuneXcy33 分钟前
28.原型
开发语言·javascript·原型模式
2401_8370885041 分钟前
ref和reactive的区别
开发语言·javascript·ecmascript
星夜泊客1 小时前
C# 浮点数与定点数详细解析
开发语言·c#·定点数·浮点数
做一位快乐的码农1 小时前
基于springboot的理商管理平台设计与实现、java/vue/mvc
java·vue.js·spring boot
蔗理苦1 小时前
2025-08-22 Python进阶10——魔术方法
开发语言·python
麦兜*1 小时前
【Prometheus】 + Grafana构建【Redis】智能监控告警体系
java·spring boot·redis·spring·spring cloud·grafana·prometheus
春蕾夏荷_7282977253 小时前
qt ElaWidgetTools第一个实例
开发语言·qt