feat: complete Smartgift AI Laravel unit testing toolkit

- Add 10 core testing guides (setup, AAA principles, PHPUnit/Pest, mocking, anti-patterns, PHP 8.4+, debugging, data factories, advanced edge cases)
- Add 13 component testing guides covering all Laravel layers
- Add standard templates for PHPUnit 10/11 and Pest v2/v3 including ArchitectureTest
- Add Docker smart test runner, dependency installer, and CI/CD templates (GitHub Actions, GitLab CI)
- Add 4 Claude Code skills (laravel-test-setup, laravel-unit-test with --all mode, laravel-test-review, laravel-mock-helper)
- Add single unified installer script (sg-install-unit-testing.sh) with global support, CLI runners, and 1-click updater (sg-test-update)
- Add comprehensive documentation (README.md, USAGE-GUIDE.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 16:41:37 +07:00
commit 2045c856e9
60 changed files with 5248 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# 00. Environment & Setup Guide for Laravel Unit Testing
Hướng dẫn thiết lập và chuẩn hóa môi trường kiểm thử (Testing Environment) cho mọi dự án Laravel (Laravel 9, 10, 11).
---
## 1. Yêu cầu Thư viện & Dependencies (Composer)
Trước khi viết bất kỳ file Unit Test nào, dự án cần có đầy đủ các thư viện kiểm thử cần thiết trong `require-dev`.
### A. Kiểm tra phiên bản Laravel & Test Framework
Xác định phiên bản Laravel trong `composer.json`:
- **Laravel 9 (PHP 8.0 - 8.1)**: Thường đi kèm `phpunit/phpunit: ^9.5`.
- **Laravel 10 (PHP 8.1 - 8.2)**: Hỗ trợ `phpunit/phpunit: ^10.0` hoặc `pestphp/pest: ^2.0`.
- **Laravel 11 (PHP 8.2 - 8.3+)**: Mặc định `pestphp/pest: ^2.0|^3.0` hoặc `phpunit/phpunit: ^10.5|^11.0`.
### B. Cài đặt các gói cốt lõi (Core Dev Packages)
#### Nếu dự án dùng PHPUnit:
```bash
composer require --dev phpunit/phpunit mockery/mockery fakerphp/faker --with-all-dependencies
```
#### Nếu dự án dùng Pest PHP:
```bash
composer require --dev pestphp/pest pestphp/pest-plugin-laravel mockery/mockery fakerphp/faker --with-all-dependencies
```
#### Các gói bổ trợ tăng tốc & chẩn đoán (Recommended):
- **Chạy song song (Parallel Testing)**:
```bash
composer require --dev brianium/paratest
# Laravel 9+ có sẵn lệnh: php artisan test --parallel
```
- **Làm đẹp output CLI**:
```bash
composer require --dev nunomaduro/collision
```
---
## 2. Cấu hình Môi trường Test (`phpunit.xml` & `.env.testing`)
Một trong những sai lầm nghiêm trọng nhất là để test chạy vào Database dev/staging. Cần cấu hình cách ly tuyệt đối.
### A. Tối ưu hóa `phpunit.xml` (hoặc `phpunit.xml.dist`)
Đảm bảo thẻ `<php>` chứa các cấu hình sau:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<!-- Môi trường test -->
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<!-- Tăng tốc mã hóa mật khẩu trong lúc test (từ 10-12 vòng xuống 4 vòng) -->
<env name="BCRYPT_ROUNDS" value="4"/>
<!-- Tắt cache, session, queue, mail thật -->
<env name="CACHE_STORE" value="array"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<!-- Database cách ly: SQLite In-memory -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
```
### B. File `.env.testing`
Nếu dự án ưu tiên dùng `.env.testing` hơn `phpunit.xml`, tạo file `.env.testing` tại thư mục root:
```ini
APP_ENV=testing
APP_KEY=base64:FakeKeyForTestingPurposesOnly1234567890=
APP_DEBUG=true
BCRYPT_ROUNDS=4
CACHE_STORE=array
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
```
---
## 3. Chiến lược Xử lý Database trong Kiểm thử
### Chiến lược 1: SQLite In-memory (Mặc định - Khuyến nghị cho 90% Unit Test)
- **Ưu điểm**: Tốc độ cực nhanh (vài milliseconds), không tạo rác trên ổ cứng, tự hủy sau mỗi test process.
- **Yêu cầu**: PHP phải kích hoạt extension `pdo_sqlite` và `sqlite3`.
### Chiến lược 2: Xử lý các hàm MySQL đặc thù (MySQL-specific functions)
Nếu code nguồn dùng các hàm raw của MySQL như `JSON_EXTRACT`, `JSON_CONTAINS`, `DATE_FORMAT`, SQLite sẽ văng lỗi `no such function`.
**Cách xử lý:**
1. Đăng ký SQLite custom function trong `TestCase::setUp()`:
```php
protected function setUp(): void
{
parent::setUp();
if (DB::connection() instanceof \Illuminate\Database\SQLiteConnection) {
$pdo = DB::connection()->getPdo();
// Giả lập hàm DATE_FORMAT của MySQL trong SQLite
$pdo->sqliteCreateFunction('DATE_FORMAT', function ($date, $format) {
return date('Y-m-d', strtotime($date));
}, 2);
// Giả lập JSON_CONTAINS
$pdo->sqliteCreateFunction('JSON_CONTAINS', function ($json, $val) {
$array = json_decode($json, true) ?? [];
return in_array(trim($val, '"'), $array) ? 1 : 0;
}, 2);
}
}
```
2. Nếu dự án có quá nhiều Stored Procedures hoặc SQL phức tạp: Sử dụng một container MySQL test riêng (xem mục Docker bên dưới) thay vì SQLite.
---
## 4. Chạy Test qua Docker (Docker Integration)
### Kịch bản 1: Dự án dùng Laravel Sail
Laravel Sail đã cấu hình sẵn container test:
```bash
# Chạy toàn bộ test
./vendor/bin/sail test
# Chạy riêng Unit Test
./vendor/bin/sail test --testsuite=Unit
# Chạy kèm tính Code Coverage
./vendor/bin/sail test --coverage
```
### Kịch bản 2: Dự án dùng Custom Docker Compose
Xác định tên service PHP trong `docker-compose.yml` (thường là `app`, `php`, `backend`, `workspace`):
```bash
# Cú pháp chạy
docker compose exec -T <service_name> php artisan test
# Ví dụ với service 'app':
docker compose exec -T app php artisan test --testsuite=Unit --filter=UserServiceTest
```
### Kịch bản 3: Docker Test Runner độc lập (`templates/docker/docker-compose.test.yml`)
Nếu dự án chưa có Docker hoặc môi trường Docker chính quá nặng nề, có thể khởi tạo một container test tối giản chỉ chứa PHP-CLI + SQLite + PCOV.
---
## 5. Cấu hình Code Coverage (PCOV vs Xdebug)
Để đo độ phủ test (Coverage):
- **Khuyến nghị dùng PCOV**: Nhanh gấp 5-10 lần Xdebug, chỉ phục vụ đo coverage không làm chậm quá trình chạy test.
- Cài đặt trên Ubuntu/Debian container: `pecl install pcov && docker-php-ext-enable pcov`
- Lệnh chạy: `php artisan test --coverage --min=80`
- **Nếu dùng Xdebug**: Cần đặt biến môi trường `XDEBUG_MODE=coverage`.
---
## 6. Checklist Kiểm tra Sẵn sàng (Ready-to-Test Checklist)
Trước khi bắt đầu sinh unit test, hãy xác nhận các mục sau:
1. [ ] Đã chạy `composer install` không lỗi.
2. [ ] Extension `pdo_sqlite` đã được bật (kiểm tra bằng `php -m | grep sqlite`).
3. [ ] `APP_KEY` đã tồn tại trong file môi trường test.
4. [ ] Chạy lệnh `php artisan test` (hoặc tương đương qua Docker) ra kết quả xanh (Pass) với các test mặc định của Laravel.
5. [ ] Đã cấu hình `DB_DATABASE=:memory:` để bảo vệ dữ liệu dev/local.
+82
View File
@@ -0,0 +1,82 @@
# 01. Overview & Core Principles of Laravel Unit Testing
Tài liệu này định nghĩa triết lý, nguyên tắc kiến trúc và tiêu chuẩn cốt lõi khi viết Unit Test cho mã nguồn Laravel.
---
## 1. Bản chất của Unit Test trong Laravel
### A. Định nghĩa Unit Test (Kiểm thử đơn vị)
- **Đối tượng**: Kiểm thử một Class duy nhất, một Method duy nhất, hoặc một đoạn Business Logic độc lập.
- **Phạm vi**: Hoàn toàn **cô lập (Isolated)**. Tất cả các thành phần ngoại vi (Database, Third-party API, File System, Queue, Mail, Event) đều phải được **giả lập (Mocked/Faked/Stubbed)**.
- **Tốc độ**: Cực nhanh (dưới 10ms - 50ms cho mỗi test case). Toàn bộ bộ Unit Test của một hệ thống lớn phải chạy xong trong vài giây.
### B. Ranh giới giữa Unit Test và Feature/Integration Test
Sự nhầm lẫn giữa Unit Test và Feature Test là nguyên nhân số 1 khiến test bị chậm và khó bảo trì:
| Tiêu chí | Unit Test | Feature / Integration Test |
| :--- | :--- | :--- |
| **Đối tượng test** | 1 Class / Method cụ thể (Service, Action, Value Object, Rule,...) | Toàn bộ luồng nghiệp vụ (Endpoint, Controller, Middleware, DB) |
| **Kế thừa TestCase** | `PHPUnit\Framework\TestCase` (thuần) hoặc `Tests\TestCase` (tối giản) | `Tests\TestCase` (boot toàn bộ Laravel Kernel) |
| **Database** | **KHÔNG DÙNG** Database thật (Mock repository/model) | Có dùng DB (thường dùng `RefreshDatabase` với SQLite/MySQL) |
| **HTTP Request** | Không dispatch HTTP request thật | Dùng `$this->getJson()`, `$this->postJson()` |
| **External Service** | Mock bằng Mockery hoặc Anonymous Stub | Fake bằng `Http::fake()`, `Event::fake()` |
| **Thời gian chạy** | Siêu nhanh (vài milliseconds) | Chậm hơn (vài chục đến hàng trăm milliseconds) |
> ⚠️ **Quy tắc vàng**: Nếu một test cần chạy migration, chèn dữ liệu vào bảng, gọi HTTP endpoint qua router -> Đó là **Feature Test**, KHÔNG PHẢI Unit Test.
---
## 2. Quy chuẩn Cấu trúc: Mô hình AAA (Arrange - Act - Assert)
Mọi bài test đơn vị đều phải tuân thủ nghiêm ngặt 3 giai đoạn:
```php
#[Test]
public function it_calculates_total_price_with_discount_successfully(): void
{
// 1. ARRANGE (Chuẩn bị dữ liệu đầu vào & thiết lập Mock)
$discountServiceMock = Mockery::mock(DiscountServiceInterface::class);
$discountServiceMock->shouldReceive('getPercentage')
->once()
->with('SUMMER2026')
->andReturn(20.0);
$cartService = new CartService($discountServiceMock);
$items = [
new CartItem(name: 'Item A', price: 100_000, quantity: 2), // 200,000
];
// 2. ACT (Thực thi hành động cần test)
$finalPrice = $cartService->calculateTotal($items, 'SUMMER2026');
// 3. ASSERT (Kiểm tra kết quả và xác thực side-effects)
$this->assertSame(160_000.0, $finalPrice);
}
```
- **Arrange**: Chuẩn bị đầy đủ input, khởi tạo đối tượng cần test, thiết lập kỳ vọng cho các dependency mock.
- **Act**: Gọi chính xác 1 method của đối tượng cần test. Không gọi lồng nhiều hành động.
- **Assert**: So sánh kết quả trả về với kỳ vọng; xác nhận mock method được gọi đúng số lần và đúng tham số.
---
## 3. Các Nguyên tắc Cốt lõi (Core Principles)
### 1. Nguyên tắc Đơn nhiệm (Single Concept per Test)
Mỗi test method chỉ kiểm tra **một khía cạnh duy nhất** của logic:
- 1 test cho trường hợp thành công (Happy path).
- 1 test cho trường hợp dữ liệu rỗng/null.
- 1 test cho trường hợp văng Exception khi đầu vào sai.
❌ Không gộp tất cả các kịch bản if/else vào chung một test method dài 100 dòng.
### 2. Tính Độc lập & Không Phụ thuộc Thứ tự (Independent & Isolated)
- Test case A chạy trước hay test case B chạy trước không được làm ảnh hưởng kết quả của nhau.
- Không chia sẻ trạng thái biến toàn cục (global state) hoặc static variable mà không dọn dẹp sau mỗi test (`tearDown()`).
### 3. Nguyên tắc FIRST trong Unit Testing
- **F - Fast**: Chạy trong chớp mắt.
- **I - Isolated / Independent**: Không phụ thuộc môi trường mạng, database bên ngoài.
- **R - Repeatable**: Chạy 100 lần trên máy local hay CI/CD đều cho ra kết quả giống nhau.
- **S - Self-validating**: Test tự xác nhận Pass hoặc Fail qua Assertions, không cần người đọc log để đoán.
- **T - Thorough / Timely**: Bao phủ từ Happy Path cho đến các trường hợp biên khắc nghiệt (Edge cases).
+128
View File
@@ -0,0 +1,128 @@
# 02. Testing Frameworks: PHPUnit 10/11 vs Pest PHP v2/v3
So sánh chi tiết, cú pháp đối chiếu và quy tắc viết code giữa hai framework kiểm thử phổ biến nhất trong hệ sinh thái Laravel.
---
## 1. Bảng Đối Chiếu Cú Pháp (Cheat Sheet)
| Tính năng | PHPUnit 10/11 (Class-based) | Pest PHP v2/v3 (Functional) |
| :--- | :--- | :--- |
| **Khai báo Test** | `#[Test]` trên public method | `it('does something', function () { ... });` |
| **Khởi tạo (Setup)** | `protected function setUp(): void` | `beforeEach(function () { ... });` |
| **Dọn dẹp (Teardown)** | `protected function tearDown(): void` | `afterEach(function () { ... });` |
| **Assert giá trị bằng** | `$this->assertSame($expected, $actual);` | `expect($actual)->toBe($expected);` |
| **Assert kiểu dữ liệu** | `$this->assertInstanceOf(User::class, $res);` | `expect($res)->toBeInstanceOf(User::class);` |
| **Assert True/False** | `$this->assertTrue($flag);` | `expect($flag)->toBeTrue();` |
| **Assert Array có key**| `$this->assertArrayHasKey('id', $data);` | `expect($data)->toHaveKey('id');` |
| **Assert Count** | `$this->assertCount(3, $items);` | `expect($items)->toHaveCount(3);` |
| **Kỳ vọng Exception** | `$this->expectException(CustomException::class);` | `expect(fn() => ...)->toThrow(CustomException::class);` |
| **Data Provider** | `#[DataProvider('provideData')]` | `->with([...])` hoặc `->with('dataset_name')` |
---
## 2. Chuẩn viết cho PHPUnit 10 & 11
Trong Laravel 10 và 11 với PHPUnit 10+, **KHÔNG dùng DocBlock `@test`** lỗi thời mà chuyển hẳn sang dùng **PHP 8 Attributes**:
```php
<?php
namespace Tests\Unit\Services;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class OrderCalculationServiceTest extends TestCase
{
private OrderCalculationService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new OrderCalculationService();
}
#[Test]
public function it_applies_tax_correctly(): void
{
// Arrange
$amount = 100.0;
$taxRate = 0.1; // 10%
// Act
$total = $this->service->calculateWithTax($amount, $taxRate);
// Assert
$this->assertSame(110.0, $total);
}
#[Test]
#[DataProvider('invalidAmountProvider')]
public function it_throws_exception_when_amount_is_negative(float $invalidAmount): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Amount must be positive');
$this->service->calculateWithTax($invalidAmount, 0.1);
}
public static function invalidAmountProvider(): array
{
return [
'negative amount' => [-10.0],
'zero amount' => [0.0],
];
}
}
```
---
## 3. Chuẩn viết cho Pest PHP v2 & v3
Pest đem lại cú pháp ngắn gọn, dễ đọc như văn xuôi (BDD style):
```php
<?php
use App\Services\OrderCalculationService;
beforeEach(function () {
$this->service = new OrderCalculationService();
});
describe('OrderCalculationService', function () {
it('applies tax correctly', function () {
// Arrange
$amount = 100.0;
$taxRate = 0.1;
// Act
$total = $this->service->calculateWithTax($amount, $taxRate);
// Assert
expect($total)->toBe(110.0);
});
it('throws exception when amount is negative or zero', function (float $invalidAmount) {
expect(fn () => $this->service->calculateWithTax($invalidAmount, 0.1))
->toThrow(\InvalidArgumentException::class, 'Amount must be positive');
})->with([
'negative amount' => -10.0,
'zero amount' => 0.0,
]);
});
```
---
## 4. Nguyên tắc Nhận diện Tự động của Agent
Khi Agent sinh code test cho một dự án:
1. Đọc `composer.json`:
- Nếu có package `pestphp/pest` -> **BẮT BUỘC sinh file test theo chuẩn Pest**.
- Nếu chỉ có `phpunit/phpunit`:
- Kiểm tra phiên bản PHP: Nếu PHP >= 8.1 và PHPUnit >= 10 -> Dùng **PHP 8 Attributes (`#[Test]`)**.
- Nếu PHP <= 8.0 hoặc PHPUnit 9 (Laravel 8/9) -> Dùng **DocBlock `/** @test */`**.
+172
View File
@@ -0,0 +1,172 @@
# 03. Mocking & Test Doubles Strategies in Laravel
Cẩm nang toàn diện về chiến lược giả lập đối tượng (Test Doubles): Khi nào dùng Mockery, khi nào dùng Laravel Fakes, và cách xử lý Static Facades.
---
## 1. Phân loại Test Doubles: Mock, Stub, Spy và Dummy
1. **Dummy**: Đối tượng truyền vào chỉ để lấp đầy tham số (e.g. `$user = new User()`), không được gọi phương thức nào.
2. **Stub**: Trả về dữ liệu cố định định sẵn, không quan tâm nó bị gọi bao nhiêu lần (e.g. `->andReturn('success')`).
3. **Mock**: Kiểm tra hành vi (Behavior Verification). Bắt buộc phải được gọi với đúng tham số và số lần nhất định (e.g. `->once()->with('param')`).
4. **Spy**: Ghi nhận lại các lệnh gọi và kiểm tra lại sau khi hành động đã diễn ra (e.g. `->shouldHaveReceived(...)`).
---
## 2. Chiến lược 1: Dùng Mockery cho Dependency Injection
Áp dụng cho các Service, Repository, Gateway được truyền qua Constructor hoặc Method Injection.
### Quy tắc Mockery chuẩn:
```php
// 1. Tạo Mock cho Interface (Luôn ưu tiên mock Interface thay vì Concrete Class)
$paymentGateway = Mockery::mock(PaymentGatewayInterface::class);
// 2. Thiết lập kỳ vọng chính xác
$paymentGateway->shouldReceive('charge')
->once() // Bắt buộc phải gọi đúng 1 lần
->with(50000, 'VND') // Tham số truyền vào phải khớp chính xác
->andReturn(new PaymentResponse(status: 'SUCCESS', transactionId: 'TXN_123'));
// 3. Truyền Mock vào Service
$checkoutService = new CheckoutService($paymentGateway);
$result = $checkoutService->processOrder($order);
// 4. Dọn dẹp Mockery (Trong Base TestCase Laravel đã tự gọi Mockery::close())
```
### Trường hợp Exception:
```php
$paymentGateway->shouldReceive('charge')
->once()
->andThrow(new PaymentFailedException('Insufficient funds'));
```
---
## 3. Chiến lược 2: Laravel Built-in Fakes
Laravel cung cấp sẵn hệ thống Fakes cực mạnh. **Luôn ưu tiên dùng Laravel Fakes thay vì tự dùng Mockery cho các thành phần sau:**
### A. `Http::fake()` (Test gọi API bên thứ 3)
```php
use Illuminate\Support\Facades\Http;
Http::fake([
// Giả lập endpoint cụ thể
'api.stripe.com/*' => Http::response(['id' => 'ch_123', 'status' => 'succeeded'], 200),
// Giả lập lỗi 500
'api.github.com/*' => Http::response(['message' => 'Server Error'], 500),
// Giả lập kết nối timeout
'api.slow-service.com/*' => Http::failedConnection(),
]);
// Assert sau khi Act
Http::assertSent(function ($request) {
return $request->url() === 'https://api.stripe.com/v1/charges' &&
$request['amount'] == 1000;
});
```
### B. `Queue::fake()` & `Bus::fake()` (Test đẩy công việc vào hàng đợi)
```php
use Illuminate\Support\Facades\Queue;
Queue::fake();
// Act: Thực hiện nghiệp vụ
$registrationService->register($userData);
// Assert: Job SendWelcomeEmail được dispatch với đúng user
Queue::assertPushed(SendWelcomeEmailJob::class, function ($job) use ($userData) {
return $job->email === $userData['email'];
});
// Assert: Đảm bảo job nguy hiểm KHÔNG bị gọi
Queue::assertNotPushed(DeleteAccountJob::class);
```
### C. `Event::fake()` (Test phát sinh sự kiện)
```php
use Illuminate\Support\Facades\Event;
Event::fake([
OrderShippedEvent::class, // Chỉ fake event này, các event khác vẫn giữ nguyên
]);
// Assert
Event::assertDispatched(OrderShippedEvent::class);
```
### D. `Storage::fake()` (Test upload / xóa file)
```php
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
Storage::fake('avatars');
$file = UploadedFile::fake()->image('profile.jpg', 400, 400)->size(500);
$avatarService->updateAvatar($userId, $file);
// Assert file đã lưu trên disk avatars
Storage::disk('avatars')->assertExists($file->hashName());
```
---
## 4. Chiến lược 3: Xử lý Static Facades & Time
### A. Facades: Cache & Log
```php
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
// Test Cache
Cache::shouldReceive('get')
->once()
->with('user_settings_1')
->andReturn(['theme' => 'dark']);
// Test Log
Log::shouldReceive('error')
->once()
->withArgs(function ($message, $context) {
return str_contains($message, 'Payment failed');
});
```
### B. Xử lý Thời gian với Carbon (Time-travel)
Không dùng `sleep()` trong Unit Test! Dùng tính năng đóng băng thời gian của Carbon:
```php
use Illuminate\Support\Carbon;
// Đóng băng thời gian tại một mốc cụ thể
Carbon::setTestNow('2026-09-07 10:00:00');
// Chạy logic nghiệp vụ phụ thuộc ngày giờ
$isExpired = $subscriptionService->isExpired($subscription);
$this->assertFalse($isExpired);
// Nhảy đến tương lai 30 ngày
Carbon::setTestNow('2026-10-08 10:00:00');
$this->assertTrue($subscriptionService->isExpired($subscription));
// Luôn reset lại thời gian trong tearDown nếu cần
Carbon::setTestNow();
```
---
## 5. Quy tắc Vàng: "Cái gì CẦN Mock và cái gì KHÔNG ĐƯỢC Mock?"
| Thành phần | Có Mock không? | Giải thích |
| :--- | :---: | :--- |
| **Interface / External Gateway** | ✅ CÓ | Cần cô lập các service ngoài, network calls |
| **Email, SMS, Queue, Storage** | ✅ CÓ | Dùng Laravel Fakes (`Mail::fake()`, `Queue::fake()`) |
| **DTO (Data Transfer Object)** | ❌ KHÔNG | DTO chỉ chứa dữ liệu thuần. Hãy khởi tạo `new UserDTO(...)` thật! |
| **Value Objects** | ❌ KHÔNG | Ví dụ `Money`, `EmailAddress`. Dùng object thật |
| **Collection** | ❌ KHÔNG | Khởi tạo `collect([1, 2, 3])` thật |
| **Eloquent Model chuỗi (Chain)**| ⚠️ HẠN CHẾ | Không mock `User::where()->with()->get()`. Thay bằng In-memory SQLite hoặc Repository pattern. |
+118
View File
@@ -0,0 +1,118 @@
# 04. Naming Conventions & Test Structure
Quy chuẩn đặt tên, ánh xạ thư mục và tổ chức mã nguồn kiểm thử trong Laravel để đảm bảo tính nhất quán và rõ ràng cho cả lập trình viên lẫn công cụ CI/CD.
---
## 1. Ánh xạ Cấu trúc Thư mục (Directory Mapping)
Cấu trúc thư mục của file Test trong `tests/Unit/` phải **phản chiếu 1-1 (Mirror)** cấu trúc của class nguồn trong `app/`:
```text
app/
├── Actions/
│ └── User/
│ └── CreateUserAction.php
├── Services/
│ └── Payment/
│ └── StripePaymentService.php
├── Rules/
│ └── ValidTaxCodeRule.php
└── Jobs/
└── ProcessPodcastJob.php
tests/
└── Unit/
├── Actions/
│ └── User/
│ └── CreateUserActionTest.php
├── Services/
│ └── Payment/
│ └── StripePaymentServiceTest.php
├── Rules/
│ └── ValidTaxCodeRuleTest.php
└── Jobs/
└── ProcessPodcastJobTest.php
```
---
## 2. Quy ước Đặt tên File & Class Test
- **Quy tắc**: Tên file test = `<TênClassNguồn>Test.php`.
- **Namespace**: Tương ứng theo namespace của class nguồn nhưng bắt đầu bằng `Tests\Unit\...`.
- **Ví dụ**:
- Class nguồn: `App\Services\InvoiceService`
- Class test: `Tests\Unit\Services\InvoiceServiceTest`
---
## 3. Quy ước Đặt tên Phương thức Test (Test Method Naming)
Mỗi tên method test phải đọc như một câu hoàn chỉnh, thể hiện rõ **Hành vi (Action)** và **Kết quả kỳ vọng (Expected Outcome)** trong một **Điều kiện cụ thể (Context)**.
### Cú pháp chuẩn PHPUnit:
Dùng định dạng `snake_case` bắt đầu bằng `test_it_...` hoặc `it_...` (nếu có attribute `#[Test]`):
1. **Happy Path (Thành công)**:
- `it_creates_order_and_dispatches_event_when_inventory_is_available()`
- `it_returns_discounted_amount_for_vip_customer()`
2. **Failure / Edge Case (Thất bại / Biên)**:
- `it_throws_out_of_stock_exception_when_inventory_is_zero()`
- `it_fails_validation_when_email_domain_is_blacklisted()`
3. **Authorization (Phân quyền)**:
- `it_denies_access_when_user_is_not_admin()`
### Cú pháp chuẩn Pest PHP:
Dùng chuỗi mô tả tự nhiên trong hàm `it()` hoặc `test()`:
```php
describe('InvoiceService', function () {
it('creates invoice and dispatches event when payment succeeds', function () { ... });
it('throws insufficient funds exception when balance is too low', function () { ... });
it('denies access when user is not admin', function () { ... });
});
```
---
## 4. Tổ chức Data Providers
Khi một method cần test với nhiều bộ dữ liệu (Input -> Output) khác nhau:
### Quy tắc PHPUnit:
- Đặt tên provider: `<tên_logic_test>_provider` hoặc `provide_<tên_logic_test>`.
- Khai báo là `public static function`.
- Luôn đặt **Key dạng chuỗi có ý nghĩa** cho từng data set (để khi test fail, CLI báo rõ data set nào bị hỏng thay vì chỉ báo index số `0, 1, 2`):
```php
#[Test]
#[DataProvider('emailValidationProvider')]
public function it_validates_email_format(string $email, bool $expectedResult): void
{
$validator = new EmailValidator();
$this->assertSame($expectedResult, $validator->isValid($email));
}
public static function emailValidationProvider(): array
{
return [
'standard email' => ['user@example.com', true],
'email with sub-domain' => ['user@corp.example.co.uk', true],
'missing at symbol' => ['userexample.com', false],
'missing domain' => ['user@', false],
'whitespace in email' => ['user @example.com', false],
];
}
```
### Quy tắc Pest PHP:
```php
it('validates email format', function (string $email, bool $expectedResult) {
$validator = new EmailValidator();
expect($validator->isValid($email))->toBe($expectedResult);
})->with([
'standard email' => ['user@example.com', true],
'email with sub-domain' => ['user@corp.example.co.uk', true],
'missing at symbol' => ['userexample.com', false],
]);
```
+118
View File
@@ -0,0 +1,118 @@
# 05. 12 Anti-Patterns Cần Tránh Khi Viết Laravel Unit Test
Danh sách 12 sai lầm nghiêm trọng mà các công cụ AI và lập trình viên thường mắc phải khi viết Unit Test trong Laravel, kèm theo nguyên nhân và giải pháp chuẩn hóa.
---
### ❌ Anti-Pattern 1: Mock Chuỗi Phương Thức Eloquent (Mocking Query Chains)
- **Sai lầm**: Cố gắng mock hàng loạt phương thức tĩnh hoặc chuỗi query của Eloquent:
```php
// RẤT XẤU VÀ DỄ VỠ (BRITTLE)
$builder = Mockery::mock(Builder::class);
$builder->shouldReceive('where')->with('status', 'active')->andReturnSelf();
$builder->shouldReceive('orderBy')->with('created_at')->andReturnSelf();
$builder->shouldReceive('get')->andReturn(collect([$user]));
```
- **Hậu quả**: Khi chỉ cần đổi thứ tự `where` hoặc thêm một scope nhỏ trong code nguồn, test sẽ đỏ lòm dù nghiệp vụ hoàn toàn đúng.
- **Giải pháp chuẩn**:
1. Nếu là Unit Test thuần: Trừu tượng hóa truy vấn vào **Repository** hoặc **Query Action** và mock Repository Interface đó.
2. Nếu bắt buộc test câu lệnh Query: Chuyển sang dùng **SQLite In-memory** và chạy query thật trên database test.
---
### ❌ Anti-Pattern 2: Lạm Dụng Database Thật Trong Unit Test
- **Sai lầm**: Gọi `use RefreshDatabase;` và chèn hàng chục bản ghi Factory vào database cho một bài test chỉ kiểm tra logic tính toán toán học.
- **Hậu quả**: Thời gian chạy test tăng vọt từ 5ms lên 300ms. Khi có 1,000 test cases, bộ test sẽ mất 5 phút thay vì 5 giây.
- **Giải pháp chuẩn**: Chỉ dùng Database khi đối tượng kiểm thử là Repository, Eloquent Scope hoặc Custom Cast. Nếu là Service/Action/Rule, hãy mock data hoặc khởi tạo in-memory model bằng `$user = new User(['id' => 1])`.
---
### ❌ Anti-Pattern 3: Over-Mocking (Mock Cả Những Thứ Không Cần Thiết)
- **Sai lầm**: Tạo mock cho cả DTO, Value Object, Collection, Carbon date object:
```php
// SAI: Mock một DTO đơn giản
$dto = Mockery::mock(UserRegistrationDTO::class);
$dto->shouldReceive('getEmail')->andReturn('test@example.com');
```
- **Giải pháp chuẩn**: Các đối tượng dữ liệu thuần (Data Holders / Value Objects) **phải được khởi tạo thật 100%**:
```php
// ĐÚNG: Khởi tạo DTO thật
$dto = new UserRegistrationDTO(email: 'test@example.com', name: 'John');
```
---
### ❌ Anti-Pattern 4: Assertions Rỗng / Vô Nghĩa
- **Sai lầm**:
```php
$service->doSomething();
$this->assertTrue(true); // Chỉ để pass test mà không xác thực kết quả
```
Hoặc chỉ kiểm tra kiểu dữ liệu `$this->assertIsArray($result);` mà không kiểm tra cấu trúc và giá trị bên trong.
- **Giải pháp chuẩn**: Luôn assert giá trị chính xác (strict assertion):
```php
$this->assertSame(expected: 250_000, actual: $result->finalAmount);
```
---
### ❌ Anti-Pattern 5: Bỏ Quên Nhánh Exception & Edge Cases (Chỉ Test Happy Path)
- **Sai lầm**: Chỉ viết 1 test case duy nhất khi mọi thứ hoàn hảo. Bỏ qua:
- Input bị `null` hoặc chuỗi rỗng `""`.
- External API trả về HTTP 401/403/500 hoặc Timeout.
- Số âm, số 0 hoặc số nguyên quá lớn.
- **Giải pháp chuẩn**: Luôn áp dụng tỷ lệ tối thiểu: 1 Happy Path + 2 Edge/Negative Cases + 1 Exception Case cho mỗi phương thức có logic phân nhánh.
---
### ❌ Anti-Pattern 6: Rò Rỉ Trạng Thái Giữa Các Test (State Leaks)
- **Sai lầm**: Gán biến static, set mock Facade hoặc time travel bằng `Carbon::setTestNow()` trong một test case nhưng quên reset lại sau đó.
- **Hậu quả**: Test case đứng sau bị fail ngẫu nhiên dù code của nó không sai (Flaky tests).
- **Giải pháp chuẩn**: Luôn reset trong `tearDown()` hoặc dùng method `Carbon::setTestNow()` không tham số để trả về thời gian thực.
---
### ❌ Anti-Pattern 7: Dùng Sleep Trong Unit Test
- **Sai lầm**: Viết `sleep(2);` để chờ queue xử lý hoặc chờ token hết hạn.
- **Giải pháp chuẩn**: Dùng `Carbon::setTestNow()` để nhảy thời gian tức thì, hoặc dùng `Queue::fake()` để đồng bộ execution.
---
### ❌ Anti-Pattern 8: Kiểm Tra Chi Tiết Thực Thi (Testing Implementation Details) Thay Vì Hành Vi (Behavior)
- **Sai lầm**: Kiểm tra xem hàm private được gọi thế nào, biến cục bộ bên trong hàm đổi tên gì.
- **Giải pháp chuẩn**: Chỉ kiểm tra **Đầu vào (Input) -> Đầu ra (Output) hoặc Tác động ngoại vi công khai (Public Side-effects)**. Cho phép lập trình viên thoải mái refactor bên trong hàm mà không làm hỏng test.
---
### ❌ Anti-Pattern 9: Quên Assert Xác Thực Số Lần Gọi Mock (Missing Expectation Verification)
- **Sai lầm**: Thiết lập mock nhưng không ghi rõ số lần:
```php
$mailer->shouldReceive('send'); // Không biết gọi bao nhiêu lần, gọi 0 lần hay 100 lần vẫn pass!
```
- **Giải pháp chuẩn**: Luôn chỉ định rõ ràng: `->once()`, `->twice()`, `->never()`, hoặc `->times(3)`.
---
### ❌ Anti-Pattern 10: Khởi Tạo Trực Tiếp Bằng Từ Khóa `new` Trong Code Nguồn (Hard Dependency)
- **Tình huống**: Code nguồn viết:
```php
public function pay() {
$stripe = new StripeClient('secret-key'); // Không thể mock từ ngoài vào
$stripe->charge();
}
```
- **Cách xử lý**:
- **Khuyến nghị**: Refactor đưa `StripeClient` vào Constructor Injection hoặc Service Provider.
- **Nếu bắt buộc test mà không được sửa code nguồn**: Dùng Mockery Overload `Mockery::mock('overload:' . StripeClient::class)`.
---
### ❌ Anti-Pattern 11: Nhầm Lẫn Giữa Strict Assertion (`assertSame`) và Loose Assertion (`assertEquals`)
- **Sai lầm**: `$this->assertEquals(0, 'any_string');` -> Trong PHP cũ có thể trả về `true` do ép kiểu lỏng lẻo.
- **Giải pháp chuẩn**: Luôn ưu tiên dùng `$this->assertSame()` hoặc Pest `expect()->toBe()` để kiểm tra cả kiểu dữ liệu lẫn giá trị (`===`).
---
### ❌ Anti-Pattern 12: Test Quá Dài và Làm Quá Nhiều Thứ (God Test Method)
- **Sai lầm**: Một test method dài 200 dòng, vừa tạo user, vừa cập nhật giỏ hàng, vừa thanh toán, vừa kiểm tra lịch sử.
- **Giải pháp chuẩn**: Tách nhỏ thành từng test case độc lập. Mỗi test method chỉ nên dài từ 10 đến 30 dòng.
+193
View File
@@ -0,0 +1,193 @@
# 06. Hướng Dẫn Nâng Cao: PHP 8.4+ & Laravel 12 / 13 (Latest & Future-Proof)
Tài liệu này chuẩn bị đầy đủ các kỹ thuật kiểm thử cho các phiên bản PHP và Laravel mới nhất, bao gồm **PHP 8.4**, **Laravel 12 & 13**, **PHPUnit 11/12** và **Pest PHP v3**.
---
## 1. Kiểm Thử Các Tính Năng Mới Của PHP 8.4
PHP 8.4 giới thiệu nhiều thay đổi mang tính cách mạng trong cú pháp lập trình hướng đối tượng. Unit Test cần thích ứng chính xác:
### A. Property Hooks (`get` / `set`)
Thay vì viết getter/setter truyền thống hoặc Eloquent Accessor cũ, PHP 8.4 cho phép định nghĩa hooks ngay trên thuộc tính:
```php
// DTO hoặc Model trong PHP 8.4
class UserProfile
{
public string $firstName;
public string $lastName;
// Property Hook tự động tính toán
public string $fullName {
get => trim("{$this->firstName} {$this->lastName}");
}
// Property Hook kiểm soát gán giá trị
public string $email {
set {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Email không hợp lệ');
}
$this->email = strtolower($value);
}
}
}
```
#### Cách viết Unit Test:
```php
#[Test]
public function it_calculates_full_name_via_property_hook(): void
{
$profile = new UserProfile();
$profile->firstName = 'Thien';
$profile->lastName = 'Phan';
// Kiểm thử get hook
$this->assertSame('Thien Phan', $profile->fullName);
}
#[Test]
public function it_throws_exception_when_setting_invalid_email_hook(): void
{
$profile = new UserProfile();
$this->expectException(\InvalidArgumentException::class);
// Kiểm thử set hook
$profile->email = 'invalid-email-format';
}
```
### B. Asymmetric Visibility (`public private(set)`)
PHP 8.4 cho phép thuộc tính có quyền đọc công khai nhưng chỉ được sửa từ bên trong class:
```php
class BankAccount
{
// Đọc public từ ngoài, nhưng chỉ private method được set
public private(set) int $balance = 0;
public function deposit(int $amount): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Số tiền nạp phải > 0');
}
$this->balance += $amount;
}
}
```
#### Cách viết Unit Test:
```php
#[Test]
public function it_updates_balance_via_public_api_and_allows_public_reading(): void
{
$account = new BankAccount();
// Đọc trạng thái ban đầu
$this->assertSame(0, $account->balance);
// Kích hoạt method hợp lệ
$account->deposit(500_000);
$this->assertSame(500_000, $account->balance);
// Cố tình ghi đè từ ngoài sẽ gây Error (PHP Fatal Error)
// Unit test tập trung xác nhận method nội bộ thay đổi đúng giá trị
}
```
### C. Hàm Array Mới (`array_find`, `array_any`, `array_all`)
Kiểm thử các business logic sử dụng các hàm tiện ích mảng mới của PHP 8.4.
---
## 2. Kiểm Thử Kiến Trúc Laravel Hiện Đại (Laravel 11, 12, 13)
### A. Concurrency API (`Concurrency::fake()`)
Từ Laravel 11 và tiếp tục hoàn thiện trên Laravel 12/13, Laravel cung cấp Facade `Concurrency` để chạy các tác vụ song song:
```php
// Trong Service
public function fetchDashboardData(): array
{
return Concurrency::run([
fn () => $this->userService->getStats(),
fn () => $this->orderService->getRevenue(),
]);
}
```
#### Cách viết Unit Test:
```php
use Illuminate\Support\Facades\Concurrency;
#[Test]
public function it_executes_concurrent_tasks_correctly(): void
{
// Giả lập Concurrency để chạy tuần tự trong lúc test
Concurrency::fake();
$dashboardData = $dashboardService->fetchDashboardData();
$this->assertCount(2, $dashboardData);
}
```
### B. Context API (`Context::fake()`)
Laravel Context cho phép theo dõi metadata xuyên suốt vòng đời request và background jobs:
```php
use Illuminate\Support\Facades\Context;
#[Test]
public function it_attaches_trace_id_to_context(): void
{
$middleware = new TraceContextMiddleware();
$request = Request::create('/api/orders', 'GET');
$request->headers->set('X-Trace-ID', 'trace-xyz-123');
$middleware->handle($request, fn () => response('OK'));
$this->assertSame('trace-xyz-123', Context::get('trace_id'));
}
```
---
## 3. Pest PHP v3: Architecture Testing (`arch()`)
Trong các dự án Laravel 11/12/13 hiện đại dùng Pest v3, ngoài việc test từng method, chúng ta có thể viết **Architectural Tests** để kiểm soát toàn bộ cấu trúc code:
```php
// tests/Unit/ArchitectureTest.php
describe('Architecture Rules', function () {
test('Controllers do not directly use Models (must use Services/Actions)')
->expect('App\Http\Controllers')
->not->toUse('App\Models')
->ignoring('App\Http\Controllers\Auth');
test('Services must be readonly or final')
->expect('App\Services')
->toBeFinal();
test('All Value Objects must be immutable')
->expect('App\ValueObjects')
->toBeReadonly();
test('No debugging functions left in code')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
});
```
---
## 4. Tương Thích PHPUnit 11 & 12
1. **Loại bỏ hoàn toàn DocBlock**: PHPUnit 11+ deprecate và loại bỏ chú thích dạng `@test`, `@dataProvider`. Bắt buộc 100% sử dụng:
- `#[PHPUnit\Framework\Attributes\Test]`
- `#[PHPUnit\Framework\Attributes\DataProvider('providerMethod')]`
- `#[PHPUnit\Framework\Attributes\TestDox('Mô tả tiếng Việt dễ đọc')]`
2. **Event-driven Internal System**: PHPUnit 11 quản lý vòng đời test hoàn toàn bằng sự kiện, giúp execution nhanh hơn và đo lường chính xác hơn.
+123
View File
@@ -0,0 +1,123 @@
# 07. Troubleshooting & Debugging Guide for Laravel Unit Tests
Cẩm nang chẩn đoán và khắc phục 10 lỗi phổ biến nhất khi viết và chạy Unit Test trong các dự án Laravel.
---
## 1. Lỗi: `Mockery\Exceptions\InvalidCountException: Method ... was expected to be called 1 times but called 0 times`
- **Nguyên nhân**:
- Bạn khai báo `->once()` cho một method mock, nhưng luồng code trong hàm thực tế bị rẽ nhánh sớm (early return) hoặc ném exception trước khi chạm tới method đó.
- Tên method trong `shouldReceive('tên_hàm')` bị sai chính tả.
- **Cách khắc phục**:
1. Kiểm tra điều kiện `if/else` trước lệnh gọi: Đảm bảo dữ liệu đầu vào trong `// Arrange` thỏa mãn để code đi vào nhánh gọi mock.
2. Kiểm tra log hoặc dùng `$this->withoutExceptionHandling()` (nếu trong Feature test) để xem có exception nào bị nuốt không.
---
## 2. Lỗi: `BadMethodCallException: Method Mockery_...::someMethod() does not exist on this mock object`
- **Nguyên nhân**: Code nguồn gọi phương thức `someMethod()`, nhưng bạn chưa định nghĩa nó trong mock (`shouldReceive('someMethod')`).
- **Cách khắc phục**:
- Khai báo bổ sung kỳ vọng cho method đó, hoặc nếu method đó không ảnh hưởng tới kết quả bài test, dùng `->shouldIgnoreMissing()` hoặc tạo `Spy` thay vì `Mock`:
```php
$loggerMock = Mockery::spy(LoggerInterface::class);
```
---
## 3. Lỗi: `Target [App\Contracts\PaymentInterface] is not instantiable while building [App\Services\CheckoutService]`
- **Nguyên nhân**: Code nguồn cố gắng resolve Interface từ Service Container nhưng Interface đó chưa được bind trong `AppServiceProvider` trong môi trường test.
- **Cách khắc phục**:
- Trong Unit Test: **Khởi tạo trực tiếp bằng từ khóa `new`** và truyền mock vào constructor thay vì gọi `app(CheckoutService::class)`:
```php
// ĐÚNG CHO UNIT TEST:
$service = new CheckoutService($mockPayment);
// NẾU CẦN RESOLVE TỪ CONTAINER:
$this->app->instance(PaymentInterface::class, $mockPayment);
$service = $this->app->make(CheckoutService::class);
```
---
## 4. Lỗi: `SQLSTATE[HY000]: General error: 1 no such table: users`
- **Nguyên nhân**: Test đang chạy trên SQLite `:memory:` nhưng chưa chạy migration, hoặc trong Unit test bạn vô tình gọi Eloquent Model thật mà không dùng trait `RefreshDatabase`.
- **Cách khắc phục**:
1. **Nếu là Unit Test thuần**: Bạn không nên chạm vào DB! Hãy mock Repository hoặc khởi tạo in-memory model `$user = new User(['id' => 1])`.
2. **Nếu là Repository/Query Test bắt buộc cần DB**: Thêm trait `use RefreshDatabase;` vào đầu class test.
---
## 5. Lỗi: `SQLSTATE[HY000]: General error: 1 no such function: JSON_CONTAINS / DATE_FORMAT`
- **Nguyên nhân**: Code nguồn sử dụng hàm SQL đặc thù của MySQL, nhưng môi trường test chạy trên SQLite In-Memory.
- **Cách khắc phục**:
- Đăng ký hàm giả lập trong `setUp()`:
```php
if (DB::connection() instanceof \Illuminate\Database\SQLiteConnection) {
DB::connection()->getPdo()->sqliteCreateFunction('JSON_CONTAINS', function ($json, $val) {
$data = json_decode($json, true) ?? [];
return in_array(trim($val, '"'), $data) ? 1 : 0;
}, 2);
}
```
- Hoặc chuyển kết nối test trong `phpunit.xml` sang một database MySQL test độc lập trên Docker.
---
## 6. Lỗi: `Illuminate\Database\QueryException: Integrity constraint violation: 19 FOREIGN KEY constraint failed`
- **Nguyên nhân**: SQLite bật kiểm tra khóa ngoại và bản ghi cha không tồn tại.
- **Cách khắc phục**:
- Tạm thời tắt kiểm tra foreign key trong test setup:
```php
use Illuminate\Support\Facades\Schema;
protected function setUp(): void
{
parent::setUp();
Schema::disableForeignKeyConstraints();
}
```
- Hoặc tạo các Model quan hệ bằng Factory: `User::factory()->has(Post::factory())->create();`.
---
## 7. Lỗi: Flaky Test (Chạy riêng lẻ thì Pass, chạy chung cả bộ test thì Fail)
- **Nguyên nhân**: **Rò rỉ trạng thái (State Leakage)** giữa các bài test:
- Dùng `Carbon::setTestNow()` nhưng quên reset.
- Sử dụng static variable hoặc singleton service lưu cache trong memory.
- Mock Facade tĩnh chưa được giải phóng.
- **Cách khắc phục**:
- Đảm bảo trong `tearDown()` có gọi:
```php
protected function tearDown(): void
{
Carbon::setTestNow(); // Reset thời gian thực
Mockery::close();
parent::tearDown();
}
```
---
## 8. Lỗi: `Error: Typed property ... must not be accessed before initialization`
- **Nguyên nhân**: Thuộc tính khai báo type (ví dụ `private UserService $service;`) nhưng trong method `setUp()` bị sót logic gán giá trị, hoặc test method chạy trước khi `setUp()` hoàn tất.
- **Cách khắc phục**: Kiểm tra phương thức `setUp()` và đảm bảo mọi typed property đều được khởi tạo instance trước khi method test chạy.
---
## 9. Lỗi: `Class "App\Http\Controllers\Controller" not found`
- **Nguyên nhân**: Laravel 11 đã loại bỏ base `Controller.php` mặc định nếu dự án tạo mới tối giản, hoặc namespace bị sai lệch.
- **Cách khắc phục**: Kiểm tra class controller đích kế thừa class nào để import chính xác.
---
## 10. Lỗi: `Session store not set on request`
- **Nguyên nhân**: Code nguồn gọi `request()->session()` hoặc `$request->session()->get(...)` trong Unit test khi chưa có middleware session.
- **Cách khắc phục**:
- Gắn session giả lập vào Request:
```php
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
$session = new Store('test_session', new ArraySessionHandler(10));
$request->setLaravelSession($session);
```
+171
View File
@@ -0,0 +1,171 @@
# 08. Quản Lý Dữ Liệu Kiểm Thử: Factories, States & Test Builders (Pro Level)
Trong các dự án quy mô lớn, việc chuẩn bị dữ liệu kiểm thử (Test Fixtures / Test Data) chiếm tới 50% thời gian viết test. Tài liệu này hướng dẫn các kỹ thuật chuyên sâu cấp Senior/Principal để chuẩn bị dữ liệu siêu tốc, sạch và tái sử dụng cao.
---
## 1. Bí Mật Tốc Độ: `make()` vs `create()` Trong Model Factory
Trong Laravel Model Factory có 2 phương thức sinh dữ liệu chính:
| Phương thức | Hành vi | Tốc độ | Khi nào nên dùng? |
| :--- | :--- | :---: | :--- |
| `Model::factory()->create()` | Sinh model + **LƯU VÀO DATABASE** (`INSERT INTO`) | Chậm (~20-50ms) | **Feature Test** (Khi cần database thật để join/query) |
| `Model::factory()->make()` | Sinh model **TRONG BỘ NHỚ (RAM)**, KHÔNG chạm DB | Siêu nhanh (< 1ms) | **Unit Test** (Truyền model vào Service/Action/Policy) |
### 💡 Quy Tắc Vàng Cho Unit Test:
> **LUÔN DÙNG `make()` HOẶC `makeOne()` CHO UNIT TEST**. Trừ khi class bạn đang test bắt buộc phải query DB (như Repository), tuyệt đối không dùng `create()`.
```php
// ❌ CHẬM VÀ SAI NGUYÊN TẮC UNIT TEST:
$user = User::factory()->create(); // Chạy INSERT vào SQLite/MySQL
// ✅ CHUẨN PRO: Khởi tạo hoàn toàn trong RAM:
$user = User::factory()->make([
'id' => 1, // Tự gán ID giả lập nếu logic cần so sánh $user->id
]);
```
---
## 2. Tận Dụng Factory States Cho Các Kịch Bản Biên
Thay vì gán thủ công từng thuộc tính trong test method, hãy định nghĩa các **Factory States** mang ý nghĩa nghiệp vụ:
```php
// Trong database/factories/UserFactory.php
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'is_admin' => true,
'role' => 'super_admin',
]);
}
public function banned(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'banned',
'banned_at' => now()->subDay(),
]);
}
```
### Sử dụng trong Unit Test:
```php
#[Test]
public function it_denies_access_for_banned_user(): void
{
// Cực kỳ trực quan, dễ đọc như văn xuôi
$bannedUser = User::factory()->banned()->make();
$this->assertFalse($this->policy->login($bannedUser));
}
```
---
## 3. Object Mother Pattern (Bộ Dựng Dữ Liệu Tập Trung)
Khi dự án có nhiều DTO hoặc Value Object phức tạp không dùng Eloquent Factory, hãy áp dụng pattern **Object Mother**:
```php
// tests/Fixtures/OrderDataMother.php
namespace Tests\Fixtures;
use App\DTOs\CreateOrderDTO;
use App\ValueObjects\Money;
class OrderDataMother
{
public static function standardOrder(int $total = 100_000): CreateOrderDTO
{
return new CreateOrderDTO(
customerId: 1,
items: [
['sku' => 'ITEM-01', 'qty' => 1, 'price' => $total]
],
totalAmount: new Money($total),
shippingAddress: '123 Nguyen Hue, Quan 1, TP.HCM'
);
}
public static function zeroAmountOrder(): CreateOrderDTO
{
return self::standardOrder(total: 0);
}
}
```
### Sử dụng trong Test:
```php
#[Test]
public function it_rejects_order_with_zero_amount(): void
{
$invalidOrder = OrderDataMother::zeroAmountOrder();
$this->expectException(InvalidOrderException::class);
$this->orderService->placeOrder($invalidOrder);
}
```
---
## 4. DTO Test Builder Pattern (Fluent API)
Khi một DTO có 10-15 trường và mỗi test case chỉ muốn thay đổi 1-2 trường:
```php
// tests/Builders/UserDTOBuilder.php
namespace Tests\Builders;
use App\DTOs\UserRegistrationDTO;
class UserDTOBuilder
{
private string $email = 'default@smartgift.vn';
private string $phone = '0912345678';
private string $name = 'Nguyen Van A';
private int $age = 25;
public static function aUser(): self
{
return new self();
}
public function withEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function underAge(int $age = 16): self
{
$this->age = $age;
return $this;
}
public function build(): UserRegistrationDTO
{
return new UserRegistrationDTO(
name: $this->name,
email: $this->email,
phone: $this->phone,
age: $this->age
);
}
}
```
### Sử dụng trong Test:
```php
#[Test]
public function it_throws_exception_when_user_is_under_18(): void
{
// Chỉ thay đổi đúng trường cần test, các trường khác giữ giá trị mặc định hợp lệ
$dto = UserDTOBuilder::aUser()->underAge(15)->build();
$this->expectException(UnderAgeRegistrationException::class);
$this->registrationAction->execute($dto);
}
```
+194
View File
@@ -0,0 +1,194 @@
# 09. Các Tình Huống Kỹ Thuật Đặc Biệt (Advanced Edge Cases)
Tài liệu hướng dẫn xử lý 7 tình huống kỹ thuật phức tạp thường gặp trong các dự án Laravel thực tế mà lập trình viên và AI hay bị lúng túng.
---
## 1. Mock `DB::transaction()` Trong Unit Test (Không Cần Database Thật)
Nhiều Service bọc logic thanh toán/tạo đơn trong `DB::transaction(function () { ... })`. Khi viết Unit Test cô lập (không kết nối DB), nếu không mock lệnh này, code sẽ văng lỗi connection.
### ✅ Giải pháp chuẩn:
Sử dụng `DB::shouldReceive('transaction')` và thực thi ngay closure truyền vào:
```php
use Illuminate\Support\Facades\DB;
#[Test]
public function it_executes_logic_inside_database_transaction(): void
{
// Giả lập DB::transaction để chạy trực tiếp callback bên trong
DB::shouldReceive('transaction')
->once()
->andReturnUsing(function (callable $callback) {
return $callback();
});
// Act
$result = $this->orderService->createOrder($orderDTO);
// Assert
$this->assertNotNull($result);
}
```
---
## 2. Kiểm Thử Trait Trong Sự Cô Lập (Testing Traits in Isolation)
Khi logic nghiệp vụ nằm trong một Trait (ví dụ: `GeneratesInvoiceCodeTrait`, `HasSlugTrait`), làm sao để test mà không cần khởi tạo một Model hoặc Service cụ thể?
### ✅ Giải pháp chuẩn: Dùng Anonymous Class
```php
#[Test]
public function it_generates_unique_invoice_code_from_trait(): void
{
// Tạo một anonymous class sử dụng Trait cần test
$instance = new class {
use GeneratesInvoiceCodeTrait;
// Bổ sung các method/property trừu tượng nếu trait yêu cầu
public function prefix(): string {
return 'INV';
}
};
$code = $instance->generateInvoiceCode(123);
$this->assertMatchesRegularExpression('/^INV-123-[A-Z0-9]{4}$/', $code);
}
```
---
## 3. Thao Tác Cấu Hình (`config()`) & Biến Môi Trường Trong Test
Nhiều class đọc cấu hình qua `config('services.stripe.secret')`.
### ✅ Quy tắc chuẩn:
- **KHÔNG BAO GIỜ** sửa file `.env` thật trong lúc test.
- Sử dụng `Config::set()` trực tiếp trong phần `// Arrange`:
```php
use Illuminate\Support\Facades\Config;
#[Test]
public function it_uses_configured_vat_rate(): void
{
// Gán tạm thời cấu hình cho bài test này
Config::set('finance.vat_rate', 0.08); // 8%
$tax = $this->taxService->calculate(100_000);
$this->assertSame(8_000.0, $tax);
}
```
---
## 4. Kiểm Thử Upload File & Multi-part Request (`UploadedFile::fake()`)
Kiểm thử các service xử lý file ảnh, tài liệu PDF, file CSV mà không cần file thật trên ổ cứng:
```php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
#[Test]
public function it_uploads_and_validates_pdf_invoice(): void
{
Storage::fake('documents');
// Tạo file PDF giả lập trong bộ nhớ
$fakePdf = UploadedFile::fake()->create('invoice.pdf', 500, 'application/pdf');
$uploadedPath = $this->documentService->storeInvoice($fakePdf);
Storage::disk('documents')->assertExists($uploadedPath);
}
#[Test]
public function it_rejects_file_exceeding_max_size(): void
{
// Giả lập file ảnh vượt quá 10MB (11,000 KB)
$heavyImage = UploadedFile::fake()->image('huge.jpg')->size(11_000);
$this->expectException(FileTooLargeException::class);
$this->avatarService->upload($heavyImage);
}
```
---
## 5. Kiểm Thử Lỗi Serialization Của Queueable Jobs (`SerializesModels`)
Trong Laravel, khi truyền một Eloquent Model vào Job, Laravel sẽ serialize model bằng ID của nó. Nếu truyền một Model chưa được lưu vào DB (ID = null), Job sẽ crash trong worker thực tế.
### ✅ Cách kiểm tra độ an toàn của Job Payload:
```php
#[Test]
public function it_can_be_serialized_and_unserialized_safely(): void
{
$user = User::factory()->make(['id' => 101]);
$job = new SendWelcomeEmailJob($user);
// Thử serialize và unserialize giống hệt cơ chế của Queue Worker
$serialized = serialize($job);
$unserializedJob = unserialize($serialized);
$this->assertInstanceOf(SendWelcomeEmailJob::class, $unserializedJob);
$this->assertSame(101, $unserializedJob->user->id);
}
```
---
## 6. Xử Lý Giả Lập Session & Authentication Trong Unit Test Thuần
Khi class nguồn gọi `auth()->user()`, `auth()->id()` hoặc `session()->get()`:
```php
use Illuminate\Support\Facades\Auth;
use App\Models\User;
#[Test]
public function it_retrieves_data_for_authenticated_user(): void
{
// Arrange: Giả lập user đã đăng nhập vào Auth Facade
$mockUser = new User(['id' => 99, 'name' => 'Thien Phan']);
Auth::shouldReceive('user')->andReturn($mockUser);
Auth::shouldReceive('id')->andReturn(99);
Auth::shouldReceive('check')->andReturnTrue();
// Act
$result = $this->userProfileService->getCurrentUserProfile();
// Assert
$this->assertSame('Thien Phan', $result->name);
}
```
---
## 7. Kiểm Thử Sự Kiện Đa Kênh & Tránh Cascading Events
Khi một hành động kích hoạt Event, và Event đó lại kích hoạt nhiều Listener khác có thể ghi dữ liệu hoặc gửi mail:
```php
use Illuminate\Support\Facades\Event;
use App\Events\UserRegisteredEvent;
#[Test]
public function it_dispatches_registered_event_without_firing_listeners(): void
{
// Chặn tất cả Listener chạy ngầm, chỉ xác nhận Event được bắn ra
Event::fake([
UserRegisteredEvent::class
]);
$this->registrationService->register($userData);
// Xác nhận event được dispatch kèm payload chuẩn
Event::assertDispatched(UserRegisteredEvent::class, function ($event) use ($userData) {
return $event->userEmail === $userData['email'];
});
}
```
+207
View File
@@ -0,0 +1,207 @@
# Component Guide: Services & Action Classes
Services và Action classes là trái tim của logic nghiệp vụ trong các ứng dụng Laravel hiện đại. Đây là đối tượng quan trọng nhất cần đạt độ phủ Unit Test cao.
---
## 1. Mục Tiêu & Bản Chất Kiểm Thử
- **Cần test**:
- Xử lý logic nghiệp vụ, tính toán, điều kiện rẽ nhánh (`if/else`, `switch/match`).
- Đảm bảo các dependency (Repository, Payment Gateway, Mailer, Logger) được gọi đúng phương thức với đúng tham số.
- Các ngoại lệ (Exception) được ném ra đúng khi điều kiện tiên quyết không thỏa mãn.
- Giá trị trả về (Return value: DTO, Array, Model, Boolean) khớp với kỳ vọng.
- **Không cần test**:
- Code của framework Laravel (ví dụ: không cần test xem hàm `collect()` có hoạt động hay không).
- Kết nối mạng thật hoặc truy vấn database thật (phải Mock hoàn toàn).
---
## 2. Chiến Lược Giả Lập (Mocking Strategy)
- **Dependencies trong Constructor**: Sử dụng `Mockery::mock(Interface::class)`.
- **Static Facades**: Sử dụng `Event::fake()`, `Queue::fake()`, `Log::shouldReceive()`.
- **DTOs / Input Data**: Khởi tạo DTO thật hoặc mảng thật.
---
## 3. Mã Mẫu Chuẩn: Service Class
### Class Nguồn Cần Test: `App\Services\PaymentService`
```php
namespace App\Services;
use App\Contracts\PaymentGatewayInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;
class PaymentService
{
public function __construct(
private readonly PaymentGatewayInterface $gateway
) {}
public function process(PaymentData $data): string
{
if ($data->amount <= 0) {
throw new \InvalidArgumentException('Số tiền thanh toán phải lớn hơn 0');
}
$result = $this->gateway->charge($data->amount, $data->currency);
if (!$result->isSuccess()) {
throw new PaymentFailedException($result->getErrorMessage());
}
return $result->getTransactionId();
}
}
```
### Test Chuẩn: PHPUnit 10+ (Attribute Style)
```php
namespace Tests\Unit\Services;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use Mockery\MockInterface;
use App\Services\PaymentService;
use App\Contracts\PaymentGatewayInterface;
use App\Contracts\PaymentGatewayResultInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;
class PaymentServiceTest extends TestCase
{
private PaymentGatewayInterface&MockInterface $gatewayMock;
private PaymentService $service;
protected function setUp(): void
{
parent::setUp();
$this->gatewayMock = Mockery::mock(PaymentGatewayInterface::class);
$this->service = new PaymentService($this->gatewayMock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_processes_payment_successfully_and_returns_transaction_id(): void
{
// Arrange
$paymentData = new PaymentData(amount: 150_000, currency: 'VND');
$resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
$resultMock->shouldReceive('isSuccess')->once()->andReturnTrue();
$resultMock->shouldReceive('getTransactionId')->once()->andReturn('TXN_9999');
$this->gatewayMock->shouldReceive('charge')
->once()
->with(150_000, 'VND')
->andReturn($resultMock);
// Act
$transactionId = $this->service->process($paymentData);
// Assert
$this->assertSame('TXN_9999', $transactionId);
}
#[Test]
public function it_throws_invalid_argument_exception_when_amount_is_zero_or_negative(): void
{
// Arrange
$paymentData = new PaymentData(amount: 0, currency: 'VND');
// Assert Expectation
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Số tiền thanh toán phải lớn hơn 0');
// Act
$this->service->process($paymentData);
}
#[Test]
public function it_throws_payment_failed_exception_when_gateway_fails(): void
{
// Arrange
$paymentData = new PaymentData(amount: 50_000, currency: 'VND');
$resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
$resultMock->shouldReceive('isSuccess')->once()->andReturnFalse();
$resultMock->shouldReceive('getErrorMessage')->once()->andReturn('Tài khoản không đủ số dư');
$this->gatewayMock->shouldReceive('charge')
->once()
->with(50_000, 'VND')
->andReturn($resultMock);
// Assert Expectation
$this->expectException(PaymentFailedException::class);
$this->expectExceptionMessage('Tài khoản không đủ số dư');
// Act
$this->service->process($paymentData);
}
}
```
### Test Chuẩn: Pest PHP
```php
use App\Services\PaymentService;
use App\Contracts\PaymentGatewayInterface;
use App\Contracts\PaymentGatewayResultInterface;
use App\DTOs\PaymentData;
use App\Exceptions\PaymentFailedException;
beforeEach(function () {
$this->gatewayMock = Mockery::mock(PaymentGatewayInterface::class);
$this->service = new PaymentService($this->gatewayMock);
});
describe('PaymentService', function () {
it('processes payment successfully and returns transaction id', function () {
$paymentData = new PaymentData(amount: 150_000, currency: 'VND');
$resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
$resultMock->shouldReceive('isSuccess')->once()->andReturnTrue();
$resultMock->shouldReceive('getTransactionId')->once()->andReturn('TXN_9999');
$this->gatewayMock->shouldReceive('charge')
->once()
->with(150_000, 'VND')
->andReturn($resultMock);
$transactionId = $this->service->process($paymentData);
expect($transactionId)->toBe('TXN_9999');
});
it('throws exception when amount is zero or negative', function () {
$paymentData = new PaymentData(amount: -1000, currency: 'VND');
expect(fn () => $this->service->process($paymentData))
->toThrow(\InvalidArgumentException::class, 'Số tiền thanh toán phải lớn hơn 0');
});
it('throws payment failed exception when gateway fails', function () {
$paymentData = new PaymentData(amount: 50_000, currency: 'VND');
$resultMock = Mockery::mock(PaymentGatewayResultInterface::class);
$resultMock->shouldReceive('isSuccess')->once()->andReturnFalse();
$resultMock->shouldReceive('getErrorMessage')->once()->andReturn('Tài khoản không đủ số dư');
$this->gatewayMock->shouldReceive('charge')
->once()
->andReturn($resultMock);
expect(fn () => $this->service->process($paymentData))
->toThrow(PaymentFailedException::class, 'Tài khoản không đủ số dư');
});
});
```
+175
View File
@@ -0,0 +1,175 @@
# Component Guide: Controllers (API & Web)
Controllers đóng vai trò điều hướng yêu cầu (HTTP Request) đến các tầng nghiệp vụ (Service/Action) và trả về phản hồi (Response: JSON, View, Redirect).
---
## 1. Ranh Giới: Unit Test vs Feature Test Cho Controller
- **Khuyến nghị ngành (Best Practice)**: 80% Controller nên được kiểm thử bằng **Feature Test** (sử dụng `$this->getJson()`, `$this->postJson()`) vì Controller gắn chặt với Router, Middleware và Validation Pipeline.
- **Khi nào viết Unit Test cho Controller?**:
- Khi bạn muốn kiểm thử **riêng biệt logic điều hướng** của Controller method mà không cần boot router, middleware hay database.
- Kiểm tra xem Controller có nhận request và gọi đúng Service/Action với đúng tham số hay không.
- Kiểm tra xem Controller có trả về đúng mã HTTP Status và cấu trúc JSON hay không.
---
## 2. Chiến Lược Unit Test Cho Controller
Để Unit Test một Controller một cách cô lập:
1. **Khởi tạo Controller trực tiếp** với các Service/Action Mock trong constructor.
2. **Khởi tạo Request giả lập** bằng `Illuminate\Http\Request::create('/path', 'POST', $data)` thay vì dispatch qua HTTP Kernel.
3. **Thực thi trực tiếp Controller Method**: `$response = $controller->store($request)`.
4. **Assert trên Response object**: `$response->getStatusCode()`, `$response->getData()`.
---
## 3. Mã Mẫu Chuẩn: Controller
### Controller Nguồn: `App\Http\Controllers\Api\UserController`
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class UserController extends Controller
{
public function __construct(
private readonly UserService $userService
) {}
public function show(int $id): JsonResponse
{
$user = $this->userService->findUserById($id);
if (!$user) {
return response()->json([
'status' => 'error',
'message' => 'User not found',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'status' => 'success',
'data' => $user,
], Response::HTTP_OK);
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Http\Controllers\Api;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use Mockery\MockInterface;
use App\Http\Controllers\Api\UserController;
use App\Services\UserService;
use Symfony\Component\HttpFoundation\Response;
class UserControllerTest extends TestCase
{
private UserService&MockInterface $userServiceMock;
private UserController $controller;
protected function setUp(): void
{
parent::setUp();
$this->userServiceMock = Mockery::mock(UserService::class);
$this->controller = new UserController($this->userServiceMock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_returns_user_data_when_user_exists(): void
{
// Arrange
$userData = ['id' => 1, 'name' => 'Thien PT', 'email' => 'thien@example.com'];
$this->userServiceMock->shouldReceive('findUserById')
->once()
->with(1)
->andReturn($userData);
// Act
$response = $this->controller->show(1);
// Assert
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertSame('success', $responseData['status']);
$this->assertSame($userData, $responseData['data']);
}
#[Test]
public function it_returns_404_not_found_when_user_does_not_exist(): void
{
// Arrange
$this->userServiceMock->shouldReceive('findUserById')
->once()
->with(999)
->andReturnNull();
// Act
$response = $this->controller->show(999);
// Assert
$this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode());
$responseData = json_decode($response->getContent(), true);
$this->assertSame('error', $responseData['status']);
$this->assertSame('User not found', $responseData['message']);
}
}
```
### Test Chuẩn: Pest PHP
```php
use App\Http\Controllers\Api\UserController;
use App\Services\UserService;
use Symfony\Component\HttpFoundation\Response;
beforeEach(function () {
$this->userServiceMock = Mockery::mock(UserService::class);
$this->controller = new UserController($this->userServiceMock);
});
describe('UserController (Unit)', function () {
it('returns user data with 200 OK when user exists', function () {
$userData = ['id' => 1, 'name' => 'Thien PT'];
$this->userServiceMock->shouldReceive('findUserById')
->once()
->with(1)
->andReturn($userData);
$response = $this->controller->show(1);
expect($response->getStatusCode())->toBe(Response::HTTP_OK);
$data = json_decode($response->getContent(), true);
expect($data['status'])->toBe('success');
expect($data['data'])->toBe($userData);
});
it('returns 404 Not Found when user does not exist', function () {
$this->userServiceMock->shouldReceive('findUserById')
->once()
->with(999)
->andReturnNull();
$response = $this->controller->show(999);
expect($response->getStatusCode())->toBe(Response::HTTP_NOT_FOUND);
});
});
```
@@ -0,0 +1,144 @@
# Component Guide: Form Requests & Custom Validation Rules
Kiểm thử FormRequest và Custom Validation Rules giúp đảm bảo tính toàn vẹn dữ liệu đầu vào và các ràng buộc phân quyền mà không cần tạo request HTTP thật.
---
## 1. Bản Chất Kiểm Thử
- **FormRequest**:
- Test phương thức `authorize()`: Trả về `true` khi user có quyền, `false` khi không có quyền.
- Test phương thức `rules()`: Xác nhận các field bắt buộc, format email, min/max length, regex,...
- **Custom Rule**:
- Test logic hàm `validate()` (Laravel 10/11 `ValidationRule`) hoặc `passes()` (Laravel 9 `Rule`).
- Đảm bảo thông báo lỗi khi validation fail là chính xác.
---
## 2. Mã Mẫu Chuẩn: Custom Validation Rule
### Rule Nguồn: `App\Rules\PhoneNumberRule` (Laravel 10/11)
```php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class PhoneNumberRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Số điện thoại Việt Nam bắt đầu bằng 0 và có 10 chữ số
if (!is_string($value) || !preg_match('/^0[0-9]{9}$/', $value)) {
$fail('Trường :attribute phải là số điện thoại Việt Nam hợp lệ gồm 10 chữ số.');
}
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Rules;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use App\Rules\PhoneNumberRule;
class PhoneNumberRuleTest extends TestCase
{
private PhoneNumberRule $rule;
protected function setUp(): void
{
parent::setUp();
$this->rule = new PhoneNumberRule();
}
#[Test]
#[DataProvider('validPhoneNumberProvider')]
public function it_passes_with_valid_phone_number(string $validPhone): void
{
$failed = false;
$failCallback = function () use (&$failed) {
$failed = true;
};
$this->rule->validate('phone', $validPhone, $failCallback);
$this->assertFalse($failed, "Phone number '{$validPhone}' should be valid.");
}
#[Test]
#[DataProvider('invalidPhoneNumberProvider')]
public function it_fails_with_invalid_phone_number(mixed $invalidPhone): void
{
$errorMessage = '';
$failCallback = function (string $message) use (&$errorMessage) {
$errorMessage = $message;
};
$this->rule->validate('phone', $invalidPhone, $failCallback);
$this->assertNotEmpty($errorMessage);
$this->assertStringContainsString('số điện thoại Việt Nam hợp lệ', $errorMessage);
}
public static function validPhoneNumberProvider(): array
{
return [
'valid mobile 1' => ['0912345678'],
'valid mobile 2' => ['0868888888'],
'valid mobile 3' => ['0345678901'],
];
}
public static function invalidPhoneNumberProvider(): array
{
return [
'starts with 1' => ['1912345678'],
'too short (9 digits)'=> ['091234567'],
'too long (11 digits)'=> ['09123456789'],
'contains letters' => ['09123abcde'],
'empty string' => [''],
'non-string (array)' => [['0912345678']],
];
}
}
```
---
## 3. Mã Mẫu Chuẩn: FormRequest Validation Rules & Authorize
```php
namespace Tests\Unit\Http\Requests;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Http\Requests\CreatePostRequest;
use Illuminate\Support\Facades\Validator;
class CreatePostRequestTest extends TestCase
{
#[Test]
public function it_validates_required_fields(): void
{
$request = new CreatePostRequest();
$rules = $request->rules();
// 1. Test payload thiếu title
$validator = Validator::make(['content' => 'Sample content'], $rules);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('title', $validator->errors()->toArray());
// 2. Test payload đầy đủ
$validData = [
'title' => 'Tiêu đề bài viết hợp lệ',
'content' => 'Nội dung bài viết đầy đủ chi tiết',
];
$validator = Validator::make($validData, $rules);
$this->assertFalse($validator->fails());
}
}
```
@@ -0,0 +1,100 @@
# Component Guide: Repositories & Custom Query Builders
Kiểm thử tầng Repository, Custom Query Builder và Eloquent Scopes đòi hỏi chiến lược đặc thù vì chúng tương tác trực tiếp với cơ sở dữ liệu.
---
## 1. Bản Chất & Chiến Lược Kiểm Thử
### Tại sao KHÔNG nên Mock Eloquent Query Builder?
Eloquent Builder có hàng trăm phương thức liên kết chuỗi (`where`, `orWhere`, `whereHas`, `orderBy`, `limit`). Việc mock toàn bộ chuỗi này vừa cực kỳ tốn công sức vừa tạo ra các bài test **rất dễ vỡ (brittle)**.
### Giải Pháp Chuẩn: SQLite In-Memory Database
Đối với Repository và Query Builder, hãy sử dụng **SQLite In-Memory** (`:memory:`):
- Chạy cực nhanh (dưới 30ms).
- Sử dụng trait `Illuminate\Foundation\Testing\RefreshDatabase`.
- Cho phép thực thi câu lệnh SQL thật để đảm bảo query trả về đúng dữ liệu, đúng điều kiện lọc và đúng sắp xếp.
---
## 2. Mã Mẫu Chuẩn: Eloquent Scope & Repository
### Class Nguồn: `App\Repositories\UserRepository`
```php
namespace App\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
class UserRepository
{
public function getActiveUsersOlderThan(int $age): Collection
{
return User::query()
->where('is_active', true)
->where('age', '>', $age)
->orderBy('name')
->get();
}
}
```
### Test Chuẩn: PHPUnit 10+ (với SQLite In-Memory)
```php
namespace Tests\Unit\Repositories;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use App\Models\User;
use App\Repositories\UserRepository;
class UserRepositoryTest extends TestCase
{
use RefreshDatabase; // Tự động migrate trên SQLite in-memory
private UserRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new UserRepository();
}
#[Test]
public function it_returns_only_active_users_older_than_specified_age_ordered_by_name(): void
{
// Arrange: Tạo dữ liệu kiểm thử
// Thỏa mãn cả 2 điều kiện
$validUserB = User::factory()->create(['name' => 'Binh', 'is_active' => true, 'age' => 25]);
$validUserA = User::factory()->create(['name' => 'An', 'is_active' => true, 'age' => 30]);
// Không thỏa mãn is_active
User::factory()->create(['name' => 'Cuong', 'is_active' => false, 'age' => 28]);
// Không thỏa mãn age (<= 20)
User::factory()->create(['name' => 'Dung', 'is_active' => true, 'age' => 18]);
// Act
$results = $this->repository->getActiveUsersOlderThan(20);
// Assert
$this->assertCount(2, $results);
$this->assertSame($validUserA->id, $results->first()->id); // Kiểm tra thứ tự A trước B
$this->assertSame($validUserB->id, $results->last()->id);
}
#[Test]
public function it_returns_empty_collection_when_no_user_matches(): void
{
// Arrange
User::factory()->create(['is_active' => false, 'age' => 50]);
// Act
$results = $this->repository->getActiveUsersOlderThan(40);
// Assert
$this->assertTrue($results->isEmpty());
}
}
```
+128
View File
@@ -0,0 +1,128 @@
# Component Guide: Eloquent Models & Custom Casts
Eloquent Model thường chứa logic nghiệp vụ mức trường dữ liệu như Accessors, Mutators, Custom Casts và quan hệ (Relationships).
---
## 1. Bản Chất Kiểm Thử Model
- **Cần test**:
- **Accessors (Casting output)**: Ví dụ tính toán `full_name`, định dạng tiền tệ, mask số thẻ tín dụng.
- **Mutators (Casting input)**: Chuẩn hóa dữ liệu trước khi lưu (viết hoa, mã hóa mật khẩu, slug).
- **Custom Casts**: Lớp kế thừa `CastsAttributes` chuyển đổi JSON/Object <-> Database column.
- **Model Helper Methods**: Các phương thức logic nội bộ (ví dụ: `$user->isVip()`, `$order->canBeCancelled()`).
- **Không cần test**:
- Các tính năng có sẵn của Laravel như `hasMany()`, `belongsTo()` trừ khi muốn xác nhận foreign key cấu hình đúng.
---
## 2. Mã Mẫu Chuẩn: Model Accessor & Logic Method (Không Cần DB)
### Model Nguồn: `App\Models\Order`
```php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['total_amount', 'status', 'created_at'];
public function getFormattedTotalAttribute(): string
{
return number_format($this->total_amount, 0, ',', '.') . ' ₫';
}
public function canBeCancelled(): bool
{
return in_array($this->status, ['pending', 'processing'], true);
}
}
```
### Test Chuẩn: PHPUnit 10+ (Thuần In-Memory - Không Cần Database)
```php
namespace Tests\Unit\Models;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use App\Models\Order;
class OrderTest extends TestCase
{
#[Test]
public function it_formats_total_amount_as_vietnam_currency(): void
{
// Arrange: Khởi tạo model thuần trong bộ nhớ, không gọi ->save()
$order = new Order(['total_amount' => 1500000]);
// Act & Assert
$this->assertSame('1.500.000 ₫', $order->formatted_total);
}
#[Test]
#[DataProvider('cancellationStatusProvider')]
public function it_determines_if_order_can_be_cancelled(string $status, bool $expectedResult): void
{
// Arrange
$order = new Order(['status' => $status]);
// Act & Assert
$this->assertSame($expectedResult, $order->canBeCancelled());
}
public static function cancellationStatusProvider(): array
{
return [
'pending status' => ['pending', true],
'processing status' => ['processing', true],
'shipped status' => ['shipped', false],
'completed status' => ['completed', false],
'cancelled status' => ['cancelled', false],
];
}
}
```
---
## 3. Mã Mẫu Chuẩn: Custom Cast Class
```php
namespace Tests\Unit\Casts;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Casts\MoneyCast;
use App\ValueObjects\Money;
use App\Models\Order;
class MoneyCastTest extends TestCase
{
#[Test]
public function it_casts_integer_cents_from_database_to_money_value_object(): void
{
$cast = new MoneyCast();
$order = new Order();
// Giả lập đọc từ database (100,000 VND)
$money = $cast->get($order, 'amount', 100000, []);
$this->assertInstanceOf(Money::class, $money);
$this->assertSame(100000, $money->getAmount());
}
#[Test]
public function it_casts_money_object_to_integer_for_database_storage(): void
{
$cast = new MoneyCast();
$order = new Order();
$money = new Money(50000);
$storedValue = $cast->set($order, 'amount', $money, []);
$this->assertSame(50000, $storedValue);
}
}
```
+111
View File
@@ -0,0 +1,111 @@
# Component Guide: Queueable Jobs
Jobs xử lý các tác vụ nền không đồng bộ (Asynchronous Background Tasks) như gửi email hàng loạt, xử lý file nặng, đồng bộ dữ liệu.
---
## 1. Hai Mức Độ Kiểm Thử Cho Jobs
1. **Kiểm thử Dispatching (Job được đẩy vào hàng đợi đúng lúc không?)**:
- Dùng `Queue::fake()` trong Service/Controller test.
- Xác nhận `Queue::assertPushed(MyJob::class)`.
2. **Kiểm thử Thực Thi (Unit Test trực tiếp method `handle()` của Job)**:
- Đây chính là đối tượng của Unit Test Job.
- Khởi tạo trực tiếp đối tượng `$job = new ProcessPaymentJob(...)`.
- Inject mock dependencies vào method `handle()`.
- Kiểm tra xem Job có thực thi logic và gọi service đúng không.
- Kiểm tra method `failed(\Throwable $exception)` khi gặp sự cố.
---
## 2. Mã Mẫu Chuẩn: Job Unit Test
### Job Nguồn: `App\Jobs\SendWelcomeEmailJob`
```php
namespace App\Jobs;
use App\Contracts\MailerInterface;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendWelcomeEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public readonly User $user
) {}
public function handle(MailerInterface $mailer): void
{
$mailer->sendWelcome($this->user->email, $this->user->name);
}
public function failed(?\Throwable $exception): void
{
\Illuminate\Support\Facades\Log::error("Failed to send welcome email to user {$this->user->id}");
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Jobs;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use App\Jobs\SendWelcomeEmailJob;
use App\Contracts\MailerInterface;
use App\Models\User;
use Illuminate\Support\Facades\Log;
class SendWelcomeEmailJobTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_sends_welcome_email_when_handled(): void
{
// Arrange
$user = new User(['id' => 10, 'name' => 'Thien', 'email' => 'thien@example.com']);
$mailerMock = Mockery::mock(MailerInterface::class);
$mailerMock->shouldReceive('sendWelcome')
->once()
->with('thien@example.com', 'Thien');
$job = new SendWelcomeEmailJob($user);
// Act
$job->handle($mailerMock);
// Assert: Mockery tự động verify qua expectation ->once()
$this->assertTrue(true);
}
#[Test]
public function it_logs_error_when_job_fails(): void
{
// Arrange
$user = new User(['id' => 10, 'name' => 'Thien', 'email' => 'thien@example.com']);
$job = new SendWelcomeEmailJob($user);
Log::shouldReceive('error')
->once()
->with('Failed to send welcome email to user 10');
// Act
$job->failed(new \RuntimeException('SMTP Connection Timeout'));
}
}
```
@@ -0,0 +1,84 @@
# Component Guide: Events & Listeners
Kiểm thử hệ thống Event-Driven trong Laravel bao gồm kiểm thử cấu trúc dữ liệu Event và kiểm thử đơn vị logic của Event Listener.
---
## 1. Bản Chất Kiểm Thử
- **Event Class**:
- Event trong Laravel thường là DTO chứa dữ liệu sự kiện.
- Test xem Event có khởi tạo đúng properties và broadcast channel (nếu có) hay không.
- **Listener Class**:
- Test phương thức `handle(Event $event)`:
- Khi nhận event, listener gọi những service/notification nào?
- Dữ liệu truyền sang service có được bóc tách chính xác từ event không?
---
## 2. Mã Mẫu Chuẩn: Listener Unit Test
### Listener Nguồn: `App\Listeners\SendOrderConfirmationNotification`
```php
namespace App\Listeners;
use App\Events\OrderPlacedEvent;
use App\Services\NotificationService;
class SendOrderConfirmationNotification
{
public function __construct(
private readonly NotificationService $notificationService
) {}
public function handle(OrderPlacedEvent $event): void
{
$this->notificationService->notifyCustomer(
$event->orderId,
$event->customerEmail,
"Đơn hàng #{$event->orderId} đã được đặt thành công!"
);
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Listeners;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use App\Listeners\SendOrderConfirmationNotification;
use App\Events\OrderPlacedEvent;
use App\Services\NotificationService;
class SendOrderConfirmationNotificationTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_triggers_notification_service_with_event_details(): void
{
// Arrange
$event = new OrderPlacedEvent(orderId: 1001, customerEmail: 'customer@smartgift.vn');
$notificationServiceMock = Mockery::mock(NotificationService::class);
$notificationServiceMock->shouldReceive('notifyCustomer')
->once()
->with(1001, 'customer@smartgift.vn', 'Đơn hàng #1001 đã được đặt thành công!');
$listener = new SendOrderConfirmationNotification($notificationServiceMock);
// Act
$listener->handle($event);
// Assert: Mockery expectation verified
$this->assertTrue(true);
}
}
```
+130
View File
@@ -0,0 +1,130 @@
# Component Guide: Middlewares
Middleware đứng giữa HTTP Request và ứng dụng, có nhiệm vụ kiểm tra header, xác thực token, kiểm tra role, hoặc biến đổi request trước khi chuyển tiếp cho `$next($request)`.
---
## 1. Bản Chất Kiểm Thử Middleware
Một Middleware có hai kịch bản chính cần Unit Test:
1. **Pass-through (Cho phép đi tiếp)**: Điều kiện thỏa mãn -> gọi closure `$next($request)` và trả về response từ `$next`.
2. **Blocked (Chặn lại / Từ chối)**: Điều kiện không thỏa mãn -> trả về `JsonResponse` (401, 403) hoặc `RedirectResponse`, closure `$next` **tuyệt đối không được gọi**.
---
## 2. Mã Mẫu Chuẩn: Middleware Unit Test
### Middleware Nguồn: `App\Http\Middleware\EnsureUserIsAdmin`
```php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || !$user->is_admin) {
return response()->json([
'error' => 'Forbidden. Admin privileges required.'
], Response::HTTP_FORBIDDEN);
}
return $next($request);
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Http\Middleware;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Models\User;
class EnsureUserIsAdminTest extends TestCase
{
private EnsureUserIsAdmin $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new EnsureUserIsAdmin();
}
#[Test]
public function it_allows_request_to_proceed_when_user_is_admin(): void
{
// Arrange
$adminUser = new User(['id' => 1, 'is_admin' => true]);
$request = Request::create('/admin/dashboard', 'GET');
$request->setUserResolver(fn () => $adminUser);
$nextCalled = false;
$next = function ($req) use (&$nextCalled) {
$nextCalled = true;
return response('Next Passed', Response::HTTP_OK);
};
// Act
$response = $this->middleware->handle($request, $next);
// Assert
$this->assertTrue($nextCalled, 'Closure $next should have been called.');
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
}
#[Test]
public function it_returns_403_forbidden_when_user_is_not_admin(): void
{
// Arrange
$normalUser = new User(['id' => 2, 'is_admin' => false]);
$request = Request::create('/admin/dashboard', 'GET');
$request->setUserResolver(fn () => $normalUser);
$nextCalled = false;
$next = function () use (&$nextCalled) {
$nextCalled = true;
return response('Next Passed');
};
// Act
$response = $this->middleware->handle($request, $next);
// Assert
$this->assertFalse($nextCalled, 'Closure $next must NOT be called.');
$this->assertInstanceOf(JsonResponse::class, $response);
$this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
}
#[Test]
public function it_returns_403_forbidden_when_no_user_is_authenticated(): void
{
// Arrange
$request = Request::create('/admin/dashboard', 'GET');
$request->setUserResolver(fn () => null);
$nextCalled = false;
$next = function () use (&$nextCalled) {
$nextCalled = true;
return response('Next Passed');
};
// Act
$response = $this->middleware->handle($request, $next);
// Assert
$this->assertFalse($nextCalled);
$this->assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
}
}
```
+100
View File
@@ -0,0 +1,100 @@
# Component Guide: Policies & Authorization Gates
Policies kiểm soát quyền hạn truy cập của người dùng đối với từng hành động trên Model (view, create, update, delete).
---
## 1. Bản Chất Kiểm Thử Policy
- Policy methods là các hàm thuần túy nhận `User` và `Model` (hoặc chỉ `User`) và trả về `bool` (hoặc `Response::allow()` / `Response::deny()`).
- **Ưu điểm lớn**: Policy có thể được kiểm thử đơn vị **hoàn toàn 100% trong bộ nhớ (In-Memory)** mà không cần kết nối database hay framework gate pipeline.
---
## 2. Mã Mẫu Chuẩn: Policy Unit Test
### Policy Nguồn: `App\Policies\ArticlePolicy`
```php
namespace App\Policies;
use App\Models\Article;
use App\Models\User;
class ArticlePolicy
{
public function update(User $user, Article $article): bool
{
// Admin được sửa tất cả bài viết
if ($user->is_admin) {
return true;
}
// Tác giả chỉ được sửa bài của mình khi bài chưa publish
return $user->id === $article->user_id && !$article->is_published;
}
public function delete(User $user, Article $article): bool
{
// Chỉ admin hoặc tác giả mới được xóa
return $user->is_admin || $user->id === $article->user_id;
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Policies;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Policies\ArticlePolicy;
use App\Models\Article;
use App\Models\User;
class ArticlePolicyTest extends TestCase
{
private ArticlePolicy $policy;
protected function setUp(): void
{
parent::setUp();
$this->policy = new ArticlePolicy();
}
#[Test]
public function admin_can_update_any_article_even_if_published(): void
{
$admin = new User(['id' => 99, 'is_admin' => true]);
$article = new Article(['user_id' => 1, 'is_published' => true]);
$this->assertTrue($this->policy->update($admin, $article));
}
#[Test]
public function author_can_update_own_article_if_not_published(): void
{
$author = new User(['id' => 10, 'is_admin' => false]);
$article = new Article(['user_id' => 10, 'is_published' => false]);
$this->assertTrue($this->policy->update($author, $article));
}
#[Test]
public function author_cannot_update_own_article_once_published(): void
{
$author = new User(['id' => 10, 'is_admin' => false]);
$article = new Article(['user_id' => 10, 'is_published' => true]);
$this->assertFalse($this->policy->update($author, $article));
}
#[Test]
public function non_author_cannot_update_others_article(): void
{
$otherUser = new User(['id' => 20, 'is_admin' => false]);
$article = new Article(['user_id' => 10, 'is_published' => false]);
$this->assertFalse($this->policy->update($otherUser, $article));
}
}
```
@@ -0,0 +1,118 @@
# Component Guide: Mailables & Notifications
Kiểm thử gửi Mail và Notification trong Laravel gồm kiểm tra các kênh gửi (`via`), nội dung (`toMail`, `toArray`) và các biến dữ liệu gán vào view.
---
## 1. Bản Chất Kiểm Thử Mailable & Notification
1. **Mailable Unit Test**:
- Kiểm tra `subject` (tiêu đề email).
- Kiểm tra người nhận (`to`), người gửi (`from`).
- Kiểm tra file đính kèm (`attachments`).
- Kiểm tra xem view template có render đúng nội dung với data truyền vào không.
2. **Notification Unit Test**:
- Kiểm tra method `via($notifiable)`: Trả về đúng mảng các kênh `['mail', 'database']`.
- Kiểm tra method `toMail($notifiable)`: Đối tượng `MailMessage` có chứa đúng tiêu đề, lời chào và URL nút bấm.
- Kiểm tra method `toArray($notifiable)`: Cấu trúc mảng lưu trữ vào bảng notifications.
---
## 2. Mã Mẫu Chuẩn: Notification Unit Test
### Notification Nguồn: `App\Notifications\InvoicePaidNotification`
```php
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvoicePaidNotification extends Notification
{
use Queueable;
public function __construct(
public readonly Invoice $invoice
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Hóa đơn #{$this->invoice->number} đã được thanh toán")
->greeting("Xin chào {$notifiable->name},")
->line("Cảm ơn bạn đã thanh toán số tiền: {$this->invoice->amount} VND.")
->action('Xem chi tiết hóa đơn', url("/invoices/{$this->invoice->id}"));
}
public function toArray(object $notifiable): array
{
return [
'invoice_id' => $this->invoice->id,
'amount' => $this->invoice->amount,
'paid_at' => now()->toIso8601String(),
];
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Notifications;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Notifications\InvoicePaidNotification;
use App\Models\Invoice;
use App\Models\User;
class InvoicePaidNotificationTest extends TestCase
{
#[Test]
public function it_defines_correct_notification_channels(): void
{
$invoice = new Invoice(['id' => 1, 'number' => 'INV-2026', 'amount' => 500000]);
$notification = new InvoicePaidNotification($invoice);
$user = new User(['id' => 10, 'name' => 'Thien']);
$channels = $notification->via($user);
$this->assertSame(['mail', 'database'], $channels);
}
#[Test]
public function it_builds_correct_mail_message(): void
{
$invoice = new Invoice(['id' => 99, 'number' => 'INV-99', 'amount' => 100000]);
$notification = new InvoicePaidNotification($invoice);
$user = new User(['id' => 10, 'name' => 'Thien']);
$mailMessage = $notification->toMail($user);
$this->assertSame('Hóa đơn #INV-99 đã được thanh toán', $mailMessage->subject);
$this->assertSame('Xin chào Thien,', $mailMessage->greeting);
$this->assertSame('Xem chi tiết hóa đơn', $mailMessage->actionText);
$this->assertStringContainsString('/invoices/99', $mailMessage->actionUrl);
}
#[Test]
public function it_builds_correct_database_array_payload(): void
{
$invoice = new Invoice(['id' => 55, 'number' => 'INV-55', 'amount' => 250000]);
$notification = new InvoicePaidNotification($invoice);
$user = new User(['id' => 10, 'name' => 'Thien']);
$data = $notification->toArray($user);
$this->assertSame(55, $data['invoice_id']);
$this->assertSame(250000, $data['amount']);
$this->assertArrayHasKey('paid_at', $data);
}
}
```
+99
View File
@@ -0,0 +1,99 @@
# Component Guide: Artisan Console Commands
Artisan Commands xử lý các công việc chạy từ dòng lệnh (CLI), cron jobs, và lệnh bảo trì hệ thống.
---
## 1. Bản Chất Kiểm Thử Console Command
Khi test một Artisan Command, cần kiểm tra:
1. **Exit Code**: Trả về `Command::SUCCESS` (0) khi hoàn thành, hoặc `Command::FAILURE` (1) / `Command::INVALID` (2) khi có lỗi.
2. **Output trên màn hình**: Kiểm tra dòng thông báo in ra bằng `expectsOutput()`, `doesntExpectOutput()`, `expectsTable()`.
3. **User Interaction**: Kiểm tra câu hỏi tương tác như `expectsConfirmation()`, `expectsQuestion()`, `expectsChoice()`.
4. **Service Execution**: Đảm bảo Command gọi đúng Service xử lý logic ngầm bên dưới.
---
## 2. Mã Mẫu Chuẩn: Console Command Test
### Command Nguồn: `App\Console\Commands\PruneOldLogsCommand`
```php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\LogCleanupService;
class PruneOldLogsCommand extends Command
{
protected $signature = 'logs:prune {--days=30 : Số ngày lưu log cũ} {--force : Thực thi mà không hỏi lại}';
protected $description = 'Xóa các file log cũ hơn số ngày quy định';
public function handle(LogCleanupService $cleanupService): int
{
$days = (int) $this->option('days');
if ($days <= 0) {
$this->error('Số ngày phải lớn hơn 0.');
return self::INVALID;
}
if (!$this->option('force') && !$this->confirm("Bạn có chắc chắn muốn xóa log cũ hơn {$days} ngày?")) {
$this->info('Đã hủy thao tác.');
return self::SUCCESS;
}
$deletedCount = $cleanupService->deleteLogsOlderThan($days);
$this->info("Đã dọn dẹp thành công {$deletedCount} file log.");
return self::SUCCESS;
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Console\Commands;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Mockery;
use App\Services\LogCleanupService;
use Illuminate\Console\Command;
class PruneOldLogsCommandTest extends TestCase
{
#[Test]
public function it_fails_when_days_option_is_zero_or_negative(): void
{
$this->artisan('logs:prune --days=0')
->expectsOutput('Số ngày phải lớn hơn 0.')
->assertExitCode(Command::INVALID);
}
#[Test]
public function it_cancels_pruning_when_user_declines_confirmation(): void
{
$this->artisan('logs:prune --days=15')
->expectsConfirmation('Bạn có chắc chắn muốn xóa log cũ hơn 15 ngày?', 'no')
->expectsOutput('Đã hủy thao tác.')
->assertExitCode(Command::SUCCESS);
}
#[Test]
public function it_prunes_logs_successfully_when_forced(): void
{
// Mock service
$cleanupServiceMock = Mockery::mock(LogCleanupService::class);
$cleanupServiceMock->shouldReceive('deleteLogsOlderThan')
->once()
->with(30)
->andReturn(12);
$this->app->instance(LogCleanupService::class, $cleanupServiceMock);
$this->artisan('logs:prune --days=30 --force')
->expectsOutput('Đã dọn dẹp thành công 12 file log.')
->assertExitCode(Command::SUCCESS);
}
}
```
@@ -0,0 +1,86 @@
# Component Guide: Blade View Components
Blade View Components cho phép tái sử dụng giao diện UI kèm theo logic tính toán PHP đóng gói bên trong class component.
---
## 1. Bản Chất Kiểm Thử View Component
Laravel cung cấp helper `$this->component(MyComponent::class, $data)` để render component ra chuỗi HTML trực tiếp trong môi trường test mà không cần khởi động trình duyệt.
- **Cần test**:
- Class constructor gán đúng thuộc tính (Props).
- Thuộc tính mặc định khi không truyền vào.
- Các method helper của Component tính toán đúng CSS class hoặc style.
- HTML được render ra có chứa đúng nội dung, text, slot hoặc icon.
---
## 2. Mã Mẫu Chuẩn: View Component Test
### Component Nguồn: `App\View\Components\Alert`
```php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class Alert extends Component
{
public function __construct(
public readonly string $type = 'info',
public readonly ?string $title = null
) {}
public function alertClass(): string
{
return match ($this->type) {
'success' => 'bg-green-100 text-green-800 border-green-300',
'error' => 'bg-red-100 text-red-800 border-red-300',
default => 'bg-blue-100 text-blue-800 border-blue-300',
};
}
public function render(): View
{
return view('components.alert');
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\View\Components;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\View\Components\Alert;
class AlertTest extends TestCase
{
#[Test]
public function it_calculates_correct_css_class_based_on_type(): void
{
$infoAlert = new Alert(type: 'info');
$this->assertStringContainsString('bg-blue-100', $infoAlert->alertClass());
$successAlert = new Alert(type: 'success');
$this->assertStringContainsString('bg-green-100', $successAlert->alertClass());
$errorAlert = new Alert(type: 'error');
$this->assertStringContainsString('bg-red-100', $errorAlert->alertClass());
}
#[Test]
public function it_renders_component_with_slot_content(): void
{
$view = $this->blade(
'<x-alert type="success" title="Thành công!">Nội dung thông báo lưu thành công.</x-alert>'
);
$view->assertSee('Thành công!');
$view->assertSee('Nội dung thông báo lưu thành công.');
$view->assertSee('bg-green-100');
}
}
```
@@ -0,0 +1,129 @@
# Component Guide: Observers & Custom Exceptions
Kiểm thử Model Observers và Custom Exception Handlers đảm bảo các hành động phản ứng tự động (Side-effects) và xử lý lỗi được thực hiện đồng nhất.
---
## 1. Kiểm Thử Model Observers
- **Bản chất**: Observer lắng nghe các lifecycle events của Model (`creating`, `created`, `updating`, `deleted`,...).
- **Cách test đơn vị**: Khởi tạo trực tiếp instance của Observer và gọi phương thức (ví dụ: `$observer->creating($model)`) mà không cần lưu vào database.
### Observer Nguồn: `App\Observers\UserObserver`
```php
namespace App\Observers;
use App\Models\User;
use Illuminate\Support\Str;
class UserObserver
{
public function creating(User $user): void
{
// Tự động gán UUID nếu chưa có
if (empty($user->uuid)) {
$user->uuid = (string) Str::uuid();
}
// Tự động chuẩn hóa email về chữ thường
$user->email = strtolower($user->email);
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Observers;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Observers\UserObserver;
use App\Models\User;
class UserObserverTest extends TestCase
{
#[Test]
public function it_generates_uuid_and_lowercases_email_on_creating(): void
{
// Arrange
$user = new User(['email' => 'JOHN.DOE@EXAMPLE.COM']);
$observer = new UserObserver();
// Act
$observer->creating($user);
// Assert
$this->assertNotEmpty($user->uuid);
$this->assertSame('john.doe@example.com', $user->email);
}
}
```
---
## 2. Kiểm Thử Custom Exceptions
Kiểm thử Custom Exception đảm bảo:
- Exception mang đúng mã HTTP status code.
- Exception render đúng cấu trúc JSON khi được kích hoạt trong API context.
### Exception Nguồn: `App\Exceptions\InsufficientBalanceException`
```php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InsufficientBalanceException extends Exception
{
public function __construct(
public readonly int $currentBalance,
public readonly int $requiredAmount
) {
parent::__construct('Số dư tài khoản không đủ để thực hiện giao dịch.');
}
public function render(Request $request): JsonResponse
{
return response()->json([
'status' => 'error',
'code' => 'INSUFFICIENT_BALANCE',
'message' => $this->getMessage(),
'current_balance' => $this->currentBalance,
'required_amount' => $this->requiredAmount,
], Response::HTTP_PAYMENT_REQUIRED);
}
}
```
### Test Chuẩn: PHPUnit 10+
```php
namespace Tests\Unit\Exceptions;
use Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Exceptions\InsufficientBalanceException;
class InsufficientBalanceExceptionTest extends TestCase
{
#[Test]
public function it_renders_custom_json_response_with_balance_details(): void
{
$exception = new InsufficientBalanceException(currentBalance: 50_000, requiredAmount: 120_000);
$request = Request::create('/api/payment', 'POST');
$response = $exception->render($request);
$this->assertSame(Response::HTTP_PAYMENT_REQUIRED, $response->getStatusCode());
$data = json_decode($response->getContent(), true);
$this->assertSame('INSUFFICIENT_BALANCE', $data['code']);
$this->assertSame(50_000, $data['current_balance']);
$this->assertSame(120_000, $data['required_amount']);
}
}
```