单元测试中 when 与 given 的深度解析

when与given在单元测试中的选择

单元测试中 when 与 given 的深度解析

1. when 与 given 的作用和区别

基本概念

// when 和 given 都是用于设置Mock对象行为的语法
// 它们来自不同的测试风格,但功能相似

作用对比表

特性when().thenReturn()given().willReturn()
来源Mockito传统语法BDD(行为驱动开发)风格
语法风格命令式声明式
可读性一般更好,更接近自然语言
使用场景传统单元测试BDD风格测试
功能完全相同完全相同

2. 详细用法和区别

2.1 when() 的用法

class WhenUsageTest {
    
    @Test
    @DisplayName("when() 方法的多种用法")
    void whenVariousUsages() {
        List<String> mockList = mock(List.class);
        
        // 1. 基本用法 - 设置返回值
        when(mockList.get(0)).thenReturn("first");
        when(mockList.get(1)).thenReturn("second");
        
        // 2. 设置异常抛出
        when(mockList.get(999)).thenThrow(new IndexOutOfBoundsException("索引越界"));
        
        // 3. 链式调用 - 多次调用返回不同值
        when(mockList.size())
            .thenReturn(1)
            .thenReturn(2)
            .thenReturn(3);
        
        // 4. 使用参数匹配器
        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);
        
        // 对于void方法,使用doReturn().when()格式
        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);
        
        // 1. 基本用法 - BDD风格
        given(mockList.get(0)).willReturn("first");
        given(mockList.get(1)).willReturn("second");
        
        // 2. 设置异常抛出
        given(mockList.get(999)).willThrow(new IndexOutOfBoundsException("索引越界"));
        
        // 3. 链式调用
        given(mockList.size())
            .willReturn(1)
            .willReturn(2)
            .willReturn(3);
        
        // 4. 使用参数匹配器
        given(mockList.get(anyInt())).willReturn("any_element");
        given(mockList.contains(anyString())).willReturn(true);
        
        // 验证 - BDD风格使用then()
        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);
        
        // BDD风格对于void方法使用willThrow()
        willThrow(new RuntimeException("清空失败")).given(mockList).clear();
        
        assertThatThrownBy(() -> mockList.clear())
            .isInstanceOf(RuntimeException.class)
            .hasMessage("清空失败");
    }
}

3. 推荐使用哪个?

推荐使用 given().willReturn() 的原因:

class RecommendationExample {
    
    /**
     * 推荐使用 BDD 风格 (given-when-then) 的原因:
     * 1. 更好的可读性,接近自然语言
     * 2. 与测试结构 (Given-When-Then) 一致
     * 3. 更清晰的测试意图表达
     */
    @Test
    @DisplayName("BDD风格测试示例 - 推荐")
    void bddStyleRecommendedExample() {
        // Given - 准备阶段(设置Mock行为)
        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);
        
        // When - 执行阶段
        boolean result = userService.sendWelcomeEmail(1L);
        
        // Then - 验证阶段
        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.*;

/**
 * 银行账户服务综合性单元测试示例
 * 展示 when 和 given 在实际项目中的使用
 */
@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();
    }

    /**
     * 存款操作测试 - 使用 BDD 风格 (推荐)
     */
    @Test
    @DisplayName("存款 - 有效金额时应成功并记录交易")
    void deposit_WithValidAmount_ShouldSucceedAndRecordTransaction() {
        // Given - 准备阶段:设置Mock对象的行为
        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));

        // When - 执行阶段:调用被测试的方法
        TransactionResult result = bankAccountService.deposit("1234567890", depositAmount);

        // Then - 验证阶段:验证结果和行为
        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));
    }

    /**
     * 取款操作测试 - 使用传统 when 风格 (对比)
     */
    @Test
    @DisplayName("取款 - 余额充足时应成功")
    void withdraw_WithSufficientBalance_ShouldSucceed() {
        // 使用 when 风格的传统写法
        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() {
        // Given - BDD风格
        BigDecimal withdrawAmount = new BigDecimal("1500.00"); // 超过余额
        
        given(accountRepository.findByAccountNumber("1234567890"))
                .willReturn(Optional.of(testAccount));

        // When & Then
        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() {
        // Given
        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); // 设置模拟ID
                    return transaction;
                });
        willDoNothing().given(notificationService).sendTransferNotification(anyString(), anyString(), any(BigDecimal.class));

        // When
        TransferResult result = bankAccountService.transfer(
                "1234567890", 
                "0987654321", 
                transferAmount, 
                "房租"
        );

        // Then
        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) {
        // Given
        BigDecimal depositAmount = new BigDecimal(amount);
        
        given(accountRepository.findByAccountNumber("1234567890"))
                .willReturn(Optional.of(testAccount));
        given(accountRepository.save(any(Account.class)))
                .willAnswer(invocation -> invocation.getArgument(0));

        // When
        TransactionResult result = bankAccountService.deposit("1234567890", depositAmount);

        // Then
        assertThat(result.getNewBalance()).isEqualByComparingTo(new BigDecimal(expectedBalance));
    }

    /**
     * 异常情况测试 - 账户不存在
     */
    @Test
    @DisplayName("操作不存在的账户时应抛出异常")
    void operateOnNonExistentAccount_ShouldThrowException() {
        // Given
        given(accountRepository.findByAccountNumber("9999999999"))
                .willReturn(Optional.empty());

        // When & Then
        assertThatThrownBy(() -> bankAccountService.deposit("9999999999", new BigDecimal("100.00")))
                .isInstanceOf(AccountNotFoundException.class)
                .hasMessage("账户不存在: 9999999999");
    }

    /**
     * 测试方法链式调用 - 连续操作
     */
    @Test
    @DisplayName("连续操作账户 - 应正确处理状态变化")
    void consecutiveOperations_ShouldHandleStateCorrectly() {
        // Given - 设置连续调用的不同返回值
        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);

        // When - 执行连续操作
        TransactionResult depositResult = bankAccountService.deposit("1234567890", new BigDecimal("500.00"));
        TransactionResult withdrawResult = bankAccountService.withdraw("1234567890", new BigDecimal("200.00"));

        // Then
        assertThat(depositResult.getNewBalance()).isEqualByComparingTo("1500.00");
        assertThat(withdrawResult.getNewBalance()).isEqualByComparingTo("1300.00");

        // 验证保存被调用了两次
        then(accountRepository).should(times(2)).save(any(Account.class));
    }

    /**
     * 测试 void 方法的不同写法
     */
    @Test
    @DisplayName("审计日志 - 测试void方法的不同Mock方式")
    void auditLogging_TestVoidMethodMocking() {
        // 测试方法1: BDD风格 - willDoNothing()
        willDoNothing().given(auditService).logTransaction(anyString(), anyString(), any(BigDecimal.class));
        
        // 测试方法2: 传统风格 - doNothing().when()
        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;
    
    // builder, getters, setters
    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; }
    }
    
    // getters
    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;
    
    // constructor, getters, setters
}

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;
    }
    
    // getters
    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;
    }
    
    // getters
    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. 最佳实践

// 推荐:BDD风格,结构清晰
@Test
void shouldDoSomethingWhenCondition() {
    // Given
    given(someRepository.findById(anyLong())).willReturn(someEntity);
    
    // When  
    SomeResult result = service.doSomething();
    
    // Then
    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,以及它们在复杂业务场景下的应用。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值