Java 单元测试全攻略:JUnit 生命周期、覆盖率提升、自动化框架与 Mock 技术

目录

阿里巴巴 Java 开发手册中的单元测试要求总结

在日常开发中,单元测试 不仅仅是保证代码正确性的手段,更是代码质量与可维护性的重要保障。阿里巴巴 Java 开发手册对单元测试有明确要求,本文结合实践经验,对其要点进行总结。

结论很明确:

  • 不要只看绿色条 ,关键是 assert 语句是否合理
  • 好的单元测试必须有明确断言,确保结果符合预期,而不仅仅是"代码跑通了"。

一、为什么需要单元测试?

单元测试的核心价值在于:

  • 提前发现问题:在开发阶段快速发现潜在 bug,避免上线后代价更大的修复。
  • 保证重构安全:修改或优化代码时,通过已有单元测试验证逻辑未被破坏。
  • 提升代码质量:测试驱动开发(TDD)推动开发者从调用方角度思考接口设计。
  • 降低维护成本:新同事接手项目时,单元测试就是最好的"活文档"。

入门案例

复制代码
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;


class CalcDemoTest
{
    /**
     * 演示正确
     */
    @Test
    void add()
    {
        CalcDemo calcDemo = new CalcDemo();
        assertEquals(4,calcDemo.add(2,2));
    }

    /**
     * 演示错误
     */
    @Test
    void addv2()
    {
        CalcDemo calcDemo = new CalcDemo();
        assertEquals(41,calcDemo.add(2,2));
    }
}

二、单元测试覆盖率(JUnit Coverage)

实际代码

复制代码
public class ScoreDemo
{
    public String scoreLevel(int score)
    {
        if(score <= 0) {
            throw new IllegalArgumentException("缺考");
        } else if (score < 60) {
            return "弱";
        } else if (score < 70) {
            return "差";
        } else if (score < 80) {
            return "中";
        } else if (score < 90) {
            return "良";
        } else {
            return "优";
        }
    }
}

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


class ScoreDemoTest
{
    @Test
    void scoreLevel()
    {
        ScoreDemo scoreDemo = new ScoreDemo();
        assertEquals("弱",scoreDemo.scoreLevel(52));
    }

    @Test
    void scoreLevelv2()
    {
        ScoreDemo scoreDemo = new ScoreDemo();
        assertEquals("差",scoreDemo.scoreLevel(62));
    }

    @Test
    void scoreLevelv3()
    {
        ScoreDemo scoreDemo = new ScoreDemo();
        assertEquals("中",scoreDemo.scoreLevel(80));
    }

    @Test
    void scoreLevelv4()
    {
        ScoreDemo scoreDemo = new ScoreDemo();
        assertThrows(IllegalArgumentException.class,() -> scoreDemo.scoreLevel(-7));
    }
}

测试案例+带着覆盖率报告

测试案例+带着覆盖率报告多次运行


三、JUnit 测试生命周期注解详解与案例

1. 生命周期注解介绍

  • @BeforeAll

    在所有测试方法执行之前运行一次,通常用于全局资源的初始化。

    👉 例如:启动数据库连接池、加载配置文件。

  • @BeforeEach

    在每个测试方法执行前运行,常用于准备测试环境。

    👉 例如:创建对象、准备测试数据。

  • @Test

    定义具体的测试方法。

  • @AfterEach

    在每个测试方法执行后运行,用于清理测试环境。

    👉 例如:关闭文件、清空缓存。

  • @AfterAll

    在所有测试方法执行完毕后运行一次,用于全局资源释放。

    👉 例如:关闭数据库连接池。


2、案例演示一

@BeforeEach和@AfterEach

假设我们有一个 CalcDemo 类,新增了一个减法方法:

java 复制代码
public class CalcDemo
{
    public int add(int x ,int y)
    {
        return x + y;
        //return x * y;
    }

    public int sub(int x ,int y)
    {
        return x - y;
    }
}

生成测试工具类

有重复代码

解决方案

结论

3、案例演示二

@BeforeAll和@AfterAll

测试代码

复制代码
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;


class CalcDemoTestV2
{
    CalcDemo calcDemo = null;

    static StringBuffer stringBuffer = null;

    @BeforeAll
    static void m1()
    {
        stringBuffer = new StringBuffer("abc");
        System.out.println("===============: "+stringBuffer.length());
    }

    @AfterAll
    static void m2()
    {
        System.out.println("===============: "+stringBuffer.append(" ,end").toString());
    }



    @BeforeEach
    void setUp()
    {
        System.out.println("----come in BeforeEach");
        calcDemo = new CalcDemo();
    }

    @AfterEach
    void tearDown()
    {
        System.out.println("----come in AfterEach");
        calcDemo = null;
    }

    @Test
    void add()
    {
        Assertions.assertEquals(5,calcDemo.add(1,4));
        assertEquals(5,calcDemo.add(2,3));
    }

    @Test
    void sub()
    {
        assertEquals(5,calcDemo.sub(10,5));
    }
}

结论

4. 小结

  • @BeforeEach
    void setUp() 每一个测试方法调用前必执行的方法
  • @AfterEach
    void tearDown() 每一个测试方法调用后必执行的方法
  • @BeforeAll
    所有测试方法调用前执行一次,在测试类没有实例化之前就已被加载,需用static修饰
  • @AfterAll
    所有测试方法调用后执行一次,在测试类没有实例化之前就已被加载,需用static修饰
特性 @BeforeEach @BeforeAll
执行次数 每个测试方法前执行一次 所有测试方法前只执行一次
方法修饰符 普通方法即可 必须是 static 方法(JUnit 5 中可配合 @TestInstance(Lifecycle.PER_CLASS) 去掉 static)
适用场景 每个测试都需要独立、干净的测试环境 所有测试共享同一个全局资源
性能表现 多次执行,性能相对较低 只执行一次,性能更高
典型用法 初始化临时对象、准备测试数据 启动数据库连接池、初始化 Spring 上下文

四、JUnit + 反射浅谈自动化测试框架设计

需求说明

复制代码
public class CalcHelpDemo
{
    public int mul(int x ,int y)
    {
        return x * y;
    }

    @DonglinTest
    public int div(int x ,int y)
    {
        return x / y;
    }

    @DonglinTest
    public void thank(int x ,int y)
    {
        System.out.println("3ks,help me test bug");
    }
}

自定义注解

复制代码
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DonglinTest {
}

编写业务类

复制代码
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import com.donglin.interview2.junit.DonglinTest;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Junit+反射+注解浅谈自动测试框架设计
 *
 * 需求
 * 1 我们自定义注解@DonglinTest
 * 2 将注解DonglinTest加入需要测试的方法
 * 3 类AutoTestClient通过反射检查,哪些方法头上标注了DonglinTest注解会自动进行单元测试
 */
@Slf4j
public class AutoTestClient
{
    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException
    {
        //家庭作业,抽取一个方法,(class,p....)
        CalcHelpDemo calcHelpDemo = new CalcHelpDemo();
        int para1 = 10;
        int para2 = 0;


        Method[] methods = calcHelpDemo.getClass().getMethods();
        AtomicInteger bugCount = new AtomicInteger();
        // 要写入的文件路径(如果文件不存在,会创建该文件)
        String filePath = "BugReport"+ (DateUtil.format(new Date(), "yyyyMMddHHmmss"))+".txt";


        for (int i = 0; i < methods.length; i++)
        {
            if (methods[i].isAnnotationPresent(DonglinTest.class))
            {
                try
                {
                    methods[i].invoke(calcHelpDemo,para1,para2);//放行
                } catch (Exception e) {
                    bugCount.getAndIncrement();
                    log.info("异常名称:{},异常原因:{}",e.getCause().getClass().getSimpleName(),e.getCause().getMessage());


                    FileUtil.writeString(methods[i].getName()+"\t"+"出现了异常"+"\n", filePath, "UTF-8");
                    FileUtil.appendString("异常名称:"+e.getCause().getClass().getSimpleName()+"\n", filePath, "UTF-8");
                    FileUtil.appendString("异常原因:"+e.getCause().getMessage()+"\n", filePath, "UTF-8");
                }finally {
                    FileUtil.appendString("异常数:"+bugCount.get()+"\n", filePath, "UTF-8");
                }
            }
        }
    }
}




/**
 * 在Hutool工具包中,使用FileUtil类进行文件操作时,通常不需要显式地"关闭"文件。
 * 这是因为Hutool在内部处理文件I/O时,已经考虑了资源的自动管理和释放。
 *
 * 具体来说,当你使用FileUtil的静态方法(如writeString、appendString、readUtf8String等)时,
 * 这些方法会在执行完毕后自动关闭与文件相关的流和资源。因此,你不需要(也不能)
 * 调用类似于close这样的方法来关闭文件。
 *
 * 这是因为这些静态方法通常使用Java的try-with-resources语句或其他类似的机制来确保资源在
 * 不再需要时得到释放。try-with-resources是Java 7及更高版本中引入的一个特性,
 * 它允许你在try语句块结束时自动关闭实现了AutoCloseable或Closeable接口的资源。
 *
 * 所以,当你使用Hutool的FileUtil类进行文件操作时,你可以放心地编写代码,
 * 而无需担心资源泄露或忘记关闭文件的问题。Hutool已经为你处理了这些细节。
 */

五、Mock 技术(模拟依赖)

在日常 Spring Boot 开发中,我们经常需要对业务逻辑进行单元测试。

然而,测试时到底该用 真实 Bean ,还是 Mock 对象 ,甚至是 部分 Mock,往往让人困惑。

本文结合一个简单的 MemberService 示例,带你梳理 @Resource、@MockBean、@SpyBean 在单元测试中的区别与应用场景。


1、示例业务类

java 复制代码
@Service
public class MemberService {
    public String add(Integer uid) {
        System.out.println("---come in addUser");

        if (uid == -1) throw new IllegalArgumentException("parameter is negative。。。。");

        return "ok";
    }

    public int del(Integer uid) {
        System.out.println("---come in del");
        return uid;
    }
}

这里我们有两个方法:

  • add():新增用户,参数非法时抛异常。
  • del():删除用户,简单返回 uid。

2、测试类框架

java 复制代码
@SpringBootTest
class MemberServiceTest {
    // 测试用例放这里
}

3、三种注入方式对比

1.@Resource(真实调用)

java 复制代码
@Resource
private MemberService memberService1;

@Test
void m1() {
    String result = memberService1.add(2);
    assertEquals("ok", result);
    System.out.println("----m1 over");
}

✅ 特点:

  • 注入 真实 Bean,方法逻辑会真正执行。
  • 需要数据库/外部依赖时,也会触发真实操作。

📌 场景:适合需要真实运行逻辑的集成测试。


2.@MockBean(完全 Mock)

java 复制代码
@MockBean
private MemberService memberService2;

@Test
void m2_NotMockRule() {
    String result = memberService2.add(2);
    assertEquals("ok", result);  // ❌ 这里会报错,因为默认返回 null
}

@Test
void m2_WithMockRule() {
    when(memberService2.add(3)).thenReturn("ok");

    String result = memberService2.add(3);
    assertEquals("ok", result);
    System.out.println("----m2_WithMockRule over");
}

✅ 特点:

  • 注入的 Bean 被 完全替换成 Mock 对象。
  • 没有指定规则时,返回默认值(对象 null、数字 0、布尔 false)。
  • 指定规则后,返回指定结果,不会执行真实逻辑

📌 场景:适合需要 完全隔离外部依赖 的单元测试。


3.@SpyBean(部分 Mock)

java 复制代码
@SpyBean
private MemberService memberService3;

@Test
void m3() {
    when(memberService3.add(2)).thenReturn("ok");

    // add() 按规则走
    String result = memberService3.add(2);
    assertEquals("ok", result);

    // del() 没有规则,走真实逻辑
    int result2 = memberService3.del(3);
    assertEquals(3, result2);

    System.out.println("----over");
}

✅ 特点:

  • 注入的 Bean 是 真实 Bean 的代理
  • 如果方法设置了 Mock 规则,就走规则;否则走真实逻辑。

📌 场景:适合测试中既想 Mock 某些方法,又想保留其他方法的真实逻辑。


六、总结对比

注解 行为 优点 缺点 使用场景
@Resource / @Autowired 注入真实 Bean 方法真实执行,覆盖面完整 依赖数据库、外部服务,测试环境复杂 集成测试
@MockBean 完全 Mock 彻底隔离外部依赖,运行快 无规则时返回默认值,逻辑不执行 纯单元测试
@SpyBean 部分 Mock 既能 Mock 指定方法,又能执行真实逻辑 管理 Mock 规则稍复杂 混合场景

最佳实践

  1. 单元测试 :优先使用 @MockBean,避免依赖数据库、MQ 等外部资源。
  2. 部分方法需要真实执行 :用 @SpyBean
  3. 集成测试 / 验证真实业务链路 :用 @Resource@Autowired

相关推荐
cominglately3 小时前
记录一次生产环境数据库死锁的处理过程
java·死锁
用户0332126663673 小时前
在 Word 文档中插入图片的 Java 指南
java
深圳蔓延科技3 小时前
单点登录到底是什么?
java·后端
SimonKing3 小时前
除了 ${},Thymeleaf 的这些用法让你直呼内行
java·后端·程序员
科兴第一吴彦祖4 小时前
基于Spring Boot + Vue 3的乡村振兴综合服务平台
java·vue.js·人工智能·spring boot·推荐算法
ajassi20004 小时前
开源 java android app 开发(十八)最新编译器Android Studio 2025.1.3.7
android·java·开源
纤瘦的鲸鱼4 小时前
Spring Gateway 全面解析:从入门到进阶实践
java·spring·gateway
用户3294190042164 小时前
Java接入DeepSeek实现流式、联网、知识库以及多轮问答
java
Knight_AL4 小时前
浅拷贝与深拷贝详解:概念、代码示例与后端应用场景
android·java·开发语言