iT邦幫忙

2026 iThome 鐵人賽

0
ChatGPT & Codex

從 Prompt Engineering 到 Copilot / Codex 實務 30 天系列 第 24 篇

Day 24:【單元測試生成】用 AI 快速撰寫 JUnit 5 與 Mockito 測試案例:輕鬆把測試覆蓋率拉滿

  • 分享至 

  • xImage
  •  

完成了前面四個模組的開發、重構與協作技巧後,今天我們正式邁入第五模組——「品質把關、自動化與資安實務」。

在現代敏捷開發與持續整合(CI/CD)流程中,單元測試(Unit Test)是確保代碼品質、防止改動引入回歸錯誤(Regression Bug)的最後一道防線。然而,很多工程師在專案裡往往不愛寫測試,原因很簡單:

  1. 樣板代碼過多:為了測一個簡單的方法,得先手動 Mock 三四個 Repository 或外部客戶端。
  2. 邊界構思耗時:要覆蓋各種 null、空集合、異常拋出,測試代碼量常常是業務代碼的兩三倍。
  3. 趕進度壓力:在時間緊迫下,單元測試往往是第一個被犧牲的環節。

今天我們就來示範:如何讓 ChatGPT / Copilot 成為你的「自動化 QA 工程師」,只需提供業務方法,就能在幾秒內自動產出符合 AAA 模式、整合 JUnit 5 + Mockito 的高覆蓋率測試案例!


一、單元測試生成提示詞模板(Unit Test Prompting)

想要讓 AI 產出嚴謹且可直接運行的測試類別,不要只丟一句「幫我寫測試」,請使用以下標準規範模板:

【單元測試提示詞範本】
請扮演資深 Java QA 自動化測試專家。以下是一段等待測試的 Spring Boot / Java 業務代碼:

[貼上既有的 Service 或業務邏輯方法]

請使用 JUnit 5 與 Mockito 框架為此方法產出完整的測試類別,要求如下:

  1. 測試框架規範:使用 @ExtendWith(MockitoExtension.class),嚴格採用 AAA 模式(Arrange, Act, Assert)標註每個測試區塊。
  2. 測試覆蓋矩陣:
    • Happy Path:正常成功流程測試。
    • Exception Path:驗證各種條件不符時,是否如預期拋出指定例外(如 IllegalArgumentException)。
    • Edge Cases:驗證傳入 null、空集合或極端數值時的防禦行為。
  3. 斷言庫偏好:優先使用 AssertJ(assertThat(...))以提升測試可讀性。
  4. 驗證依賴互動:使用 Mockito.verify(...) 確認外部相依套件被呼叫的次數與參數正確性。

二、實戰案例:會員轉帳服務(TransferService)

假設我們有一個銀行轉帳的核心業務方法,涉及金額扣款、收款與帳號餘額檢查:

[待測業務代碼]
public class TransferService {

    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("轉帳金額必須大於 0");
        }
        if (fromAccountId.equals(toAccountId)) {
            throw new IllegalArgumentException("轉出與轉入帳號不可相同");
        }

        Account fromAccount = accountRepository.findById(fromAccountId)
                .orElseThrow(() -> new AccountNotFoundException("找不到轉出帳號"));
        Account toAccount = accountRepository.findById(toAccountId)
                .orElseThrow(() -> new AccountNotFoundException("找不到轉入帳號"));

        if (fromAccount.getBalance().compareTo(amount) < 0) {
            throw new InsufficientBalanceException("帳戶餘額不足");
        }

        fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
        toAccount.setBalance(toAccount.getBalance().add(amount));

        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
    }
}

三、AI 生成的 JUnit 5 + Mockito 完整測試類別

將上述代碼與模板交給 AI 後,生成的測試類別結構分明、職責單一:

[AI 生成的完整單元測試]
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
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.any;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TransferServiceTest {

    @Mock
    private AccountRepository accountRepository;

    @InjectMocks
    private TransferService transferService;

    @Test
    @DisplayName("轉帳成功:餘額充足時應正確扣除轉出帳戶並增加轉入帳戶餘額")
    void transfer_Success() {
        // [Arrange] 準備測試資料與 Mock 行為
        Long fromId = 1L;
        Long toId = 2L;
        BigDecimal amount = new BigDecimal("500");

        Account fromAccount = new Account(fromId, new BigDecimal("1000"));
        Account toAccount = new Account(toId, new BigDecimal("200"));

        when(accountRepository.findById(fromId)).thenReturn(Optional.of(fromAccount));
        when(accountRepository.findById(toId)).thenReturn(Optional.of(toAccount));

        // [Act] 執行待測方法
        transferService.transfer(fromId, toId, amount);

        // [Assert] 驗證結果與狀態變更
        assertThat(fromAccount.getBalance()).isEqualByComparingTo("500");
        assertThat(toAccount.getBalance()).isEqualByComparingTo("700");

        // 驗證儲存方法被呼叫了兩次
        verify(accountRepository, times(2)).save(any(Account.class));
    }

    @Test
    @DisplayName("轉帳失敗:轉帳金額小於等於 0 時應拋出 IllegalArgumentException")
    void transfer_AmountZeroOrNegative_ThrowsException() {
        // [Act & Assert]
        assertThatThrownBy(() -> transferService.transfer(1L, 2L, BigDecimal.ZERO))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("轉帳金額必須大於 0");

        // 驗證未查詢資料庫
        verifyNoInteractions(accountRepository);
    }

    @Test
    @DisplayName("轉帳失敗:轉出與轉入帳號相同時應拋出 IllegalArgumentException")
    void transfer_SameAccount_ThrowsException() {
        // [Act & Assert]
        assertThatThrownBy(() -> transferService.transfer(1L, 1L, new BigDecimal("100")))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("轉出與轉入帳號不可相同");

        verifyNoInteractions(accountRepository);
    }

    @Test
    @DisplayName("轉帳失敗:轉出帳戶餘額不足時應拋出 InsufficientBalanceException")
    void transfer_InsufficientBalance_ThrowsException() {
        // [Arrange]
        Long fromId = 1L;
        Long toId = 2L;
        Account fromAccount = new Account(fromId, new BigDecimal("100")); // 只有 100
        Account toAccount = new Account(toId, new BigDecimal("500"));

        when(accountRepository.findById(fromId)).thenReturn(Optional.of(fromAccount));
        when(accountRepository.findById(toId)).thenReturn(Optional.of(toAccount));

        // [Act & Assert]
        assertThatThrownBy(() -> transferService.transfer(fromId, toId, new BigDecimal("500")))
                .isInstanceOf(InsufficientBalanceException.class)
                .hasMessage("帳戶餘額不足");

        // 驗證未觸發任何 save 更新操作
        verify(accountRepository, never()).save(any(Account.class));
    }
}

四、AI 單元測試為工程師帶來的效益

  1. 消除 Mockito 的語法負擔:
    when(...).thenReturn(...)、verify(..., times(2)) 這些繁複的語法,由 AI 自動根據邏輯推導完成,工程師只需檢視資料 mock 是否合乎現實。
  2. 自動捕獲防禦邊界:
    AI 會主動產生「金額小於 0」、「同帳號互轉」、「餘額不足」等多個測試分支,避免工程師只測「成功案例」的思維盲區。
  3. 驗證副作用(Side Effects):
    AI 在拋出例外時自動加上了 verifyNoInteractions(accountRepository) 或 never().save(...),能精確確認當業務失敗時,系統沒有意外更動到資料庫。

今日小結與預告

今天我們示範了如何透過結構化 Prompt,讓 AI 在幾秒鐘內產出具備 AAA 架構、整合 JUnit 5 與 Mockito 的高品質測試案例,讓原本枯燥耗時的單元測試撰寫變得無痛且高效。


上一篇
Day 23:【終端機與指令】讓 AI 扮演 Linux / Git 指令字典:精準生成 find、grep、awk 與排查指令
下一篇
Day 25:【測試覆蓋率】讓 AI 主動挖掘邊界案例(Boundary Values)以補全測試盲點
系列文
從 Prompt Engineering 到 Copilot / Codex 實務 30 天 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言