iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

今天要做什麼?

昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的函數?」

想像一個場景:你的應用需要呼叫 API、寄送 email 或讀取資料庫。在測試時,你不希望真的去呼叫這些外部服務。今天我們要學習「測試替身」,了解如何用假物件替換真實依賴。

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 Pest 的 mock()spy() 用法
  • 學會 Mock 和 Stub 的使用場景
  • 掌握測試替身的最佳實踐

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試替身? 🎪

測試替身(Test Double)是在測試中用來替代真實依賴的假物件。主要有兩種:

  1. Stub:回傳預設定好回應的物件
  2. Mock:有預期行為的物件,會驗證是否被正確呼叫

為什麼需要測試替身?

// 問題:直接依賴外部服務
class UserService
{
    public function getUserProfile(int $userId): array
    {
        $response = Http::get("/api/users/{$userId}"); // 真實 API 呼叫
        $user = $response->json();
        
        return [
            'id' => $user['id'],
            'name' => $user['name'],
            'displayName' => strtoupper($user['name'])
        ];
    }
}

測試時會遇到的問題:

  • 需要真實的 API server
  • 測試速度慢
  • 難以測試錯誤情況

基本語法與用法 🔧

Mock 的基本用法

Pest 提供了強大的 Mock 功能:

// 建立 Mock
$mockHttp = Http::fake([
    'api/users/*' => Http::response([
        'id' => 1,
        'name' => 'John Doe',
        'email' => 'john@example.com'
    ])
]);

實戰演練:Mock HTTP 請求

建立 app/Services/UserService.php

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class UserService
{
    public function getUserProfile(int $userId): array
    {
        $response = Http::get("/api/users/{$userId}");
        $user = $response->json();
        
        return [
            'id' => $user['id'],
            'name' => $user['name'],
            'displayName' => strtoupper($user['name'])
        ];
    }
    
    public function createUser(array $userData): array
    {
        $response = Http::post('/api/users', $userData);
        return $response->json();
    }
}

建立 tests/Feature/Day07/UserServiceTest.php

<?php

use App\Services\UserService;
use Illuminate\Support\Facades\Http;

describe('UserService', function() {
    beforeEach(function() {
        $this->userService = new UserService();
    });

    it('gets user profile', function() {
        // 建立 HTTP fake
        Http::fake([
            '/api/users/1' => Http::response([
                'id' => 1,
                'name' => 'John Doe',
                'email' => 'john@example.com'
            ])
        ]);
        
        $result = $this->userService->getUserProfile(1);
        
        expect($result)->toEqual([
            'id' => 1,
            'name' => 'John Doe',
            'displayName' => 'JOHN DOE'
        ]);
        
        // 驗證 HTTP 請求被呼叫
        Http::assertSent(function($request) {
            return $request->url() === '/api/users/1';
        });
    });

    it('creates user', function() {
        Http::fake([
            '/api/users' => Http::response(['id' => 2, 'name' => 'Jane'])
        ]);
        
        $userData = ['name' => 'Jane', 'email' => 'jane@example.com'];
        $result = $this->userService->createUser($userData);
        
        expect($result)->toEqual(['id' => 2, 'name' => 'Jane']);
        
        Http::assertSent(function($request) use ($userData) {
            return $request->url() === '/api/users' &&
                   $request->data() === $userData;
        });
    });
});

進階使用 🚀

模擬不同回應狀況

it('handles api error', function() {
    Http::fake([
        '/api/users/999' => Http::response(null, 404)
    ]);
    
    expect(function() {
        $this->userService->getUserProfile(999);
    })->toThrow(Exception::class);
});

使用 Spy 驗證呼叫

it('tests with spy', function() {
    $spy = $this->spy(UserService::class);
    
    $spy->getUserProfile(1);
    
    $spy->shouldHaveReceived('getUserProfile')->with(1)->once();
});

最佳實踐 💡

1. 明確的測試意圖

// ✅ 好的做法:清楚表達測試意圖
it('formats user name to uppercase', function() {
    Http::fake([
        '/api/users/1' => Http::response(['id' => 1, 'name' => 'john'])
    ]);
    
    $result = $this->userService->getUserProfile(1);
    
    expect($result['displayName'])->toBe('JOHN');
});

2. 最小化 Mock 範圍

只 Mock 必要的部分,不要過度 Mock。

完整實作 📝

更新 tests/Feature/Day07/UserServiceTest.php

<?php

use App\Services\UserService;
use Illuminate\Support\Facades\Http;

describe('UserService with Test Doubles', function() {
    beforeEach(function() {
        $this->userService = new UserService();
    });

    describe('getUserProfile', function() {
        it('returns formatted user data', function() {
            Http::fake([
                '/api/users/1' => Http::response([
                    'id' => 1,
                    'name' => 'John Doe',
                    'email' => 'john@example.com'
                ])
            ]);
            
            $result = $this->userService->getUserProfile(1);
            
            expect($result)->toHaveKeys(['id', 'name', 'displayName']);
            expect($result['displayName'])->toBe('JOHN DOE');
            
            Http::assertSent(fn($request) => 
                str_contains($request->url(), '/api/users/1')
            );
        });

        it('handles different user names correctly', function() {
            Http::fake(['/api/users/*' => Http::response([
                'id' => 2, 'name' => 'alice smith', 'email' => 'alice@example.com'
            ])]);
            
            $result = $this->userService->getUserProfile(2);
            
            expect($result['displayName'])->toBe('ALICE SMITH');
        });
    });

    describe('createUser', function() {
        it('sends correct data to api', function() {
            Http::fake([
                '/api/users' => Http::response(['id' => 3, 'success' => true])
            ]);
            
            $userData = [
                'name' => 'New User',
                'email' => 'new@example.com'
            ];
            
            $result = $this->userService->createUser($userData);
            
            expect($result)->toHaveKey('success', true);
            
            Http::assertSent(function($request) use ($userData) {
                return $request->url() === '/api/users' &&
                       $request->data() === $userData;
            });
        });
    });
});

今天學到什麼? 📚

今天我們深入學習了測試替身的重要概念:

核心概念

  • 測試替身:用假物件替代真實依賴
  • Mock vs Stub:Mock 驗證行為,Stub 提供回應
  • 隔離測試:讓測試專注於被測試的程式碼

實用技術

  • HTTP::fake():模擬 HTTP 請求回應
  • assertSent():驗證 HTTP 請求被正確呼叫
  • Spy:監控方法呼叫

最佳實踐

  • 最小化 Mock:只 Mock 必要的依賴
  • 驗證重要互動:確保外部服務被正確呼叫
  • 清晰的測試意圖:讓測試表達明確的業務邏輯

總結 🎊

測試替身是 TDD 中的重要工具,讓我們能夠:

  • 隔離測試對象:專注測試目標程式碼
  • 控制外部依賴:模擬各種情況和回應
  • 提高測試速度:避免真實的外部服務呼叫
  • 增強測試可靠性:不受外部服務影響

記住:好的測試替身讓測試更快、更穩定、更專注。

明天我們將學習「例外處理測試」,了解如何驗證程式在異常情況下的行為。


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言