本文从实际 Java 开发的角度系统介绍 JUnit 测试框架,不仅讲解常用注解和 API,还会结合电商订单业务分析单元测试应该如何设计,以及面试中常见的测试相关问题。
核心内容包括:
- 什么是 JUnit,为什么实际项目需要测试
- JUnit 的核心架构与运行原理
- JUnit 常用注解与生命周期
- Assertions 断言机制
- 参数化测试
- JUnit 与 Mockito 的关系
- 真实订单业务单元测试
- 单元测试、集成测试与端到端测试
- Maven 中 JUnit 的执行过程
- 实际开发中的测试最佳实践
- Java 面试高频问题
一、为什么软件开发需要测试?
很多刚开始学习 Java 的开发者会产生一个疑问:
程序写完之后,我自己运行一下,看看结果是否正确,不就可以了吗?为什么还要专门写测试代码?
假设我们有一个简单的方法:
java
public int add(int a, int b) {
return a + b;
}
手动测试当然很简单:
java
System.out.println(add(1, 2));
运行结果:
text
3
但是实际业务系统远远没有这么简单。
例如一个电商系统中的"创建订单"功能可能包含:
text
用户提交订单
↓
校验请求参数
↓
检查商品库存
↓
计算商品金额
↓
计算优惠券金额
↓
计算最终支付金额
↓
扣减库存
↓
保存订单
↓
发送消息
现在业务代码修改了一个优惠券计算规则。
开发人员需要确认:
text
普通订单是否正常?
优惠券订单是否正常?
库存不足是否正确报错?
购买数量为 0 是否正确拦截?
订单金额是否计算正确?
库存是否真的被扣减?
异常情况下订单是否会被错误保存?
如果每修改一次代码,都需要开发人员手动启动项目、准备数据、调用接口、查看数据库,那么开发效率会非常低。
于是就需要:
自动化测试。
测试代码可以自动调用业务代码,并自动判断最终结果是否符合预期。
例如:
java
@Test
void shouldCalculateOrderAmountCorrectly() {
BigDecimal result = orderService.calculateAmount(
new BigDecimal("100"),
2
);
assertEquals(
new BigDecimal("200"),
result
);
}
以后无论代码修改多少次,只需要执行:
bash
mvn test
就可以自动验证程序是否仍然满足原来的业务规则。
二、什么是 JUnit?
JUnit 是 Java 生态中最经典的自动化测试框架之一。
它主要用于:
text
编写测试
管理测试
执行测试
判断测试结果
生成测试报告
最简单的 JUnit 测试:
java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void testAdd() {
int result = 1 + 2;
assertEquals(3, result);
}
}
运行测试后,JUnit 会判断:
text
expected: 3
actual: 3
两者相同:
text
Test Passed
如果代码出现 Bug:
java
int result = 1 + 2 + 1;
测试结果:
text
expected: 3
actual: 4
JUnit 会告诉我们:
text
Test Failed
因此可以把 JUnit 理解为:
JUnit 是帮助 Java 开发人员自动执行代码,并自动验证代码行为是否符合预期的测试框架。
三、JUnit 的核心价值是什么?
JUnit 的核心价值并不是"检查一个方法有没有返回正确结果"这么简单。
在真实项目中,它主要解决以下几个问题。
1. 防止代码修改破坏已有功能
例如:
text
V1版本:
优惠券满100减20
后来产品提出新需求:
text
V2版本:
VIP用户满100减30
开发人员修改优惠券代码后,可能导致:
text
普通用户优惠券计算异常
如果之前已经编写测试:
text
CouponServiceTest
├── 普通用户满100减20
├── VIP用户满100减30
├── 金额不足100不优惠
└── 优惠金额不能超过订单金额
修改代码后重新运行测试,就能快速发现:
text
原来的普通用户逻辑被破坏了
这种测试称为:
回归测试(Regression Testing)
2. 提高重构代码的安全性
例如原来的代码:
java
public BigDecimal calculatePrice(...) {
// 100行代码
}
准备重构为:
java
calculateOriginalPrice();
calculateDiscount();
calculateShippingFee();
calculateFinalPrice();
代码结构发生了巨大变化。
但是只要原来的测试全部通过,就可以在一定程度上说明:
text
内部实现发生了变化
但是外部业务行为没有发生变化
这也是自动化测试最大的价值之一。
3. 让业务规则变得更加明确
例如看到下面的测试:
java
@Test
void shouldThrowExceptionWhenStockIsInsufficient() {
}
开发人员马上就知道:
text
库存不足 → 应该抛出异常
测试代码本身实际上也是一种:
可执行的业务文档。
四、JUnit 的核心架构
现代 JUnit 可以从三个核心部分理解:
text
JUnit Platform
│
├── JUnit Jupiter
│
└── JUnit Vintage
1. JUnit Platform
JUnit Platform 是整个测试系统运行的基础平台。
可以简单理解为:
text
JUnit Platform
↓
发现测试
↓
选择测试引擎
↓
执行测试
↓
返回测试结果
IDEA、Maven、Gradle 等工具都可以通过 JUnit Platform 执行测试。
2. JUnit Jupiter
JUnit Jupiter 是现代 JUnit 最核心的部分。
开发人员平时编写的:
java
@Test
@BeforeEach
@AfterEach
@ParameterizedTest
@ExtendWith
基本都属于 Jupiter 编程模型。
例如:
java
import org.junit.jupiter.api.Test;
注意包名:
text
org.junit.jupiter
看到这个包名,通常意味着使用的是:
text
JUnit Jupiter
3. JUnit Vintage
JUnit Vintage 主要用于兼容旧的 JUnit 3 和 JUnit 4 测试。
例如老项目中可能存在:
java
import org.junit.Test;
而现代 Jupiter 通常是:
java
import org.junit.jupiter.api.Test;
Vintage 可以帮助老项目逐步迁移。
不过在新项目中,一般应该优先使用:
text
JUnit Jupiter
五、在 Maven 项目中引入 JUnit
标准 Maven 项目结构:
text
project
│
├── src
│ ├── main
│ │ └── java
│ │
│ └── test
│ └── java
│
└── pom.xml
业务代码:
text
src/main/java
测试代码:
text
src/test/java
例如:
text
src
├── main
│ └── java
│ └── com
│ └── ken
│ └── service
│ └── OrderService.java
│
└── test
└── java
└── com
└── ken
└── service
└── OrderServiceTest.java
通常推荐:
text
生产类:OrderService
测试类:OrderServiceTest
普通 Maven 项目可以添加:
xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.1</version>
<scope>test</scope>
</dependency>
其中:
xml
<scope>test</scope>
表示:
text
JUnit 依赖只在测试阶段使用
不会进入正式业务代码
对于使用 Spring Boot 的项目,一般通过:
xml
spring-boot-starter-test
统一管理测试相关依赖,而不是随意单独修改 JUnit 版本。
六、第一个 JUnit 测试
业务代码:
java
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
测试代码:
java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20);
assertEquals(30, result);
}
}
整个测试可以拆成三个阶段。
七、AAA 测试模式
实际开发中非常推荐使用:
text
Arrange
Act
Assert
简称:
text
AAA
Arrange:准备测试数据
java
Calculator calculator = new Calculator();
Act:执行被测试代码
java
int result = calculator.add(10, 20);
Assert:验证结果
java
assertEquals(30, result);
完整代码:
java
@Test
void testAdd() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.add(10, 20);
// Assert
assertEquals(30, result);
}
八、Given-When-Then 模式
在业务测试中,还有一种更加常见的写法:
text
Given
When
Then
例如:
java
@Test
void shouldCreateOrderSuccessfully() {
// Given
Long productId = 1001L;
int quantity = 2;
// When
Order order = orderService.createOrder(productId, quantity);
// Then
assertNotNull(order);
}
含义:
text
Given
给定什么业务条件
When
发生什么操作
Then
应该产生什么结果
在业务代码中,Given-When-Then 往往比 AAA 更容易表达业务语义。
九、JUnit 常用注解
1. @Test
表示当前方法是一个测试方法。
java
@Test
void testCreateOrder() {
}
JUnit 会自动发现并执行这个方法。
2. @BeforeEach
每个测试方法执行之前运行。
java
@BeforeEach
void setUp() {
System.out.println("初始化测试数据");
}
例如:
java
class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService();
}
@Test
void testRegister() {
}
@Test
void testLogin() {
}
}
执行顺序:
text
setUp
testRegister
setUp
testLogin
常见用途:
text
创建测试对象
初始化公共测试数据
初始化 Mock
准备测试环境
3. @AfterEach
每个测试执行完成后运行。
java
@AfterEach
void tearDown() {
System.out.println("清理测试数据");
}
执行过程:
text
@BeforeEach
↓
@Test
↓
@AfterEach
常见用途:
text
释放资源
删除临时文件
清理测试数据
关闭连接
4. @BeforeAll
整个测试类执行之前,只运行一次。
java
@BeforeAll
static void beforeAll() {
}
例如:
text
beforeAll
testA
testB
testC
只执行一次。
5. @AfterAll
整个测试类执行结束后执行一次。
java
@AfterAll
static void afterAll() {
}
完整生命周期:
text
@BeforeAll
@BeforeEach
@Test
@AfterEach
@BeforeEach
@Test
@AfterEach
@AfterAll
十、@BeforeEach 和 @BeforeAll 有什么区别?
这是非常经典的面试问题。
@BeforeEach
text
每执行一个测试方法
就执行一次
适合:
text
每个测试都需要一个全新的测试对象
每个测试之间不能共享状态
@BeforeAll
text
整个测试类只执行一次
适合:
text
启动成本较高的公共资源
例如:
text
初始化大型测试环境
创建共享资源
准备一次性数据
实际开发中,应优先保证:
测试之间彼此独立。
因此业务单元测试通常更常使用:
java
@BeforeEach
十一、JUnit 中的断言 Assertions
测试的核心不是:
text
代码有没有执行
而是:
text
执行结果是否符合预期
因此 JUnit 提供了:
text
Assertions
1. assertEquals
判断两个值是否相等。
java
assertEquals(100, result);
完整形式:
java
assertEquals(expected, actual);
注意顺序:
text
expected
actual
例如:
java
assertEquals(100, order.getAmount());
2. assertNotEquals
判断两个值不相等。
java
assertNotEquals(0, result);
3. assertTrue
java
assertTrue(user.isActive());
4. assertFalse
java
assertFalse(order.isDeleted());
5. assertNull
java
assertNull(user.getDeletedTime());
6. assertNotNull
java
assertNotNull(order);
7. assertSame
判断是否为:
text
同一个对象
java
assertSame(object1, object2);
它比较的是:
text
对象引用
而不是单纯比较:
text
对象内容
十二、异常测试:assertThrows
真实业务中,异常也是一种正常的业务结果。
例如:
text
库存不足
用户不存在
余额不足
优惠券过期
订单不存在
业务代码:
java
public void buy(int stock) {
if (stock <= 0) {
throw new IllegalStateException("库存不足");
}
}
测试:
java
@Test
void shouldThrowExceptionWhenStockIsInsufficient() {
IllegalStateException exception =
assertThrows(
IllegalStateException.class,
() -> service.buy(0)
);
assertEquals(
"库存不足",
exception.getMessage()
);
}
这里验证了两个内容:
text
1. 是否真的抛出了异常
2. 异常信息是否正确
十三、不要这样测试异常
错误写法:
java
@Test
void testBuy() {
try {
service.buy(0);
} catch (Exception e) {
System.out.println("发生异常");
}
}
问题在于:
text
即使没有发生异常
测试也可能通过
正确方式:
java
assertThrows(
BusinessException.class,
() -> service.buy(0)
);
十四、assertAll:一次验证多个结果
例如创建订单后,需要验证:
text
订单用户
商品
数量
金额
状态
可以写:
java
assertAll(
() -> assertEquals(
1001L,
order.getUserId()
),
() -> assertEquals(
2001L,
order.getProductId()
),
() -> assertEquals(
2,
order.getQuantity()
),
() -> assertEquals(
OrderStatus.CREATED,
order.getStatus()
)
);
相比连续调用多个断言:
java
assertEquals(...);
assertEquals(...);
assertEquals(...);
assertAll 可以将多个相关验证组织在一起。
十五、参数化测试
假设业务规则:
text
购买数量必须大于 0
需要测试:
text
0
-1
-10
-100
最直接的方式:
java
@Test
void testQuantityZero() {
}
@Test
void testQuantityMinusOne() {
}
@Test
void testQuantityMinusTen() {
}
代码重复非常严重。
JUnit 提供:
java
@ParameterizedTest
@ValueSource
java
@ParameterizedTest
@ValueSource(ints = {0, -1, -10, -100})
void shouldRejectInvalidQuantity(int quantity) {
assertThrows(
IllegalArgumentException.class,
() -> orderService.createOrder(quantity)
);
}
JUnit 会分别执行:
text
quantity = 0
quantity = -1
quantity = -10
quantity = -100
相当于自动执行了:
text
4 个测试
@CsvSource
适合多参数测试。
例如会员折扣:
text
普通会员 100元 → 100元
VIP会员 100元 → 90元
SVIP会员 100元 → 80元
测试:
java
@ParameterizedTest
@CsvSource({
"NORMAL, 100, 100",
"VIP, 100, 90",
"SVIP, 100, 80"
})
void shouldCalculatePriceCorrectly(
String level,
int originalPrice,
int expectedPrice
) {
int actualPrice =
priceService.calculate(
level,
originalPrice
);
assertEquals(
expectedPrice,
actualPrice
);
}
参数化测试非常适合:
text
边界值测试
规则测试
不同用户类型
不同订单状态
不同输入组合
十六、@DisplayName:让测试结果更容易阅读
例如:
java
@Test
@DisplayName("库存不足时创建订单应该失败")
void shouldFailWhenStockIsInsufficient() {
}
相比:
text
shouldFailWhenStockIsInsufficient
测试报告中可以直接显示:
text
库存不足时创建订单应该失败
大型项目中非常有利于:
text
查看测试报告
定位失败用例
理解业务规则
十七、测试方法应该如何命名?
不推荐:
java
test1()
test2()
testOrder()
因为无法表达:
text
测试什么?
什么情况下?
预期结果是什么?
推荐:
java
shouldCreateOrderSuccessfully()
或者:
java
shouldThrowExceptionWhenStockIsInsufficient()
一种常见命名规则:
text
should + 预期行为 + when + 条件
例如:
java
shouldRejectRequestWhenQuantityIsZero()
java
shouldApplyDiscountWhenCouponIsValid()
java
shouldSaveOrderWhenStockIsEnough()
测试方法的名字应该尽可能表达:
业务行为。
十八、真实业务案例:订单创建
下面通过一个真实业务场景理解 JUnit。
业务需求:
text
用户创建订单
1. 购买数量必须大于 0
2. 检查商品库存
3. 库存不足禁止下单
4. 计算订单原始金额
5. 使用优惠券计算优惠金额
6. 计算最终支付金额
7. 保存订单
8. 扣减库存
业务代码:
java
public class OrderService {
private final InventoryService inventoryService;
private final CouponService couponService;
private final OrderRepository orderRepository;
public OrderService(
InventoryService inventoryService,
CouponService couponService,
OrderRepository orderRepository
) {
this.inventoryService = inventoryService;
this.couponService = couponService;
this.orderRepository = orderRepository;
}
public Order createOrder(
Long userId,
Long productId,
int quantity,
BigDecimal unitPrice,
String couponCode
) {
if (quantity <= 0) {
throw new IllegalArgumentException(
"购买数量必须大于0"
);
}
boolean hasStock =
inventoryService.hasStock(
productId,
quantity
);
if (!hasStock) {
throw new BusinessException(
"库存不足"
);
}
BigDecimal originalAmount =
unitPrice.multiply(
BigDecimal.valueOf(quantity)
);
BigDecimal discount =
BigDecimal.ZERO;
if (couponCode != null) {
discount =
couponService.calculateDiscount(
couponCode,
originalAmount
);
}
BigDecimal payAmount =
originalAmount.subtract(discount);
Order order = new Order(
userId,
productId,
quantity,
payAmount
);
orderRepository.save(order);
inventoryService.deduct(
productId,
quantity
);
return order;
}
}
现在出现了一个问题:
测试 OrderService 时,需要:
text
InventoryService
CouponService
OrderRepository
难道测试的时候真的连接:
text
库存系统?
数据库?
优惠券系统?
如果这样做:
text
测试速度慢
环境依赖严重
测试数据难维护
测试结果不稳定
因此就需要:
Mock。
十九、JUnit 和 Mockito 有什么区别?
这是 Java 面试中的经典问题。
JUnit
主要负责:
text
组织测试
发现测试
执行测试
生命周期管理
断言结果
扩展机制
例如:
java
@Test
@BeforeEach
assertEquals()
assertThrows()
Mockito
主要负责:
text
创建 Mock 对象
模拟依赖行为
验证方法调用
隔离外部依赖
例如:
java
when(...).thenReturn(...)
java
verify(...)
因此:
text
JUnit
负责执行测试
Mockito
负责模拟依赖
两者经常一起使用:
text
JUnit + Mockito
Mockito 提供 JUnit Jupiter 扩展,可以初始化 Mock 并与 Jupiter 扩展机制配合使用。
二十、使用 Mockito 测试真实业务
测试类:
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private InventoryService inventoryService;
@Mock
private CouponService couponService;
@Mock
private OrderRepository orderRepository;
@InjectMocks
private OrderService orderService;
}
@Mock
创建模拟对象:
java
@Mock
private InventoryService inventoryService;
这不是真正的库存服务。
它只是:
text
假的 InventoryService
我们可以人为控制它的返回结果。
@InjectMocks
java
@InjectMocks
private OrderService orderService;
Mockito 会尝试将:
text
inventoryService
couponService
orderRepository
注入:
text
orderService
这样就可以:
text
只测试 OrderService
而不会真的访问:
text
数据库
Redis
第三方接口
消息队列
二十一、测试场景一:正常创建订单
业务条件:
text
商品单价:100元
购买数量:2
订单原价:200元
优惠金额:20元
最终支付:180元
库存充足
测试:
java
@Test
void shouldCreateOrderSuccessfully() {
// Given
Long userId = 1L;
Long productId = 1001L;
int quantity = 2;
BigDecimal unitPrice =
new BigDecimal("100.00");
when(
inventoryService.hasStock(
productId,
quantity
)
).thenReturn(true);
when(
couponService.calculateDiscount(
eq("VIP20"),
any(BigDecimal.class)
)
).thenReturn(
new BigDecimal("20.00")
);
// When
Order order =
orderService.createOrder(
userId,
productId,
quantity,
unitPrice,
"VIP20"
);
// Then
assertNotNull(order);
assertEquals(
0,
order.getPayAmount()
.compareTo(
new BigDecimal("180.00")
)
);
verify(orderRepository)
.save(any(Order.class));
verify(inventoryService)
.deduct(
productId,
quantity
);
}
这个测试实际上验证了三个层面。
第一层:业务结果
java
assertEquals(
0,
order.getPayAmount()
.compareTo(
new BigDecimal("180.00")
)
);
验证:
text
最终金额是否正确
第二层:订单是否保存
java
verify(orderRepository)
.save(any(Order.class));
验证:
text
OrderRepository.save()
是否真的被调用
第三层:库存是否扣减
java
verify(inventoryService)
.deduct(productId, quantity);
验证:
text
库存扣减操作是否发生
因此测试不应该只关注:
text
返回值
还要根据业务关注:
text
状态变化
数据库写入
外部依赖调用
消息发送
二十二、测试场景二:库存不足
业务规则:
text
库存不足
↓
禁止创建订单
↓
不能保存订单
↓
不能扣减库存
测试:
java
@Test
void shouldThrowExceptionWhenStockIsInsufficient() {
// Given
Long productId = 1001L;
when(
inventoryService.hasStock(
productId,
10
)
).thenReturn(false);
// When
BusinessException exception =
assertThrows(
BusinessException.class,
() -> orderService.createOrder(
1L,
productId,
10,
new BigDecimal("100"),
null
)
);
// Then
assertEquals(
"库存不足",
exception.getMessage()
);
verify(
orderRepository,
never()
).save(any());
verify(
inventoryService,
never()
).deduct(
anyLong(),
anyInt()
);
}
这里有一个非常重要的测试思想:
不仅要验证"应该发生什么",还要验证"不应该发生什么"。
库存不足时:
text
应该:
抛出异常
不应该:
保存订单
扣减库存
这类测试在支付、订单、库存等业务中非常重要。
二十三、测试场景三:非法购买数量
业务规则:
text
quantity <= 0
应该:
text
直接拒绝请求
参数化测试:
java
@ParameterizedTest
@ValueSource(ints = {
0,
-1,
-10
})
void shouldRejectInvalidQuantity(
int quantity
) {
assertThrows(
IllegalArgumentException.class,
() -> orderService.createOrder(
1L,
1001L,
quantity,
new BigDecimal("100"),
null
)
);
verifyNoInteractions(
inventoryService,
couponService,
orderRepository
);
}
为什么要验证:
java
verifyNoInteractions(...)
因为参数本身已经非法:
text
程序应该立即失败
不应该继续:
text
查询库存
查询优惠券
访问数据库
二十四、什么应该 Mock?
这是非常重要的工程问题。
一般来说,应该 Mock:
text
数据库 Repository
第三方 HTTP API
Redis
消息队列
支付服务
短信服务
邮件服务
文件系统
外部微服务
耗时服务
不稳定服务
例如测试:
text
OrderService
可以 Mock:
text
OrderRepository
InventoryClient
CouponService
PaymentClient
二十五、什么不应该随便 Mock?
通常不建议 Mock:
text
简单实体类
DTO
VO
普通 Java 对象
简单工具类
当前真正需要测试的对象
例如:
java
Order order = mock(Order.class);
很多时候没有必要。
直接:
java
Order order = new Order();
即可。
一个重要原则:
Mock 系统边界,而不是 Mock 一切。
Mock 太多会导致:
text
测试和实现细节严重绑定
代码稍微重构
测试就大量失败
二十六、单元测试和集成测试有什么区别?
单元测试 Unit Test
测试:
text
一个方法
一个类
一个业务组件
例如:
text
OrderServiceTest
通过 Mock:
text
数据库
Redis
第三方接口
特点:
text
速度快
隔离性强
定位问题容易
集成测试 Integration Test
测试多个组件是否能够正确协作。
例如:
text
OrderService
↓
OrderRepository
↓
MySQL
测试:
text
Java代码
数据库连接
SQL
ORM映射
事务
例如:
java
@SpringBootTest
class OrderServiceIntegrationTest {
}
两者区别
| 对比 | 单元测试 | 集成测试 |
|---|---|---|
| 测试范围 | 单个类或组件 | 多个组件 |
| 数据库 | 通常 Mock | 可能真实连接 |
| Spring 容器 | 通常不启动 | 可能启动 |
| 运行速度 | 快 | 较慢 |
| 问题定位 | 容易 | 相对复杂 |
| 目的 | 验证内部业务逻辑 | 验证组件协作 |
实际项目中:
text
不是二选一
而是:
text
单元测试 + 集成测试
共同存在。
二十七、@SpringBootTest 和普通 JUnit 测试有什么区别?
例如:
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
}
主要是:
text
JUnit + Mockito
特点:
text
不启动完整 Spring 容器
执行速度快
适合 Service 业务逻辑测试
而:
java
@SpringBootTest
class OrderServiceTest {
}
通常会:
text
启动 Spring ApplicationContext
加载 Bean
进行依赖注入
适合:
text
集成测试
面试回答可以概括为:
@ExtendWith(MockitoExtension.class)更适合隔离性的单元测试,而@SpringBootTest更适合验证多个 Spring Bean 协同工作的集成测试。实际项目中不应该所有测试都使用@SpringBootTest,否则测试执行速度和维护成本都会明显增加。
二十八、测试边界值
很多 Bug 并不是出现在正常数据中,而是出现在:
text
边界位置
例如:
java
if (age >= 18) {
}
应该重点测试:
text
17
18
19
而不是:
text
20
30
40
例如库存:
text
stock = 10
重点测试:
text
quantity = 0
quantity = 1
quantity = 9
quantity = 10
quantity = 11
这种方法称为:
边界值分析。
二十九、测试正常路径和异常路径
一个完整的业务功能通常至少包含:
text
Happy Path
Error Path
Boundary Case
例如订单创建。
Happy Path
text
库存充足
参数正确
优惠券有效
创建成功
Error Path
text
库存不足
用户不存在
优惠券失效
数据库异常
Boundary Case
text
购买数量 = 0
购买数量 = 1
购买数量 = 最大库存
金额 = 优惠门槛
优秀的测试不会只测试:
text
正常情况
而是关注:
text
正常
异常
边界
三十、测试之间必须相互独立
假设:
java
@Test
void testA() {
user.setBalance(100);
}
然后:
java
@Test
void testB() {
assertEquals(
100,
user.getBalance()
);
}
这意味着:
text
testB依赖testA
如果单独运行:
text
testB
可能失败。
这是一种非常危险的测试设计。
正确原则:
text
每个测试
都应该可以独立执行
测试不应该依赖:
text
其他测试执行顺序
共享可变状态
上一个测试产生的数据
三十一、为什么测试单独运行成功,一起运行失败?
这是非常经典的面试问题。
可能原因:
text
共享 static 变量
共享数据库数据
测试没有清理资源
测试依赖执行顺序
使用全局缓存
随机数不稳定
时间相关逻辑
并发问题
例如:
java
private static int count = 0;
不同测试共同修改:
text
count
就可能产生:
text
测试污染
专业术语:
Test Pollution
因此测试设计应该尽可能:
text
独立
可重复
确定性
三十二、什么是一个好的单元测试?
可以从几个方面判断。
Fast
运行速度快。
Independent
测试之间相互独立。
Repeatable
无论执行多少次:
text
结果一致
不能:
text
今天成功
明天失败
Self-Validating
测试应该自动判断:
text
成功
失败
而不是人工查看:
java
System.out.println(result);
Timely
测试应该及时编写。
这些原则经常被总结为:
text
FIRST
三十三、为什么 System.out.println 不是测试?
错误方式:
java
@Test
void testCreateOrder() {
Order order =
orderService.createOrder();
System.out.println(order);
}
问题:
text
JUnit不知道结果对不对
最终还需要开发人员:
text
人工查看
正确:
java
assertEquals(
OrderStatus.CREATED,
order.getStatus()
);
测试必须:
自动判断结果。
三十四、代码覆盖率越高越好吗?
代码覆盖率可以反映:
text
哪些代码被测试执行过
例如:
text
Line Coverage = 80%
表示:
text
大约80%的代码行
被测试执行过
但是:
高覆盖率不等于高质量测试。
例如:
java
@Test
void test() {
orderService.createOrder();
}
可能执行了大量代码。
覆盖率:
text
很高
但是没有任何:
text
assert
这种测试几乎没有实际价值。
因此:
text
覆盖率
≠
测试质量
真正重要的是:
text
是否验证关键业务规则
是否覆盖异常路径
是否覆盖边界条件
是否能够发现真实Bug
三十五、实际开发中应该重点测试什么?
并不是所有代码都值得投入完全相同的测试成本。
应该优先测试:
text
核心业务逻辑
复杂条件判断
金额计算
优惠规则
权限判断
状态机
数据转换
异常处理
边界逻辑
容易出错的算法
例如:
text
支付金额计算
订单状态流转
库存扣减
会员权益
优惠券规则
权限控制
这些代码的 Bug 可能直接导致:
text
资金损失
数据错误
业务事故
因此测试价值非常高。
三十六、哪些代码测试价值相对较低?
例如:
java
public String getName() {
return name;
}
简单 Getter:
text
逻辑非常简单
通常没有必要为了追求:
text
100%覆盖率
机械地编写大量没有价值的测试。
测试应该考虑:
text
风险
复杂度
业务价值
而不是单纯:
text
追求数字
三十七、Maven 是如何执行 JUnit 测试的?
在 Maven 项目中执行:
bash
mvn test
Maven 会进入:
text
test 生命周期阶段
通常由 Maven Surefire Plugin 执行单元测试。
大致过程:
text
读取 pom.xml
↓
编译 main 代码
↓
编译 test 代码
↓
Surefire 发现测试
↓
JUnit Platform
↓
JUnit Jupiter TestEngine
↓
执行 @Test
↓
执行 Assertions
↓
生成测试结果
Maven 官方生命周期中:
text
validate
↓
compile
↓
test
↓
package
↓
verify
↓
install
↓
deploy
因此执行:
bash
mvn package
并不是直接打包。
默认会先经过:
text
compile
test
package
因此如果:
text
JUnit测试失败
通常:
text
package也会失败
这就是为什么企业项目中自动化测试可以成为:
text
构建流程的一道质量门禁
JUnit Platform 可以由 Maven Surefire/Failsafe 执行,而 Maven 的默认生命周期也明确将 test 放在 package 之前。
三十八、测试在 CI/CD 中的作用
真实开发流程可能是:
text
开发人员提交代码
↓
Git Push
↓
GitHub Actions / Jenkins
↓
Maven Build
↓
mvn test
↓
测试成功
↓
继续构建和部署
如果测试失败:
text
停止部署
例如:
text
订单金额测试失败
库存测试失败
用户权限测试失败
系统就不会继续发布。
因此自动化测试实际上是:
软件交付流程中的自动质量检查机制。
三十九、JUnit 4 和 JUnit Jupiter 的区别
这是面试中的经典问题。
JUnit 4
java
import org.junit.Test;
JUnit Jupiter
java
import org.junit.jupiter.api.Test;
常见区别:
| JUnit 4 | JUnit Jupiter |
|---|---|
@Before |
@BeforeEach |
@After |
@AfterEach |
@BeforeClass |
@BeforeAll |
@AfterClass |
@AfterAll |
@Ignore |
@Disabled |
@RunWith |
@ExtendWith |
| 传统架构 | Platform + TestEngine 架构 |
例如 Mockito:
现代 Jupiter 常见写法:
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
}
旧项目中可能看到:
java
@RunWith(MockitoJUnitRunner.class)
public class OrderServiceTest {
}
JUnit 官方目前仍提供 Vintage 作为旧测试迁移路径,但 Vintage 已被标记为应仅用于迁移阶段,新代码应优先使用原生 JUnit Platform/Jupiter 测试模型。
四十、面试题:什么是单元测试?
推荐回答:
单元测试是针对程序中最小可测试单元进行验证的自动化测试。在 Java 项目中,这个单元通常是一个类或者一个方法。单元测试通常会隔离数据库、Redis、第三方接口等外部依赖,只关注当前业务单元的行为是否正确。JUnit 负责测试的组织、执行和断言,Mockito 等 Mock 框架可以用来模拟外部依赖。
四十一、面试题:JUnit 的作用是什么?
推荐回答:
JUnit 是 Java 生态中的自动化测试框架,主要负责测试用例的定义、测试生命周期管理、测试发现和执行以及结果断言。现代 JUnit 基于 JUnit Platform,可以通过不同 TestEngine 执行测试,其中 Java 项目最常使用的是 JUnit Jupiter。
四十二、面试题:@BeforeEach 和 @BeforeAll 的区别?
推荐回答:
@BeforeEach会在每个测试方法执行前运行一次,适合为每个测试创建独立测试环境;@BeforeAll在整个测试类执行前只运行一次,适合初始化创建成本较高的共享资源。为了保证测试隔离性,普通业务单元测试通常更常使用@BeforeEach。
四十三、面试题:JUnit 和 Mockito 有什么区别?
推荐回答:
JUnit 是测试框架,负责测试执行、生命周期管理和断言;Mockito 是 Mock 框架,负责模拟依赖对象的行为并验证对象之间的交互。实际项目中经常使用 JUnit 执行测试,使用 Mockito 隔离数据库、远程接口等依赖。
可以总结:
text
JUnit
解决:
怎么执行测试
Mockito
解决:
依赖对象怎么办
四十四、面试题:为什么要使用 Mock?
推荐回答:
单元测试应该尽可能只验证当前被测试对象。如果 Service 依赖数据库、Redis、第三方接口或者其他微服务,直接调用真实依赖会导致测试速度慢、环境依赖严重,并且容易受到外部系统影响。使用 Mock 可以控制依赖对象的行为,使测试更加快速、稳定和可重复。
四十五、面试题:如何测试 void 方法?
很多人认为:
java
void方法没有返回值
无法测试
实际上可以验证:
text
状态变化
外部交互
副作用
例如:
java
public void cancelOrder(Long id) {
repository.updateStatus(
id,
OrderStatus.CANCELLED
);
}
测试:
java
@Test
void shouldCancelOrder() {
orderService.cancelOrder(1001L);
verify(repository)
.updateStatus(
1001L,
OrderStatus.CANCELLED
);
}
因此:
没有返回值,不代表没有可验证的行为。
四十六、面试题:Private 方法应该怎么测试?
通常不建议直接测试:
text
private方法
例如:
java
private BigDecimal calculateDiscount() {
}
应该通过:
text
public业务方法
间接验证。
例如:
text
createOrder()
内部调用:
text
calculateDiscount()
测试最终订单金额即可。
因为测试应该关注:
text
外部行为
而不是过度依赖:
text
内部实现细节
如果一个 private 方法:
text
非常复杂
非常重要
非常难测试
往往意味着:
text
这个方法可能应该被拆分成独立组件
四十七、面试题:为什么不应该过度 Mock?
如果测试:
text
Mock A
Mock B
Mock C
Mock D
Mock E
Mock F
然后大量:
java
when(...)
verify(...)
测试可能变成:
text
验证代码内部调用过程
而不是:
text
验证业务行为
这样一旦重构内部代码:
text
业务行为没有变化
测试却可能大量失败。
因此一个优秀的测试应该:
text
关注可观察的业务行为
减少对内部实现细节的依赖
四十八、面试题:测试覆盖率达到 100% 是否代表没有 Bug?
答案:
text
不能
原因:
text
代码执行过
≠
业务逻辑正确
例如:
java
@Test
void test() {
orderService.createOrder();
}
可能产生覆盖率。
但没有验证:
text
金额
库存
订单状态
数据库写入
仍然可能存在大量 Bug。
因此覆盖率只是:
text
测试完整度的参考指标之一
而不是:
text
软件质量的绝对保证
四十九、面试题:测试失败一定说明业务代码有 Bug 吗?
不一定。
可能是:
text
业务代码有Bug
也可能是:
text
测试代码本身错误
测试数据错误
Mock配置错误
环境异常
共享状态污染
并发问题
时间依赖问题
因此测试失败后的正确处理过程应该是:
text
分析失败原因
↓
确认预期业务行为
↓
检查生产代码
↓
检查测试代码
而不是:
text
为了让测试通过
直接删除测试
五十、实际项目中的测试策略
一个相对成熟的项目通常会形成:
text
大量单元测试
+
适量集成测试
+
少量端到端测试
可以理解为:
text
/\
/ \
/ E2E\
/------\
/集成测试\
/----------\
/ 单元测试 \
/--------------\
越往下:
text
数量更多
速度更快
成本更低
越往上:
text
更接近真实系统
执行更慢
维护成本更高
因此:
text
不能全部写成集成测试
也不能完全没有集成测试
五十一、实际开发中的推荐测试流程
假设开发:
text
创建订单功能
可以按照下面的顺序设计测试。
第一步:列出业务规则
text
购买数量必须大于0
库存必须充足
优惠券必须有效
金额必须正确
订单必须保存
库存必须扣减
第二步:设计测试场景
text
正常创建订单
库存不足
数量为0
数量为负数
无优惠券
有效优惠券
优惠金额异常
第三步:确定测试边界
text
哪些是真实对象?
哪些需要Mock?
例如:
text
OrderService
真实对象
OrderRepository
Mock
InventoryService
Mock
CouponService
Mock
第四步:编写 Given-When-Then
text
Given
准备业务条件
When
执行目标行为
Then
验证业务结果
第五步:验证两类结果
不仅验证:
text
返回值
还要验证:
text
副作用
例如:
text
订单是否保存
库存是否扣减
消息是否发送
五十二、总结
JUnit 的核心并不是记住:
text
@Test
@BeforeEach
assertEquals
真正重要的是理解:
如何通过自动化测试验证业务行为。
在实际开发中,一个完整的测试过程通常是:
text
理解业务规则
↓
确定测试边界
↓
准备测试数据
↓
隔离外部依赖
↓
执行目标代码
↓
验证返回结果
↓
验证状态变化
↓
验证外部交互
对于 Java 开发者来说,需要重点掌握:
text
JUnit Jupiter
Assertions
生命周期
参数化测试
异常测试
Mockito
单元测试
集成测试
测试隔离
边界值测试
CI/CD自动测试
最终需要形成一种开发思维:
写代码不仅要考虑"正常情况下能不能运行",还要考虑"异常情况下是否正确失败、边界情况下是否正确处理,以及未来代码修改后如何快速证明原有功能没有被破坏"。
这才是测试在真实软件开发中的真正价值。
面试速记版
text
1. JUnit是什么?
Java自动化测试框架。
2. @Test作用?
标记测试方法。
3. @BeforeEach?
每个测试前执行。
4. @BeforeAll?
整个测试类执行前运行一次。
5. assertEquals?
验证预期值和实际值是否相同。
6. assertThrows?
验证代码是否抛出指定异常。
7. JUnit和Mockito区别?
JUnit负责执行测试和断言,
Mockito负责Mock依赖。
8. 什么是单元测试?
隔离外部依赖,验证单个业务单元。
9. 什么是集成测试?
验证多个组件协同工作。
10. 为什么使用Mock?
提高测试速度、稳定性和隔离性。
11. 如何测试void方法?
验证状态变化或方法交互。
12. private方法怎么测试?
通常通过public方法间接验证。
13. 覆盖率100%是否代表没有Bug?
不代表。
14. 好的测试有什么特点?
快速、独立、可重复、自动验证。
15. 测试重点是什么?
正常路径、异常路径、边界条件。