Java单元测试JUnit入门
文章目录
- Java单元测试JUnit入门
-
- 前言
- 一、环境准备与第一个测试
- 二、JUnit常用注解
- 三、断言(Assertions)
- [四、测试套件(Test Suite)](#四、测试套件(Test Suite))
- 五、参数化测试
- 六、Mock简介
- 总结
- [✅ 亮点总结](#✅ 亮点总结)
- 适用场景
- 扩展方向
前言
"这段代码没问题,不用测试"------这是软件工程中最危险的自负。一个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)==3和divide(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日志框架使用指南,掌握项目排错的核心工具