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
+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']);
}
}
```