单元测试中 when 与 given 的深度解析
1. when 与 given 的作用和区别
基本概念
作用对比表
| 特性 | when().thenReturn() | given().willReturn() |
|---|
| 来源 | Mockito传统语法 | BDD(行为驱动开发)风格 |
| 语法风格 | 命令式 | 声明式 |
| 可读性 | 一般 | 更好,更接近自然语言 |
| 使用场景 | 传统单元测试 | BDD风格测试 |
| 功能 | 完全相同 | 完全相同 |
2. 详细用法和区别
2.1 when() 的用法
class WhenUsageTest {
@Test
@DisplayName("when() 方法的多种用法")
void whenVariousUsages() {
List<String> mockList = mock(List.class);
when(mockList.get(0)).thenReturn("first");
when(mockList.get(1)).thenReturn("second");
when(mockList.get(999)).thenThrow(new IndexOutOfBoundsException("索引越界"));
when(mockList.size())
.thenReturn(1)
.thenReturn(2)
.thenReturn(3);
when(mockList.get(anyInt())).thenReturn("any_element");
when(mockList.contains(anyString())).thenReturn(true);
assertEquals("first", mockList.get(0));
assertEquals("second", mockList.get(1));
assertEquals("any_element", mockList.get(100));
assertEquals(1, mockList.size());
assertEquals(2, mockList.size());
assertEquals(3, mockList.size());
assertThrows(IndexOutOfBoundsException.class, () -> mockList.get(999));
}
@Test
@DisplayName("when() 用于void方法")
void whenForVoidMethods() {
List<String> mockList = mock(List.class);
doThrow(new RuntimeException("清空失败")).when(mockList).clear();
assertThrows(RuntimeException.class, () -> mockList.clear());
}
}
2.2 given() 的用法
class GivenUsageTest {
@Test
@DisplayName("given() 方法的多种用法")
void givenVariousUsages() {
List<String> mockList = mock(List.class);
given(mockList.get(0)).willReturn("first");
given(mockList.get(1)).willReturn("second");
given(mockList.get(999)).willThrow(new IndexOutOfBoundsException("索引越界"));
given(mockList.size())
.willReturn(1)
.willReturn(2)
.willReturn(3);
given(mockList.get(anyInt())).willReturn("any_element");
given(mockList.contains(anyString())).willReturn(true);
assertThat(mockList.get(0)).isEqualTo("first");
assertThat(mockList.get(1)).isEqualTo("second");
assertThat(mockList.get(100)).isEqualTo("any_element");
assertThat(mockList.size()).isEqualTo(1);
assertThat(mockList.size()).isEqualTo(2);
assertThat(mockList.size()).isEqualTo(3);
assertThatThrownBy(() -> mockList.get(999))
.isInstanceOf(IndexOutOfBoundsException.class);
}
@Test
@DisplayName("given() 用于void方法")
void givenForVoidMethods() {
List<String> mockList = mock(List.class);
willThrow(new RuntimeException("清空失败")).given(mockList).clear();
assertThatThrownBy(() -> mockList.clear())
.isInstanceOf(RuntimeException.class)
.hasMessage("清空失败");
}
}
3. 推荐使用哪个?
推荐使用 given().willReturn() 的原因:
class RecommendationExample {
@Test
@DisplayName("BDD风格测试示例 - 推荐")
void bddStyleRecommendedExample() {
UserRepository userRepository = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
UserService userService = new UserService(userRepository, emailService);
User testUser = new User(1L, "john_doe", "john@example.com");
given(userRepository.findById(1L)).willReturn(Optional.of(testUser));
given(emailService.sendWelcomeEmail(anyString())).willReturn(true);
boolean result = userService.sendWelcomeEmail(1L);
assertThat(result).isTrue();
then(emailService).should().sendWelcomeEmail("john@example.com");
}
@Test
@DisplayName("传统风格测试示例 - 不推荐")
void traditionalStyleNotRecommended() {
UserRepository userRepository = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
UserService userService = new UserService(userRepository, emailService);
User testUser = new User(1L, "john_doe", "john@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
when(emailService.sendWelcomeEmail(anyString())).thenReturn(true);
boolean result = userService.sendWelcomeEmail(1L);
assertTrue(result);
verify(emailService).sendWelcomeEmail("john@example.com");
}
}
4. 综合性单元测试示例
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.BDDMockito.*;
@ExtendWith(MockitoExtension.class)
class BankAccountServiceTest {
@Mock
private AccountRepository accountRepository;
@Mock
private TransactionRepository transactionRepository;
@Mock
private AuditService auditService;
@Mock
private NotificationService notificationService;
@InjectMocks
private BankAccountService bankAccountService;
@Captor
private ArgumentCaptor<Account> accountCaptor;
@Captor
private ArgumentCaptor<Transaction> transactionCaptor;
private Account testAccount;
private Account targetAccount;
@BeforeEach
void setUp() {
testAccount = Account.builder()
.id(1L)
.accountNumber("1234567890")
.accountHolder("张三")
.balance(new BigDecimal("1000.00"))
.status(AccountStatus.ACTIVE)
.createdAt(LocalDateTime.now())
.build();
targetAccount = Account.builder()
.id(2L)
.accountNumber("0987654321")
.accountHolder("李四")
.balance(new BigDecimal("500.00"))
.status(AccountStatus.ACTIVE)
.createdAt(LocalDateTime.now())
.build();
}
@Test
@DisplayName("存款 - 有效金额时应成功并记录交易")
void deposit_WithValidAmount_ShouldSucceedAndRecordTransaction() {
BigDecimal depositAmount = new BigDecimal("500.00");
BigDecimal expectedBalance = new BigDecimal("1500.00");
given(accountRepository.findByAccountNumber("1234567890"))
.willReturn(Optional.of(testAccount));
given(accountRepository.save(any(Account.class)))
.willAnswer(invocation -> invocation.getArgument(0));
given(transactionRepository.save(any(Transaction.class)))
.willAnswer(invocation -> invocation.getArgument(0));
willDoNothing().given(auditService).logTransaction(anyString(), anyString(), any(BigDecimal.class));
TransactionResult result = bankAccountService.deposit("1234567890", depositAmount);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getMessage()).contains("存款成功");
assertThat(result.getNewBalance()).isEqualByComparingTo(expectedBalance);
then(accountRepository).should().save(accountCaptor.capture());
Account savedAccount = accountCaptor.getValue();
assertThat(savedAccount.getBalance()).isEqualByComparingTo(expectedBalance);
then(transactionRepository).should().save(transactionCaptor.capture());
Transaction savedTransaction = transactionCaptor.getValue();
assertThat(savedTransaction.getType()).isEqualTo(TransactionType.DEPOSIT);
assertThat(savedTransaction.getAmount()).isEqualByComparingTo(depositAmount);
then(auditService).should().logTransaction(eq("1234567890"), eq("DEPOSIT"), eq(depositAmount));
}
@Test
@DisplayName("取款 - 余额充足时应成功")
void withdraw_WithSufficientBalance_ShouldSucceed() {
BigDecimal withdrawAmount = new BigDecimal("200.00");
BigDecimal expectedBalance = new BigDecimal("800.00");
when(accountRepository.findByAccountNumber("1234567890"))
.thenReturn(Optional.of(testAccount));
when(accountRepository.save(any(Account.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(transactionRepository.save(any(Transaction.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
doNothing().when(auditService).logTransaction(anyString(), anyString(), any(BigDecimal.class));
TransactionResult result = bankAccountService.withdraw("1234567890", withdrawAmount);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getNewBalance()).isEqualByComparingTo(expectedBalance);
verify(accountRepository).save(accountCaptor.capture());
assertThat(accountCaptor.getValue().getBalance()).isEqualByComparingTo(expectedBalance);
}
@Test
@DisplayName("取款 - 余额不足时应失败")
void withdraw_WithInsufficientBalance_ShouldFail() {
BigDecimal withdrawAmount = new BigDecimal("1500.00");
given(accountRepository.findByAccountNumber("1234567890"))
.willReturn(Optional.of(testAccount));
assertThatThrownBy(() -> bankAccountService.withdraw("1234567890", withdrawAmount))
.isInstanceOf(InsufficientBalanceException.class)
.hasMessage("余额不足");
then(accountRepository).should(never()).save(any(Account.class));
then(transactionRepository).should(never()).save(any(Transaction.class));
}
@Test
@DisplayName("转账 - 正常情况应成功更新两个账户")
void transfer_WithValidData_ShouldUpdateBothAccounts() {
BigDecimal transferAmount = new BigDecimal("300.00");
BigDecimal sourceExpectedBalance = new BigDecimal("700.00");
BigDecimal targetExpectedBalance = new BigDecimal("800.00");
given(accountRepository.findByAccountNumber("1234567890"))
.willReturn(Optional.of(testAccount));
given(accountRepository.findByAccountNumber("0987654321"))
.willReturn(Optional.of(targetAccount));
given(accountRepository.save(any(Account.class)))
.willAnswer(invocation -> invocation.getArgument(0));
given(transactionRepository.save(any(Transaction.class)))
.willAnswer(invocation -> {
Transaction transaction = invocation.getArgument(0);
transaction.setId(100L);
return transaction;
});
willDoNothing().given(notificationService).sendTransferNotification(anyString(), anyString(), any(BigDecimal.class));
TransferResult result = bankAccountService.transfer(
"1234567890",
"0987654321",
transferAmount,
"房租"
);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getTransactionId()).isNotNull();
then(accountRepository).should(times(2)).save(accountCaptor.capture());
List<Account> savedAccounts = accountCaptor.getAllValues();
assertThat(savedAccounts).hasSize(2);
assertThat(savedAccounts.get(0).getBalance()).isEqualByComparingTo(sourceExpectedBalance);
assertThat(savedAccounts.get(1).getBalance()).isEqualByComparingTo(targetExpectedBalance);
then(notificationService).should().sendTransferNotification(
eq("1234567890"),
eq("0987654321"),
eq(transferAmount)
);
}
@ParameterizedTest
@DisplayName("存款金额边界测试")
@CsvSource({
"0.01, 1000.01",
"100.00, 1100.00",
"999999.99, 1000999.99"
})
void deposit_WithVariousAmounts_ShouldCalculateCorrectBalance(
String amount, String expectedBalance) {
BigDecimal depositAmount = new BigDecimal(amount);
given(accountRepository.findByAccountNumber("1234567890"))
.willReturn(Optional.of(testAccount));
given(accountRepository.save(any(Account.class)))
.willAnswer(invocation -> invocation.getArgument(0));
TransactionResult result = bankAccountService.deposit("1234567890", depositAmount);
assertThat(result.getNewBalance()).isEqualByComparingTo(new BigDecimal(expectedBalance));
}
@Test
@DisplayName("操作不存在的账户时应抛出异常")
void operateOnNonExistentAccount_ShouldThrowException() {
given(accountRepository.findByAccountNumber("9999999999"))
.willReturn(Optional.empty());
assertThatThrownBy(() -> bankAccountService.deposit("9999999999", new BigDecimal("100.00")))
.isInstanceOf(AccountNotFoundException.class)
.hasMessage("账户不存在: 9999999999");
}
@Test
@DisplayName("连续操作账户 - 应正确处理状态变化")
void consecutiveOperations_ShouldHandleStateCorrectly() {
given(accountRepository.findByAccountNumber("1234567890"))
.willReturn(Optional.of(testAccount));
given(accountRepository.save(any(Account.class)))
.willAnswer(invocation -> invocation.getArgument(0));
Account afterFirstDeposit = Account.builder()
.id(1L)
.accountNumber("1234567890")
.balance(new BigDecimal("1500.00"))
.build();
Account afterWithdrawal = Account.builder()
.id(1L)
.accountNumber("1234567890")
.balance(new BigDecimal("1300.00"))
.build();
given(accountRepository.save(any(Account.class)))
.willReturn(afterFirstDeposit, afterWithdrawal);
TransactionResult depositResult = bankAccountService.deposit("1234567890", new BigDecimal("500.00"));
TransactionResult withdrawResult = bankAccountService.withdraw("1234567890", new BigDecimal("200.00"));
assertThat(depositResult.getNewBalance()).isEqualByComparingTo("1500.00");
assertThat(withdrawResult.getNewBalance()).isEqualByComparingTo("1300.00");
then(accountRepository).should(times(2)).save(any(Account.class));
}
@Test
@DisplayName("审计日志 - 测试void方法的不同Mock方式")
void auditLogging_TestVoidMethodMocking() {
willDoNothing().given(auditService).logTransaction(anyString(), anyString(), any(BigDecimal.class));
doNothing().when(notificationService).sendTransferNotification(anyString(), anyString(), any(BigDecimal.class));
auditService.logTransaction("123", "TEST", new BigDecimal("100"));
notificationService.sendTransferNotification("123", "456", new BigDecimal("100"));
then(auditService).should().logTransaction("123", "TEST", new BigDecimal("100"));
verify(notificationService).sendTransferNotification("123", "456", new BigDecimal("100"));
}
}
class Account {
private Long id;
private String accountNumber;
private String accountHolder;
private BigDecimal balance;
private AccountStatus status;
private LocalDateTime createdAt;
public static AccountBuilder builder() { return new AccountBuilder(); }
static class AccountBuilder {
private Account account = new Account();
public AccountBuilder id(Long id) { account.id = id; return this; }
public AccountBuilder accountNumber(String accountNumber) { account.accountNumber = accountNumber; return this; }
public AccountBuilder accountHolder(String accountHolder) { account.accountHolder = accountHolder; return this; }
public AccountBuilder balance(BigDecimal balance) { account.balance = balance; return this; }
public AccountBuilder status(AccountStatus status) { account.status = status; return this; }
public AccountBuilder createdAt(LocalDateTime createdAt) { account.createdAt = createdAt; return this; }
public Account build() { return account; }
}
public Long getId() { return id; }
public String getAccountNumber() { return accountNumber; }
public String getAccountHolder() { return accountHolder; }
public BigDecimal getBalance() { return balance; }
public AccountStatus getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
enum AccountStatus {
ACTIVE, INACTIVE, FROZEN, CLOSED
}
enum TransactionType {
DEPOSIT, WITHDRAWAL, TRANSFER
}
class Transaction {
private Long id;
private String fromAccount;
private String toAccount;
private BigDecimal amount;
private TransactionType type;
private String description;
private LocalDateTime timestamp;
}
class TransactionResult {
private boolean success;
private String message;
private BigDecimal newBalance;
public TransactionResult(boolean success, String message, BigDecimal newBalance) {
this.success = success;
this.message = message;
this.newBalance = newBalance;
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public BigDecimal getNewBalance() { return newBalance; }
}
class TransferResult {
private boolean success;
private String message;
private Long transactionId;
public TransferResult(boolean success, String message, Long transactionId) {
this.success = success;
this.message = message;
this.transactionId = transactionId;
}
public boolean isSuccess() { return success; }
public String getMessage() { return message; }
public Long getTransactionId() { return transactionId; }
}
class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(String message) {
super(message);
}
}
class AccountNotFoundException extends RuntimeException {
public AccountNotFoundException(String message) {
super(message);
}
}
关键总结
1. when vs given 选择建议
| 场景 | 推荐用法 | 理由 |
|---|
| 新项目 | given().willReturn() | BDD风格,可读性更好 |
| 遗留项目 | 保持现有风格 | 一致性更重要 |
| 团队偏好 | 统一选择一种 | 保持代码风格一致 |
| 复杂测试 | given().willReturn() | 更清晰的测试结构 |
2. 最佳实践
@Test
void shouldDoSomethingWhenCondition() {
given(someRepository.findById(anyLong())).willReturn(someEntity);
SomeResult result = service.doSomething();
assertThat(result).isNotNull();
then(someRepository).should().findById(anyLong());
}
@Test
void testSomething() {
when(repo.find()).thenReturn(entity);
given(service.process()).willReturn(result);
}
3. 核心要点
- 功能相同:
when().thenReturn() 和 given().willReturn() 功能完全一样 - 风格差异:主要是语法风格和可读性差异
- 统一性:在项目中保持风格统一最重要
- BDD优势:
given-when-then 结构更符合测试的自然逻辑流程
这个综合性示例展示了在实际企业级开发中如何选择和使用 when/given,以及它们在复杂业务场景下的应用。