「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!
測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
Day 1-7 Day 8-13 Day 14-20 Day 21-27
經過前二十天的學習,我們已經熟悉了 Laravel HTTP 測試、Pest 框架,以及測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。
在開始寫測試之前,先列出新增 Todo 的需求:
建立 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');
});
建立 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);
}
}
建立 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();
});
postJson
測試 API 端點明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!