iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!

🗺️ 第二十一天的旅程

測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
  Day 1-7    Day 8-13    Day 14-20    Day 21-27

經過前二十天的學習,我們已經熟悉了 Laravel HTTP 測試、Pest 框架,以及測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。

📝 分析需求

在開始寫測試之前,先列出新增 Todo 的需求:

  1. 基本輸入:使用者可以輸入待辦事項
  2. 提交方式:透過 API POST 請求新增
  3. 輸入驗證:不允許空白或只有空格的待辦事項
  4. 狀態管理:成功新增後回傳 201 狀態碼
  5. API 互動:將新待辦事項儲存到資料庫

🔴 紅燈:第一個測試

建立 tests/Feature/Day21/TodoCreateTest.php

<?php

use App\Models\Todo;
use function Pest\Laravel\postJson;

beforeEach(function () {
    Todo::query()->delete();
});

test('can_create_todo_with_valid_data', function () {
    $data = [
        'title' => 'Write Day 21 article',
        'completed' => false
    ];
    
    $response = postJson('/api/todos', $data);
    
    $response->assertStatus(201)
             ->assertJsonStructure([
                 'id', 'title', 'completed',
                 'created_at', 'updated_at'
             ]);
    
    $this->assertDatabaseHas('todos', [
        'title' => 'Write Day 21 article',
        'completed' => false
    ]);
});

🟢 綠燈:實作控制器

建立 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;

class TodoController extends Controller
{
    public function store(Request $request)
    {
        $todo = Todo::create([
            'title' => $request->input('title'),
            'completed' => $request->input('completed', false)
        ]);
        
        return response()->json($todo, 201);
    }
}

更新 routes/api.php

use App\Http\Controllers\TodoController;

Route::post('/todos', [TodoController::class, 'store']);

加入驗證測試 🛡️

更新 tests/Feature/Day21/TodoCreateTest.php

test('requires_title_when_creating_todo', function () {
    $response = postJson('/api/todos', [
        'completed' => false
    ]);
    
    $response->assertStatus(422)
             ->assertJsonValidationErrors(['title']);
});

test('title_must_not_be_empty', function () {
    $response = postJson('/api/todos', [
        'title' => ''
    ]);
    
    $response->assertStatus(422)
             ->assertJsonValidationErrors(['title']);
});

test('title_has_maximum_length', function () {
    $response = postJson('/api/todos', [
        'title' => str_repeat('a', 256)
    ]);
    
    $response->assertStatus(422)
             ->assertJsonValidationErrors(['title']);
});

實作驗證邏輯 📝

建立 app/Http/Requests/StoreTodoRequest.php

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreTodoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
    
    public function rules(): array
    {
        return [
            'title' => 'required|string|max:255',
            'completed' => 'boolean'
        ];
    }
}

更新 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use App\Http\Requests\StoreTodoRequest;

class TodoController extends Controller
{
    public function store(StoreTodoRequest $request)
    {
        $todo = Todo::create($request->validated());
        return response()->json($todo, 201);
    }
}

測試邊界情況 🔍

更新 tests/Feature/Day21/TodoCreateTest.php

test('completed_defaults_to_false_when_not_provided', function () {
    $response = postJson('/api/todos', ['title' => 'Learn TDD']);
    $response->assertStatus(201)->assertJson(['completed' => false]);
});

test('can_create_completed_todo', function () {
    $response = postJson('/api/todos', [
        'title' => 'Completed task', 'completed' => true
    ]);
    $response->assertStatus(201)->assertJson(['completed' => true]);
});

test('ignores_extra_fields', function () {
    $response = postJson('/api/todos', [
        'title' => 'Normal task',
        'completed' => false,
        'extra_field' => 'ignored'
    ]);
    
    $response->assertStatus(201);
    expect(Todo::first()->title)->toBe('Normal task');
});

💡 重構:Service 層

建立 app/Services/TodoService.php

<?php

namespace App\Services;

use App\Models\Todo;

class TodoService
{
    public function create(array $data): Todo
    {
        return Todo::create([
            'title' => $data['title'],
            'completed' => $data['completed'] ?? false
        ]);
    }
}

重構 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreTodoRequest;
use App\Services\TodoService;

class TodoController extends Controller
{
    public function __construct(
        private TodoService $todoService
    ) {}
    
    public function store(StoreTodoRequest $request)
    {
        $todo = $this->todoService->create($request->validated());
        return response()->json($todo, 201);
    }
}

單元測試 Service 🧪

建立 tests/Unit/Day21/TodoServiceTest.php

<?php

use App\Models\Todo;
use App\Services\TodoService;

beforeEach(function () {
    $this->service = new TodoService();
    Todo::query()->delete();
});

test('creates_todo_with_all_fields', function () {
    $data = ['title' => 'Test task', 'completed' => true];
    
    $todo = $this->service->create($data);
    
    expect($todo)->toBeInstanceOf(Todo::class);
    expect($todo->title)->toBe('Test task');
    expect($todo->completed)->toBeTrue();
});

test('creates_todo_with_default_status', function () {
    $todo = $this->service->create(['title' => 'Default task']);
    expect($todo->completed)->toBeFalse();
});

🎯 本日重點整理

✅ 測試要點

  • 基本測試:驗證新增功能正常運作
  • 驗證測試:確保輸入驗證正確
  • 錯誤測試:測試各種錯誤情況
  • 整合測試:測試完整流程

🔧 技術重點

  • 使用 Pest 框架撰寫測試
  • postJson 測試 API 端點
  • 實作 Service 層分離關注點
  • 使用 FormRequest 進行驗證

明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!

📚 參考資源


上一篇
Day 20 - 測試 TodoList 元件 📝
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言