39_Java单元测试JUnit入门

Java单元测试JUnit入门

文章目录

前言

"这段代码没问题,不用测试"------这是软件工程中最危险的自负。一个bug在开发阶段被发现和在生产环境被用户发现,修复成本可能相差百倍。单元测试 就是开发阶段最有效的质量保障手段,而JUnit是Java生态中最主流的单元测试框架。本文将从零开始,带你掌握JUnit的核心用法。

测试的ROI:很多开发者抗拒写单元测试的理由是"浪费时间"。但实际上,调试一个没有测试覆盖的bug所花的时间,通常是写测试的3-5倍------因为你需要在脑海中重新构建代码的上下文,还要手动构造测试数据、模拟各种边界条件。更重要的是,有单元测试保护的代码,你可以放心重构而不怕引入回归bug。单元测试就像一份"代码的行为说明书",几个月后你回来看代码,跑一遍测试就知道各方法期望的输入输出是什么。在实际面试中,是否有写测试的习惯也是区分初中级和高级工程师的重要标尺。

一、环境准备与第一个测试

在Maven项目中添加JUnit依赖(以JUnit 4为例,JUnit 5时代码会更现代):

xml 复制代码
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

编写被测试的类:

java 复制代码
// src/main/java/com/example/Calculator.java
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("除数不能为0");
        }
        return a / b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }
}

编写测试类(测试类命名规范:被测类名+Test):

java 复制代码
// src/test/java/com/example/CalculatorTest.java
import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calc = new Calculator();
        int result = calc.add(2, 3);
        assertEquals("2 + 3 应该等于 5", 5, result);
    }

    @Test
    public void testDivide() {
        Calculator calc = new Calculator();
        assertEquals(3, calc.divide(6, 2));
        assertEquals(0, calc.divide(0, 5));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testDivideByZero() {
        Calculator calc = new Calculator();
        calc.divide(10, 0);  // 期望抛出异常
    }
}

测试方法命名建议test + 方法名 + 测试场景,如testDivideByZero。也可以使用Given-When-Then风格:givenTwoNumbers_whenAdd_thenReturnSum

测试方法编写的基本原则------AAA模式 :Arrange(准备测试数据)、Act(执行被测方法)、Assert(断言结果)。例如上面的testAdd:先Arrange创建Calculator对象,再Act调用calc.add(2,3),最后Assert断言assertEquals(5, result)。清晰的AAA结构让测试一目了然,评审者能快速理解测试意图。注意AAA并不是说每个测试只能有一个Act------有时需要连续调用多个方法来完成一个业务场景------但核心是"准备-执行-验证"的清晰分工。

二、JUnit常用注解

JUnit提供了丰富的注解来控制测试的生命周期和行为:

java 复制代码
import org.junit.*;
import static org.junit.Assert.*;

public class LifecycleTest {

    // 在所有测试方法之前执行一次(必须是static)
    @BeforeClass
    public static void setUpBeforeClass() {
        System.out.println("[@BeforeClass] 整个测试类初始化一次");
        // 典型用途:建立数据库连接、加载配置文件
    }

    // 在所有测试方法之后执行一次(必须是static)
    @AfterClass
    public static void tearDownAfterClass() {
        System.out.println("[@AfterClass] 整个测试类清理一次");
        // 典型用途:关闭数据库连接
    }

    // 在每个@Test方法之前执行
    @Before
    public void setUp() {
        System.out.println("  [@Before] 每个测试方法前执行");
        // 典型用途:初始化测试数据
    }

    // 在每个@Test方法之后执行
    @After
    public void tearDown() {
        System.out.println("  [@After] 每个测试方法后执行");
        // 典型用途:清理测试数据
    }

    @Test
    public void testMethod1() {
        System.out.println("    testMethod1");
        assertTrue(true);
    }

    @Test
    public void testMethod2() {
        System.out.println("    testMethod2");
        assertEquals(4, 2 + 2);
    }

    // 忽略此测试(暂不执行)
    @Ignore("等待需求确认后实现")
    @Test
    public void testNotReady() {
        // 这个测试暂时跳过
    }

    // 超时测试(单位:毫秒)
    @Test(timeout = 1000)
    public void testTimeout() {
        // 如果超过1秒仍未完成,判定为失败
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出示例:

复制代码
[@BeforeClass] 整个测试类初始化一次
  [@Before] 每个测试方法前执行
    testMethod1
  [@After] 每个测试方法后执行
  [@Before] 每个测试方法前执行
    testMethod2
  [@After] 每个测试方法后执行
[@AfterClass] 整个测试类清理一次

三、断言(Assertions)

断言是测试的核心,JUnit提供了丰富的断言方法。理解各种断言方法的适用场景,能让你的测试更精准、失败信息更清晰。

典型错误用法 :用assertTrue(condition)替代所有断言。比如assertTrue(a == b)------如果失败,你只能看到"expected true but was false",但看不到a和b的实际值。应该用assertEquals(expected, actual)------失败时会打印"expected 5 but was 3",直接定位问题。同理,不要用assertTrue(list.contains(x)),而要用专门的集合断言或assertThat

java 复制代码
import org.junit.Test;
import static org.junit.Assert.*;

public class AssertionDemo {

    @Test
    public void testAssertions() {
        // 等值断言
        assertEquals("字符串应相等", "hello", "hello");
        assertEquals("浮点数有精度误差", 3.14, 3.14159, 0.01); // 第三个参数是误差范围

        // 真假断言
        assertTrue("条件应为真", 5 > 3);
        assertFalse("条件应为假", 1 > 2);

        // 空值断言
        String str = null;
        assertNull("应为null", str);
        assertNotNull("不应为null", "hello");

        // 相同引用断言(== 而非 equals)
        String s1 = "abc";
        String s2 = s1;
        assertSame(s1, s2);

        // 数组断言
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};
        assertArrayEquals(expected, actual);
    }
}

经验法则:每个测试方法只测一个行为,并使用有意义的断言消息(第一个参数),这样测试失败时能快速定位问题。

一条测试多个断言还是多个测试? 原则是:测试同一个"行为"的不同方面可以放多个断言;测试不同"行为"必须分开。比如测试divide方法,testDivideNormal可以同时断言divide(6,2)==3divide(0,5)==0,因为这都是在测"正常除法";但testDivideByZero必须单独写一个测试方法,因为它在测"异常路径"。混在一起的话,第一个断言失败后,后面的断言就不会执行了,你无法知道后面的行为是否也出问题了。

四、测试套件(Test Suite)

当测试类越来越多时,可以用测试套件将它们组合在一起批量执行:

java 复制代码
import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({
    CalculatorTest.class,
    LifecycleTest.class,
    AssertionDemo.class
})
public class AllTests {
    // 此类为空,仅作为套件的容器
    // 运行此类即可执行所有指定的测试类
}

多个套件还可以嵌套组合:

java 复制代码
@RunWith(Suite.class)
@Suite.SuiteClasses({
    BusinessTestSuite.class,
    UtilTestSuite.class
})
public class FullTestSuite {
}

五、参数化测试

当需要测试同一逻辑在不同输入下的表现时,参数化测试可以避免写大量相似的测试方法:

java 复制代码
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import java.util.Arrays;
import java.util.Collection;
import static org.junit.Assert.assertEquals;

@RunWith(Parameterized.class)
public class CalculatorParameterizedTest {

    private int a;
    private int b;
    private int expected;

    // 构造器接收参数
    public CalculatorParameterizedTest(int a, int b, int expected) {
        this.a = a;
        this.b = b;
        this.expected = expected;
    }

    // 提供参数数据的方法
    @Parameterized.Parameters(name = "{index}: {0} + {1} = {2}")
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][] {
            {1, 1, 2},
            {2, 3, 5},
            {0, 0, 0},
            {-1, 1, 0},
            {100, 200, 300}
        });
    }

    @Test
    public void testAdd() {
        Calculator calc = new Calculator();
        assertEquals(expected, calc.add(a, b));
    }
}

六、Mock简介

单元测试讲究隔离。当被测试的类依赖数据库或外部服务时,我们用Mock对象来模拟这些依赖。

为什么要Mock? 单元测试的目标是验证被测类自身的逻辑,而不是它所依赖的外部系统。如果你的UserService里调用了PaymentGateway,而PaymentGateway又连接了真实的支付接口,那么:

  • 测试会变慢(网络延迟)
  • 测试不稳定(支付接口可能挂了)
  • 会产生副作用(真的扣了钱)
  • 无法测试边缘场景(如支付接口返回超时、返回异常)

Mock对象让你完全掌控依赖的行为,可以模拟"支付成功""支付失败""支付超时"等各种场景,而不依赖任何外部系统。

java 复制代码
// 需要引入 Mockito 依赖
// 业务类:依赖外部服务
class OrderService {
    private PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public String placeOrder(double amount) {
        if (paymentGateway.process(amount)) {
            return "订单成功";
        }
        return "支付失败";
    }
}

interface PaymentGateway {
    boolean process(double amount);
}

// 手动Mock
class MockPaymentGateway implements PaymentGateway {
    private boolean shouldSucceed;

    public MockPaymentGateway(boolean shouldSucceed) {
        this.shouldSucceed = shouldSucceed;
    }

    @Override
    public boolean process(double amount) {
        return shouldSucceed;
    }
}

// 测试
@Test
public void testPlaceOrderSuccess() {
    PaymentGateway mockGateway = new MockPaymentGateway(true);
    OrderService service = new OrderService(mockGateway);
    assertEquals("订单成功", service.placeOrder(100.0));
}

@Test
public void testPlaceOrderFailure() {
    PaymentGateway mockGateway = new MockPaymentGateway(false);
    OrderService service = new OrderService(mockGateway);
    assertEquals("支付失败", service.placeOrder(100.0));
}

更推荐使用Mockito框架进行Mock:

java 复制代码
import static org.mockito.Mockito.*;

@Test
public void testWithMockito() {
    // 创建Mock对象
    PaymentGateway gateway = mock(PaymentGateway.class);
    // 设定行为
    when(gateway.process(anyDouble())).thenReturn(true);

    OrderService service = new OrderService(gateway);
    String result = service.placeOrder(50.0);

    assertEquals("订单成功", result);
    // 验证方法被调用了
    verify(gateway).process(50.0);
}

总结

单元测试不是负担,而是开发者的安全网。JUnit的核心要素包括:@Test注解 标记测试方法、断言(Assert)验证结果、@Before/@After 管理测试生命周期、测试套件批量执行。对于外部依赖,使用Mock对象来隔离测试。

测试覆盖率不是目的,有意义的测试才是。养成"写代码前先想测试"的习惯,你的代码质量将会有质的飞跃。

TDD入门:测试驱动开发(Test-Driven Development)的核心理念是"先写测试,再写实现"。三部曲是:Red(写一个失败的测试)→ Green(写最少代码让测试通过)→ Refactor(重构代码,测试仍然通过)。TDD最大的好处不是"先写测试"本身,而是它迫使你先思考"这个类的接口应该是什么样的""边界条件有哪些""什么算成功什么算失败"------这些思考反过来会让你的API设计更合理。即使你不完全采纳TDD,在写复杂业务逻辑前先列一份测试场景清单,也是极好的实践。

✅ 亮点总结

  • @Test注解 标记测试方法,@Before/@After管理测试生命周期,执行顺序清晰可控
  • 丰富的断言方法(assertEquals、assertTrue、assertNull、assertArrayEquals)覆盖各种验证场景
  • 参数化测试(@Parameterized)实现数据驱动,一组测试数据覆盖多种输入情况
  • Mock对象隔离外部依赖,配合Mockito的when/thenReturn和verify实现行为验证
  • 测试套件(@Suite)批量组织和管理测试类,支持嵌套分组

适用场景

  • 日常开发中为Service层业务逻辑编写单元测试,确保核心逻辑正确
  • 回归测试阶段批量运行测试套件,验证代码修改未引入新Bug
  • 使用Mock隔离数据库或外部API依赖,在CI/CD流水线中实现快速无环境测试

扩展方向

  • 学习JUnit 5的新特性:@DisplayName自定义测试名称、@Nested内嵌测试类、@ParameterizedTest增强参数化
  • 深入Mockito框架:掌握spy、ArgumentCaptor、doThrow等高级Mock技巧
  • 推荐阅读下一篇文章:Java日志框架使用指南,掌握项目排错的核心工具
相关推荐
shushangyun_2 小时前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化
JAVA9652 小时前
JAVA面试-JVM篇 03-JVM运行时数据区哪些是线程私有的哪些是共享的
java·jvm·面试
于先生吖2 小时前
教育类Java实战项目:在线错题整理平台分层架构设计与接口源码解析
java·开发语言
慧一居士2 小时前
Feign的GET请求如何传递对象参数?
java·spring cloud
开发小能手-roy3 小时前
Java集合框架选型指南:从ArrayList到ConcurrentSkipListMap
java·开发语言
凡人叶枫3 小时前
Effective C++ 条款41:了解隐式接口和编译期多态
java·开发语言·c++·effective c++
凡人叶枫3 小时前
Effective C++ 条款42:了解 typename 的双重意义
java·linux·服务器·c++
chushiyunen4 小时前
java中的路径处理、左右斜杠
java·开发语言·python
yyxx4121234 小时前
上海企业如何选择专业的钉钉服务商
java·大数据·人工智能·钉钉