Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 30: Testing với Pest trong Laravel 13

Đến bài trước, mỗi lần thêm tính năng mình lại mở trình duyệt, tinker để thử lại từ đầu. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về testing với Pest trong Laravel 13 để máy làm việc đó thay chúng ta.

1. Testing và Pest trong Laravel.

Test là code gọi code của ứng dụng rồi kiểm tra kết quả. Pest là framework test xây trên PHPUnit với cú pháp gọn hơn, có sẵn trong TaskFlow từ lúc tạo project (Bài 2). Thư mục tests có hai loại:

  • tests/Unit: unit test, kiểm tra một phần nhỏ (một class, một method), không khởi động Laravel nên không dùng được database, facade.
  • tests/Feature: feature test, khởi động cả ứng dụng, gửi request rồi kiểm tra response, database. Nên viết chủ yếu loại này.

Khi chạy test, phpunit.xml đặt APP_ENV=testing, database SQLite :memory: (trong RAM, không đụng tới database.sqlite), cache, session và mail là array, queue là sync. Tạo file test bằng php artisan make:test ProjectTest (thêm --unit để tạo trong tests/Unit), chạy test bằng php artisan test hoặc vendor/bin/pest. Mình xóa hai file ExampleTest.php có sẵn.

Bài này chỉ trích một số test tiêu biểu, các test còn lại mình chỉ kể tên trường hợp, các bạn viết thêm theo cùng cách nhé.

2. Unit test, dataset và expectation.

Mỗi test là một lời gọi test('mô tả', closure). expect($giaTri) tạo một expectation: toBe() (so sánh ===), toBeNull(), toBeTrue(), toContain()..., not để phủ định. with() gắn một dataset: test chạy một lần cho mỗi dòng, key là tên hiện ra khi chạy.

- tests/Unit/TaskStatusTest.php (trích)

<?php

use App\Enums\TaskStatus;

// mỗi dòng của dataset chạy test một lần
test('mỗi trạng thái có nhãn tiếng Việt', function (TaskStatus $status, string $label) {
    expect($status->label())->toBe($label);
})->with([
    'todo' => [TaskStatus::Todo, 'Cần làm'],
    'doing' => [TaskStatus::Doing, 'Đang làm'],
    'done' => [TaskStatus::Done, 'Hoàn thành'],
]);

3. Feature test cho project.

Trait RefreshDatabase chạy migration một lần rồi bọc mỗi test trong một transaction, xong thì rollback, nên test nào cũng bắt đầu với database trống. Mình bật nó cho cả thư mục Feature bằng cách bỏ comment dòng ->use(RefreshDatabase::class) trong tests/Pest.php.

Dữ liệu tạo bằng factory (Bài 13, for() ở Bài 17). actingAs($user) đăng nhập cho request, get(), post(), put(), delete() gửi request và trả về response để kiểm tra:

- tests/Feature/ProjectTest.php (trích)

<?php

use App\Models\Project;
use App\Models\User;

test('tạo project mới', function () {
    $an = User::factory()->create();

    $this->actingAs($an)
        ->post('/projects', ['name' => 'Học Pest', 'description' => 'Viết test cho TaskFlow'])
        ->assertRedirect()
        ->assertSessionHas('status', 'Đã tạo project "Học Pest".');

    $this->assertDatabaseHas('projects', ['name' => 'Học Pest', 'user_id' => $an->id]);
});

test('không xem, sửa, xóa được project của người khác', function () {
    $project = Project::factory()->create();
    $binh = User::factory()->create();

    $this->actingAs($binh)->get("/projects/{$project->id}")->assertForbidden();
    $this->actingAs($binh)->get("/projects/{$project->id}/edit")->assertForbidden();
    $this->actingAs($binh)->put("/projects/{$project->id}", ['name' => 'Bị sửa'])->assertForbidden();
    $this->actingAs($binh)->delete("/projects/{$project->id}")->assertForbidden();

    $this->assertModelExists($project);
});

Trong đó:

  • assertRedirect(), assertOk(), assertForbidden(), assertStatus(422): status code; assertSee(): HTML có chứa chuỗi.
  • assertSessionHas(): có flash message; assertSessionHasErrors('name'): trường name bị lỗi validation.
  • assertDatabaseHas(), assertModelExists(): kiểm tra database.

Test thứ hai chống lỗi IDOR (đổi id trên URL để xem dữ liệu người khác): sửa ProjectPolicy (Bài 22) cho ai cũng xem được là nó báo đỏ ngay. File đầy đủ còn có test validation dùng dataset.

4. Test đăng nhập và API.

Request JSON dùng getJson(), postJson() cùng assertJsonPath(), assertJsonCount(), assertJsonValidationErrors(). VD kiểm tra POST /api/tokens (Bài 23) bị khóa sau 5 lần sai mật khẩu, mỗi test có cache array mới nên bộ đếm luôn từ 0:

- tests/Feature/AuthTest.php (trích, đã use App\Models\User;)

test('API lấy token cũng bị khóa sau 5 lần sai', function () {
    $user = User::factory()->create();
    $data = ['email' => $user->email, 'password' => 'sai', 'device_name' => 'pest'];

    foreach (range(1, 5) as $i) {
        $this->postJson('/api/tokens', $data)->assertUnprocessable();
    }

    $this->postJson('/api/tokens', [...$data, 'password' => 'password'])
        ->assertTooManyRequests()
        ->assertJsonValidationErrors('email');
});

Test cho form /login (Bài 21) cũng vậy, lần 6 dùng assertSessionHasErrors('email') và assertGuest(). Với route cần token, Sanctum::actingAs($user, $abilities) đăng nhập bằng token giả có các ability truyền vào:

- tests/Feature/Api/TaskApiTest.php (trích, đã use các model và Laravel\Sanctum\Sanctum)

test('token thiếu ability thì bị chặn', function () {
    $project = Project::factory()->for(User::factory(), 'owner')->create();

    Sanctum::actingAs($project->owner, ['tasks:read']);

    $this->postJson("/api/projects/{$project->id}/tasks", ['title' => 'Viết test API'])
        ->assertForbidden()
        ->assertJsonPath('message', 'Token không có quyền tạo task.');

    $this->assertDatabaseCount('tasks', 0);
});

File đầy đủ còn kiểm tra token tasks:create (201), không có token (401) và user khác (403).

5. Fake mail, queue, event và storage.

Mail, Notification, Queue, Event, Storage có method fake() thay bằng bản giả chỉ ghi lại những gì được gọi, sau đó kiểm tra bằng assert.... VD test đăng ký gọi Mail::fake() rồi Mail::assertQueued(WelcomeMail::class), test giao task gọi Notification::fake() rồi Notification::assertSentTo($binh, TaskAssigned::class), còn Event::fake() đã có ở Bài 26. Đây là test nhắc việc của Bài 29:

- tests/Feature/TaskNotificationTest.php (trích, đã use các class)

test('tasks:remind đưa job nhắc việc vào queue', function () {
    Queue::fake();
    $binh = User::factory()->create();
    // task hạn ngày mai: 1 task đã giao, 1 task chưa giao, 1 task đã xong
    $task = Task::factory()->create(['due_date' => today()->addDay(), 'assignee_id' => $binh->id]);
    Task::factory()->create(['due_date' => today()->addDay()]);
    Task::factory()->create(['due_date' => today()->addDay(), 'assignee_id' => $binh->id, 'status' => TaskStatus::Done]);

    $this->artisan('tasks:remind')
        ->expectsOutput('Đã đưa 1 job nhắc việc vào queue.')
        ->assertSuccessful();

    Queue::assertPushedOn('reminders', SendTaskReminder::class, fn (SendTaskReminder $job) => $job->task->is($task));
    Queue::assertPushedTimes(SendTaskReminder::class, 1); // assertCount() đếm cả job của TaskAssigned
});

$this->artisan() chạy command và kiểm tra output. Task có người được giao còn sinh job TaskAssigned nên mình đếm riêng SendTaskReminder.

Với upload, Storage::fake() đổi disk mặc định thành thư mục tạm, UploadedFile::fake()->create() tạo file giả với tên, dung lượng (KB) và MIME. beforeEach() chạy trước mỗi test trong file:

- tests/Feature/AttachmentTest.php (trích, đã use các model, UploadedFile, Storage)

beforeEach(function () {
    Storage::fake(); // disk mặc định thành thư mục tạm, được dọn sạch mỗi lần gọi

    $this->task = Task::factory()
        ->for(Project::factory()->for(User::factory(), 'owner'))
        ->create();
    $this->owner = $this->task->project->owner;
});

test('chủ project tải file lên được', function () {
    $file = UploadedFile::fake()->create('bao-cao.pdf', 200, 'application/pdf'); // 200 KB

    $this->actingAs($this->owner)
        ->post("/tasks/{$this->task->id}/attachments", ['file' => $file])
        ->assertSessionHas('status', 'Đã tải lên bao-cao.pdf.');

    Storage::assertExists('attachments/'.$this->task->id.'/'.$file->hashName());
    $this->assertDatabaseHas('attachments', ['task_id' => $this->task->id, 'original_name' => 'bao-cao.pdf']);
});

File đầy đủ còn chặn file .exe, file 2 MB và kiểm tra người được giao task tải về được (assertDownload()) nhưng tải lên thì 403.

6. Chạy toàn bộ test.

Kết quả chạy bộ test đầy đủ của mình (31 test), các bạn viết đủ các trường hợp ở trên thì sẽ ra con số tương tự:

php artisan test
#   ...
#    PASS  Tests\Feature\TaskNotificationTest
#   ✓ tasks:remind đưa job nhắc việc vào queue                             0.02s
#
#   Tests:    31 passed (100 assertions)
#   Duration: 2.43s

Tùy chọn hay dùng: --filter=ability chỉ chạy test có tên chứa chuỗi đó, --coverage báo bao nhiêu phần trăm code được test chạy qua (cần Xdebug hoặc PCOV), thêm --min=80 để fail khi dưới 80%.

7. Lời kết.

Như vậy chúng ta đã tìm hiểu xong testing với Pest trong Laravel 13, bộ test của TaskFlow đã có 31 test cho xác thực, phân quyền, API, upload và queue. Các bạn xem thêm tại tài liệu chính thức. Bài sau mình sẽ giới thiệu về Laravel AI SDK để tóm tắt project bằng AI.

Đăng ký nhận tin.

Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!

Vũ Thanh Tài

About author
The best way to learn is to share
Xem tất cả bài đăng

0 Comments

Bài viết chưa có ai bình luận, hãy là người đầu tiên đi bạn!

Bình luận

Captcha