3022 字
15 分钟

Java单元测试详解

2026-02-04
浏览量 加载中...

Java单元测试详解#

什么是单元测试?#

单元测试是一种测试方法,用于测试软件中的最小可测试单元,通常是一个方法或一个类。单元测试的目的是验证代码的行为是否符合预期,确保代码的质量和可靠性。

单元测试的重要性#

  1. 提高代码质量:单元测试可以帮助发现代码中的错误和缺陷
  2. 促进代码重构:有了单元测试,可以放心地进行代码重构
  3. 提高开发效率:单元测试可以快速验证代码的正确性,减少调试时间
  4. 便于维护:单元测试可以作为代码的文档,说明代码的预期行为
  5. 促进团队协作:单元测试可以确保代码的行为符合团队的预期

单元测试的核心概念#

1. 测试框架#

Java中常用的单元测试框架包括:

  • JUnit:最流行的Java单元测试框架
  • TestNG:功能强大的测试框架,支持更多的测试类型
  • Mockito:用于模拟(mock)对象的框架
  • PowerMock:扩展了Mockito,支持模拟静态方法、私有方法等

2. 测试用例#

测试用例是一个具体的测试场景,它包含测试代码和预期结果。

3. 断言#

断言是用于验证测试结果是否符合预期的语句。

4. 测试套件#

测试套件是一组相关的测试用例,可以一起运行。

5. 模拟对象#

模拟对象是用于替代真实对象的假对象,用于隔离被测试的代码。

JUnit 5详解#

JUnit 5是JUnit的最新版本,它由以下几个模块组成:

  • JUnit Platform:测试平台,用于启动测试
  • JUnit Jupiter:新的编程模型和扩展模型
  • JUnit Vintage:兼容JUnit 3和JUnit 4的测试

1. 基本用法#

1.1 依赖配置#

在Maven项目中,需要添加以下依赖:

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
</dependencies>

1.2 基本测试类#

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
private Calculator calculator = new Calculator();
@Test
void testAdd() {
int result = calculator.add(2, 3);
assertEquals(5, result);
}
@Test
void testSubtract() {
int result = calculator.subtract(5, 2);
assertEquals(3, result);
}
@Test
void testMultiply() {
int result = calculator.multiply(2, 3);
assertEquals(6, result);
}
@Test
void testDivide() {
int result = calculator.divide(6, 2);
assertEquals(3, result);
}
@Test
void testDivideByZero() {
assertThrows(ArithmeticException.class, () -> {
calculator.divide(6, 0);
});
}
}

1.3 断言方法#

JUnit 5提供了以下常用的断言方法:

  • assertEquals:验证两个值是否相等
  • assertNotEquals:验证两个值是否不相等
  • assertTrue:验证条件是否为真
  • assertFalse:验证条件是否为假
  • assertNull:验证对象是否为null
  • assertNotNull:验证对象是否不为null
  • assertThrows:验证是否抛出指定的异常
  • assertTimeout:验证操作是否在指定时间内完成
  • assertAll:验证多个断言是否都通过

2. 测试生命周期#

JUnit 5提供了以下生命周期注解:

  • @BeforeAll:在所有测试方法之前执行,静态方法
  • @BeforeEach:在每个测试方法之前执行
  • @Test:测试方法
  • @AfterEach:在每个测试方法之后执行
  • @AfterAll:在所有测试方法之后执行,静态方法
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class LifecycleTest {
@BeforeAll
static void beforeAll() {
System.out.println("Before all tests");
}
@BeforeEach
void beforeEach() {
System.out.println("Before each test");
}
@Test
void test1() {
System.out.println("Test 1");
assertTrue(true);
}
@Test
void test2() {
System.out.println("Test 2");
assertTrue(true);
}
@AfterEach
void afterEach() {
System.out.println("After each test");
}
@AfterAll
static void afterAll() {
System.out.println("After all tests");
}
}

3. 参数化测试#

参数化测试允许使用不同的参数运行同一个测试方法。

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
public class ParameterizedTestExample {
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"4, 5, 9",
"6, 7, 13"
})
void testAdd(int a, int b, int expected) {
Calculator calculator = new Calculator();
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("provideNumbers")
void testIsEven(int number, boolean expected) {
assertEquals(expected, number % 2 == 0);
}
static Stream<Object[]> provideNumbers() {
return Stream.of(
new Object[]{1, false},
new Object[]{2, true},
new Object[]{3, false},
new Object[]{4, true}
);
}
}

4. 测试接口和默认方法#

JUnit 5支持测试接口和默认方法,可以在接口中定义测试方法。

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
interface TestInterface {
@Test
default void testDefaultMethod() {
assertTrue(true);
}
@Test
void testAbstractMethod();
}
class TestInterfaceImpl implements TestInterface {
@Override
public void testAbstractMethod() {
assertTrue(true);
}
}

5. 嵌套测试#

JUnit 5支持嵌套测试,可以在测试类中定义嵌套的测试类。

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class NestedTestExample {
@Test
void testOuter() {
assertTrue(true);
}
@Nested
class InnerTest {
@Test
void testInner() {
assertTrue(true);
}
}
}

Mockito详解#

Mockito是一个用于模拟(mock)对象的框架,它可以帮助我们隔离被测试的代码。

1. 基本用法#

1.1 依赖配置#

在Maven项目中,需要添加以下依赖:

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.8.1</version>
<scope>test</scope>
</dependency>

1.2 基本示例#

import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
public class MockitoExample {
@Mock
private UserRepository userRepository;
private UserService userService;
public MockitoExample() {
MockitoAnnotations.openMocks(this);
userService = new UserService(userRepository);
}
@Test
void testFindUserById() {
// 模拟userRepository.findById方法的行为
User expectedUser = new User(1, "John");
when(userRepository.findById(1)).thenReturn(expectedUser);
// 调用被测试的方法
User actualUser = userService.findUserById(1);
// 验证结果
assertEquals(expectedUser, actualUser);
// 验证userRepository.findById方法是否被调用了一次,参数是1
verify(userRepository, times(1)).findById(1);
}
}

2. 核心功能#

2.1 模拟方法调用#

// 模拟方法返回值
when(mock.method()).thenReturn(value);
// 模拟方法抛出异常
when(mock.method()).thenThrow(new Exception());
// 模拟方法执行自定义行为
when(mock.method()).thenAnswer(invocation -> {
// 自定义行为
return value;
});

2.2 验证方法调用#

// 验证方法是否被调用了一次
verify(mock).method();
// 验证方法是否被调用了指定次数
verify(mock, times(2)).method();
// 验证方法是否被调用了至少一次
verify(mock, atLeastOnce()).method();
// 验证方法是否被调用了最多一次
verify(mock, atMostOnce()).method();
// 验证方法是否从未被调用
verify(mock, never()).method();
// 验证方法是否在指定时间内被调用
verify(mock, timeout(100)).method();

2.3 模拟对象的创建#

// 使用@Mock注解
@Mock
private UserRepository userRepository;
// 使用Mockito.mock方法
UserRepository userRepository = Mockito.mock(UserRepository.class);
// 使用Mockito.spy方法(部分模拟)
UserRepository userRepository = Mockito.spy(new UserRepositoryImpl());

2.4 参数匹配器#

// 任何参数
when(mock.method(any())).thenReturn(value);
// 任何String类型的参数
when(mock.method(anyString())).thenReturn(value);
// 任何int类型的参数
when(mock.method(anyInt())).thenReturn(value);
// 参数等于指定值
when(mock.method(eq(value))).thenReturn(value);
// 参数满足指定条件
when(mock.method(argThat(argument -> argument > 0))).thenReturn(value);

单元测试的最佳实践#

1. 测试方法的命名#

测试方法的名称应该清晰地说明测试的目的,通常使用以下格式:

  • test[MethodName][Scenario][ExpectedResult]
  • 或者使用更简洁的格式:should[ExpectedBehavior]When[Scenario]
// 好的命名
@Test
void testAddPositiveNumbers() {
// 测试代码
}
@Test
void shouldReturnSumWhenAddingTwoNumbers() {
// 测试代码
}
// 不好的命名
@Test
void test1() {
// 测试代码
}

2. 测试的独立性#

测试方法应该是独立的,不依赖于其他测试方法的执行顺序。

// 不好的做法
private int counter = 0;
@Test
void testIncrement() {
counter++;
assertEquals(1, counter);
}
@Test
void testDecrement() {
counter--;
assertEquals(0, counter); // 依赖于testIncrement的执行
}
// 好的做法
@Test
void testIncrement() {
int counter = 0;
counter++;
assertEquals(1, counter);
}
@Test
void testDecrement() {
int counter = 1;
counter--;
assertEquals(0, counter);
}

3. 测试的覆盖范围#

测试应该覆盖代码的主要路径和边界条件。

@Test
void testDivide() {
Calculator calculator = new Calculator();
assertEquals(3, calculator.divide(6, 2));
}
@Test
void testDivideByZero() {
Calculator calculator = new Calculator();
assertThrows(ArithmeticException.class, () -> {
calculator.divide(6, 0);
});
}
@Test
void testDivideNegativeNumbers() {
Calculator calculator = new Calculator();
assertEquals(-3, calculator.divide(-6, 2));
assertEquals(-3, calculator.divide(6, -2));
assertEquals(3, calculator.divide(-6, -2));
}

4. 避免测试实现细节#

测试应该测试代码的行为,而不是实现细节。

// 不好的做法(测试实现细节)
@Test
void testCalculateTotal() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Apple", 10));
cart.addItem(new Item("Banana", 5));
assertEquals(15, cart.getTotal());
// 测试了getTotal方法的返回值,这是行为
// 但如果我们测试cart.items的大小,就是测试实现细节
}
// 好的做法(测试行为)
@Test
void testCalculateTotal() {
ShoppingCart cart = new ShoppingCart();
cart.addItem(new Item("Apple", 10));
cart.addItem(new Item("Banana", 5));
assertEquals(15, cart.getTotal());
}

5. 使用模拟对象#

对于依赖外部系统的代码,应该使用模拟对象来隔离被测试的代码。

@Test
void testProcessOrder() {
// 模拟外部依赖
OrderRepository orderRepository = Mockito.mock(OrderRepository.class);
PaymentService paymentService = Mockito.mock(PaymentService.class);
// 设置模拟对象的行为
when(paymentService.processPayment(any())).thenReturn(true);
// 创建被测试的对象
OrderService orderService = new OrderService(orderRepository, paymentService);
// 调用被测试的方法
Order order = new Order(1, 100);
boolean result = orderService.processOrder(order);
// 验证结果
assertTrue(result);
verify(orderRepository).save(order);
verify(paymentService).processPayment(order);
}

6. 测试的可读性#

测试代码应该清晰易读,便于理解和维护。

// 不好的做法(测试代码难以理解)
@Test
void testComplexMethod() {
// 大量的测试代码
// 难以理解测试的目的和预期结果
}
// 好的做法(测试代码清晰易读)
@Test
void testComplexMethod() {
// 准备测试数据
User user = new User(1, "John");
Order order = new Order(1, user, 100);
// 设置模拟对象的行为
when(inventoryService.checkStock(any())).thenReturn(true);
when(paymentService.processPayment(any())).thenReturn(true);
// 调用被测试的方法
boolean result = orderService.processOrder(order);
// 验证结果
assertTrue(result);
verify(inventoryService).checkStock(order);
verify(paymentService).processPayment(order);
verify(orderRepository).save(order);
}

7. 测试的速度#

测试应该运行得快,以便于频繁运行。

// 不好的做法(测试运行慢)
@Test
void testWithDatabase() {
// 连接数据库
// 执行数据库操作
// 测试代码
}
// 好的做法(使用模拟对象,测试运行快)
@Test
void testWithMockDatabase() {
// 模拟数据库操作
UserRepository userRepository = Mockito.mock(UserRepository.class);
when(userRepository.findById(1)).thenReturn(new User(1, "John"));
// 测试代码
UserService userService = new UserService(userRepository);
User user = userService.findUserById(1);
assertEquals("John", user.getName());
}

8. 测试的维护#

测试代码也需要维护,应该保持测试代码的质量。

// 不好的做法(测试代码重复)
@Test
void testAddPositiveNumbers() {
Calculator calculator = new Calculator();
assertEquals(3, calculator.add(1, 2));
}
@Test
void testAddNegativeNumbers() {
Calculator calculator = new Calculator();
assertEquals(-3, calculator.add(-1, -2));
}
// 好的做法(使用@BeforeEach注解,减少重复代码)
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
void testAddPositiveNumbers() {
assertEquals(3, calculator.add(1, 2));
}
@Test
void testAddNegativeNumbers() {
assertEquals(-3, calculator.add(-1, -2));
}

常见陷阱#

1. 测试过度#

不要测试所有的代码,只测试重要的代码和可能出错的代码。

2. 测试实现细节#

测试应该测试代码的行为,而不是实现细节。如果测试实现细节,当实现改变时,测试就会失败。

3. 测试依赖外部系统#

测试应该是独立的,不应该依赖外部系统,如数据库、网络等。应该使用模拟对象来隔离被测试的代码。

4. 测试运行慢#

测试应该运行得快,以便于频繁运行。如果测试运行慢,开发者就会不愿意运行测试。

5. 测试代码质量差#

测试代码也需要维护,应该保持测试代码的质量。如果测试代码质量差,就会难以理解和维护。

6. 测试覆盖率低#

测试覆盖率是衡量测试质量的一个指标,但不是唯一的指标。应该确保测试覆盖了代码的主要路径和边界条件。

7. 测试与生产代码不同步#

当生产代码改变时,测试代码也应该相应地改变。如果测试代码与生产代码不同步,测试就会失去意义。

总结#

单元测试是Java开发中的重要部分,它可以帮助提高代码质量、促进代码重构、提高开发效率、便于维护和促进团队协作。本文介绍了单元测试的基本概念、JUnit 5和Mockito的使用方法,以及单元测试的最佳实践和常见陷阱。

希望本文能够帮助你更好地理解和使用单元测试,提高你的Java开发技能。

练习#

  1. 编写一个计算器类,并为其编写单元测试,测试加法、减法、乘法、除法等方法。

  2. 编写一个用户服务类,使用Mockito模拟用户仓库,测试用户服务的方法。

  3. 编写一个订单服务类,使用Mockito模拟订单仓库和支付服务,测试订单服务的方法。

  4. 编写参数化测试,测试一个方法的不同输入和输出。

  5. 编写嵌套测试,测试一个复杂类的不同方法。

  6. 编写测试用例,测试代码的边界条件。

  7. 编写测试用例,测试代码的异常处理。

  8. 编写测试套件,运行一组相关的测试用例。

  9. 使用TestNG编写测试用例,比较TestNG和JUnit的不同。

  10. 使用PowerMock模拟静态方法和私有方法,测试依赖这些方法的代码。

通过这些练习,你将更加熟悉单元测试的使用,为后续的学习做好准备。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Java单元测试详解
https://blog.vanilla.net.cn/posts/2026-02-05-java-unit-testing/
作者
鹁鸪
发布于
2026-02-04
许可协议
CC BY-NC-SA 4.0

评论区

目录