commit 2045c856e94ccaa43a6276d15b12796d6a780286 Author: thienpt Date: Mon Sep 7 16:41:37 2026 +0700 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 diff --git a/.claude/skills/laravel-mock-helper/skill.md b/.claude/skills/laravel-mock-helper/skill.md new file mode 100644 index 0000000..f3bbac5 --- /dev/null +++ b/.claude/skills/laravel-mock-helper/skill.md @@ -0,0 +1,46 @@ +--- +name: laravel-mock-helper +description: Phân tích một Class hoặc Interface bất kỳ và tự động sinh khối mã thiết lập Mock (Mockery / Laravel Fakes) sẵn sàng đưa vào test. +--- + +# Skill: Laravel Mock Helper + +Skill này hỗ trợ lập trình viên sinh nhanh mã giả lập (Mocking code) cho một Interface, Repository hoặc Service phức tạp. + +## Cách kích hoạt +```text +/laravel-mock-helper +``` +*Ví dụ*: `/laravel-mock-helper App\Contracts\PaymentGatewayInterface` + +--- + +## Quy trình xử lý của Agent: +1. Đọc nội dung file class/interface được cung cấp. +2. Liệt kê toàn bộ các `public methods`, bao gồm kiểu dữ liệu của tham số và kiểu trả về. +3. Sinh ra 2 phiên bản mã Mockery: + - **Phiên bản Happy Path (Thành công)**: Mock trả về dữ liệu mẫu chuẩn theo kiểu trả về. + - **Phiên bản Exception / Failure**: Mock ném ra ngoại lệ hoặc trả về `false`/`null`. +4. Sinh kèm cú pháp kiểm tra số lần gọi (`->once()`, `->withArgs(...)`). + +## Mẫu kết quả trả về: + +```php +// ========================================== +// THIẾT LẬP MOCK: PaymentGatewayInterface +// ========================================== + +// 1. Khởi tạo mock +$gatewayMock = Mockery::mock(\App\Contracts\PaymentGatewayInterface::class); + +// Kịch bản A: Happy Path +$gatewayMock->shouldReceive('charge') + ->once() + ->with(Mockery::type('float'), Mockery::type('string')) + ->andReturn(new \App\DTOs\TransactionResult(success: true, id: 'TXN_123')); + +// Kịch bản B: Exception Failure +$gatewayMock->shouldReceive('charge') + ->once() + ->andThrow(new \App\Exceptions\PaymentGatewayException('Connection timed out')); +``` diff --git a/.claude/skills/laravel-test-review/skill.md b/.claude/skills/laravel-test-review/skill.md new file mode 100644 index 0000000..987e945 --- /dev/null +++ b/.claude/skills/laravel-test-review/skill.md @@ -0,0 +1,63 @@ +--- +name: laravel-test-review +description: Đánh giá, chấm điểm chất lượng và phát hiện các lỗi anti-patterns trong file Unit Test Laravel có sẵn. +--- + +# Skill: Laravel Test Reviewer + +Skill này chịu trách nhiệm kiểm tra sức khỏe và chất lượng của một file test đã được viết sẵn trong dự án. + +## Cách kích hoạt +```text +/laravel-test-review <đường_dẫn_file_test> +``` +*Ví dụ*: `/laravel-test-review tests/Unit/Services/PaymentServiceTest.php` + +--- + +## Tiêu Chí Đánh Giá (Scoring Criteria - Thang Điểm 100) + +Agent sẽ quét file test qua 5 tiêu chí: + +### 1. Tính Cô Lập & Tốc Độ (Isolation & Speed - 25đ) +- Test có vô tình kích hoạt kết nối Database thật không? +- Có bị gọi mạng ngoài thật (Real HTTP) mà quên `Http::fake()` không? +- Có dùng `sleep()` gây chậm test không? + +### 2. Chiến Lược Giả Lập (Mocking Quality - 20đ) +- Có bị over-mocking (Mock cả DTO, Value Object, Model đơn giản) không? +- Mock method có ghi rõ số lần gọi (`->once()`, `->never()`) không hay chỉ dùng `shouldReceive()` vô thưởng vô phạt? +- Có bị lỗi mock chuỗi Eloquent Builder không? + +### 3. Cấu Trúc & Assertions (Structure & Assertions - 20đ) +- Có phân tách rõ ràng mô hình AAA (`// Arrange`, `// Act`, `// Assert`) không? +- Assertions có chặt chẽ (`assertSame`, `toBe`) hay dùng loose assertion? +- Có assertion rỗng / vô nghĩa (`assertTrue(true)`) không? + +### 4. Độ Phủ Kịch Bản (Scenario Coverage - 20đ) +- Ngoài Happy Path, đã có test case cho Exception chưa? +- Đã test các giá trị biên: `null`, mảng rỗng `[]`, số `0`, số âm chưa? + +### 5. Khả Năng Bảo Trì (Maintainability & Cleanliness - 15đ) +- Tên test method có mô tả rõ ràng hành vi và ngữ cảnh không? +- Có dọn dẹp state / reset `Carbon::setTestNow()` để tránh rò rỉ trạng thái giữa các test không? + +--- + +## Báo Cáo Đầu Ra Mẫu: + +```markdown +### 🔍 BÁO CÁO REVIEW UNIT TEST: `PaymentServiceTest.php` + +**Điểm tổng kết: 70/100 (Cần cải thiện)** + +#### ⚠️ Các vấn đề phát hiện (Anti-Patterns): +1. **Over-mocking tại line 24**: Đang mock `PaymentDTO` thay vì khởi tạo instance thật. DTO chỉ mang dữ liệu, không có hành vi. +2. **Thiếu số lần gọi Mock tại line 35**: `$gatewayMock->shouldReceive('charge')` chưa có `->once()`. Nếu hàm không được gọi hoặc bị gọi 10 lần, test vẫn pass. +3. **Chưa có Exception Test**: Chưa kiểm thử kịch bản khi Gateway ném `PaymentGatewayException`. + +#### 💡 Đề xuất Refactor: +```php +// Đoạn code được sửa lại chuẩn AAA và strict mock... +``` +``` diff --git a/.claude/skills/laravel-test-setup/skill.md b/.claude/skills/laravel-test-setup/skill.md new file mode 100644 index 0000000..f157790 --- /dev/null +++ b/.claude/skills/laravel-test-setup/skill.md @@ -0,0 +1,91 @@ +--- +name: laravel-test-setup +description: Tự động quét kiểm tra môi trường, dependencies, database config và docker để chuẩn bị viết Unit Test cho bất kỳ dự án Laravel nào. +--- + +# Skill: Laravel Test Environment Setup & Health Check + +Skill này chịu trách nhiệm chuẩn bị "Bước 0" cho dự án Laravel trước khi bắt đầu viết bất kỳ Unit Test nào. + +## Cách kích hoạt +- **Chế độ kiểm tra (Dry-run / Health-Check)**: + ```text + /laravel-test-setup + ``` +- **Chế độ tự động cài đặt gói thiếu (Auto-Install Mode)**: + ```text + /laravel-test-setup --install + ``` + +## Quy trình thực hiện (Execution Workflow) + +Khi người dùng kích hoạt lệnh `/laravel-test-setup`, Agent sẽ tuần tự thực hiện các bước sau: + +### Bước 1: Quét `composer.json` & Nhận Diện Thư Viện Cần Thiết +1. Đọc file `composer.json` tại thư mục gốc của dự án. +2. Xác định: + - **Phiên bản PHP & Laravel**: PHP 8.0 - 8.4+, Laravel 9.x - 13.x + - **Test Framework**: `phpunit/phpunit` hay `pestphp/pest` + - **Mocking & Fake**: `mockery/mockery`, `fakerphp/faker` + - **Performance & Coverage**: `brianium/paratest`, `nunomaduro/collision` +3. Nếu phát hiện thiếu thư viện: + - Nếu có cờ `--install` hoặc người dùng đồng ý: Agent tự động kích hoạt lệnh cài đặt qua môi trường tương ứng (Sail / Docker Compose / Local Composer): + ```bash + # Nếu dùng Sail: + ./vendor/bin/sail composer require --dev mockery/mockery fakerphp/faker --with-all-dependencies + # Nếu dùng Docker Compose (service app): + docker compose exec -T app composer require --dev mockery/mockery fakerphp/faker --with-all-dependencies + # Nếu chạy Local: + composer require --dev mockery/mockery fakerphp/faker --with-all-dependencies + ``` + - Hoặc chạy script `./setup-test-deps.sh` có sẵn. + +### Bước 2: Kiểm tra cấu hình Testing Database & Drivers +1. Kiểm tra sự tồn tại của `phpunit.xml` (hoặc `phpunit.xml.dist`) hoặc `.env.testing`. +2. Xác thực cấu hình thẻ `` trong `phpunit.xml`: + - `DB_CONNECTION` phải là `sqlite` và `DB_DATABASE` là `:memory:` (hoặc kết nối DB test riêng an toàn). + - `CACHE_STORE` hoặc `CACHE_DRIVER` phải là `array`. + - `QUEUE_CONNECTION` phải là `sync`. + - `MAIL_MAILER` phải là `array`. + - `BCRYPT_ROUNDS` nên là `4`. +3. Nếu chưa có hoặc cấu hình đang trỏ vào database dev thật: + - Cảnh báo người dùng về nguy cơ mất dữ liệu dev. + - Đề xuất tạo `.env.testing` hoặc cập nhật `phpunit.xml` dựa trên template có sẵn tại `templates/configs/`. + +### Bước 3: Phát hiện Môi trường Docker / Runtime +1. Kiểm tra `./vendor/bin/sail` -> Nếu có Sail, ưu tiên lệnh `./vendor/bin/sail test`. +2. Kiểm tra `docker-compose.yml` -> Dò tìm service PHP (`app`, `php`, `backend`,...) -> Đề xuất lệnh `docker compose exec -T php artisan test`. +3. Nếu chạy Local: Kiểm tra xem extension `pdo_sqlite` đã được bật chưa bằng `php -m | grep sqlite`. + +### Bước 4: Chạy Smoke Test (Kiểm tra thử nghiệm) +1. Chạy thử nghiệm lệnh test hiện có của project (ví dụ: `php artisan test --filter=ExampleTest`). +2. Nếu test PASS: Báo cáo môi trường đã sẵn sàng 100% để sinh Unit Test. +3. Nếu test FAIL hoặc văng lỗi (ví dụ: thiếu APP_KEY, thiếu SQLite driver, lỗi database): Đưa ra chẩn đoán chính xác nguyên nhân và lệnh khắc phục. + +--- + +## Mẫu báo cáo đầu ra cho người dùng: + +```markdown +### 📋 BÁO CÁO MÔI TRƯỜNG TEST (HEALTH CHECK) + +1. **Framework & Dependencies**: + - Laravel: 11.2.0 | PHP: 8.3 + - Test Runner: Pest PHP v2.34 + - Mocking: Mockery 1.6 (Đã cài) + - Faker: FakerPHP 1.23 (Đã cài) + +2. **Cấu hình Môi trường**: + - DB Test: SQLite (:memory:) [OK - An toàn] + - Cache / Queue / Mail: Array / Sync [OK - Tối ưu] + - BCRYPT_ROUNDS: 4 [OK - Tốc độ cao] + +3. **Môi trường thực thi**: + - Docker Service: `app` container đang hoạt động + - Lệnh chạy test chuẩn: `docker compose exec -T app php artisan test` + +4. **Kết quả Smoke Test**: + - Trạng thái: ✅ PASSED (2 tests, 4 assertions) + +🎉 **Môi trường đã hoàn toàn sẵn sàng cho việc sinh Unit Test!** +``` diff --git a/.claude/skills/laravel-unit-test/skill.md b/.claude/skills/laravel-unit-test/skill.md new file mode 100644 index 0000000..431cde8 --- /dev/null +++ b/.claude/skills/laravel-unit-test/skill.md @@ -0,0 +1,99 @@ +--- +name: laravel-unit-test +description: Phân tích một class mã nguồn PHP Laravel bất kỳ và tự động sinh file Unit Test hoàn chỉnh đạt chuẩn AAA, cô lập cao và bao phủ biên. +--- + +# Skill: Laravel Unit Test Generator + +Skill này là công cụ chính để tự động tạo ra file Unit Test cho bất kỳ class nào trong dự án Laravel (Service, Action, Controller, Job, FormRequest, Policy, Rule,...). + +## Cách kích hoạt +Người dùng có thể kích hoạt linh hoạt theo các chế độ (từ 1 lệnh duy nhất cho toàn bộ dự án đến từng file đơn lẻ): + +1. **🌟 Chế độ 1 Lệnh Duy Nhất Cho Toàn Bộ Dự Án (All-in-One Full Coverage Mode - Khuyên dùng cho dự án lớn)**: + ```text + /laravel-unit-test --all + ``` + **Cơ chế cam kết không bỏ sót bất kỳ file nào:** + - **Giai đoạn 1 (Lập Manifest Kiểm Soát)**: Tự động quét toàn bộ `app/` (và `Modules/`), lọc ra tất cả các class có logic (Services, Actions, FormRequests, Rules, Jobs, Policies, Middlewares, Listeners, Observers, Repositories, Custom Casts). Lập file danh sách kiểm soát `tests/.test-manifest.json` (ví dụ: Tổng cộng 42 files). + - **Giai đoạn 2 (Thực Thi Tuần Tự & Theo Dõi Tiến Độ)**: Duyệt tuần tự từng class trong manifest (`[1/42]`, `[2/42]`,...), sinh test tương ứng và đánh dấu `[COMPLETED]`. Không bao giờ bị tràn context và có thể tiếp tục nếu bị gián đoạn. + - **Giai đoạn 3 (Tự Động Chạy Thử & Sửa Lỗi Ngay - Auto-Healing)**: Sinh xong file nào lập tức chạy `./test-runner.sh --filter=` cho file đó. Nếu FAIL -> Tự động đọc log lỗi và sửa lại test cho đến khi PASS 100%. + - **Giai đoạn 4 (Báo Cáo Nghiệm Thu Độ Phủ)**: Chạy `./test-runner.sh --coverage` và in bảng tổng kết: `42/42 files covered, 100% Passed, Code Coverage: > 80%`. + +2. **Chế độ Hàng loạt theo Thư mục (Batch Directory Mode)**: + ```text + /laravel-unit-test app/Services/ + # Hoặc: + /laravel-unit-test app/Actions/ + ``` + +3. **Chế độ Theo Module / DDD (Module Mode)**: + ```text + /laravel-unit-test Modules/Order/ + # Hoặc: + /laravel-unit-test app/Domains/Payment/ + ``` + +4. **Chế độ Quét & Phủ Test Toàn Dự Án (Untested Audit Mode)**: + ```text + /laravel-unit-test --untested + ``` + +5. **Chế độ Theo Git Diff (Changed Files Mode - Dành Cho Pull Request)**: + ```text + /laravel-unit-test --changed + ``` + +6. **Chế độ File đơn lẻ (Single File Mode)**: + ```text + /laravel-unit-test app/Services/PaymentService.php + ``` + +--- + +## Quy trình 6 Bước Tự Động của Agent + +### Bước 1: Khảo sát Môi trường & Framework +1. Đọc `composer.json` để xác định Framework test của dự án: + - Nếu có `pestphp/pest`: Tạo test theo định dạng **Pest PHP**. + - Nếu có `phpunit/phpunit`: + - PHP >= 8.1 & PHPUnit 10+: Dùng **PHP 8 Attributes (`#[Test]`, `#[DataProvider]`)**. + - PHP < 8.1 hoặc PHPUnit 9: Dùng **DocBlock (`/** @test */`)**. + +### Bước 2: Phân tích File Nguồn +1. Đọc toàn bộ nội dung file class nguồn được chỉ định. +2. Xác định: + - **Loại Component**: Service, Action, Controller, FormRequest, Model, Job, Policy, Middleware,... + - **Constructor Dependencies**: Các class/interface được inject vào qua `__construct()`. + - **Public Methods**: Danh sách các hàm cần test. + - **Kiểu dữ liệu & Logic phân nhánh**: Các lệnh `if/else`, `try/catch`, `throw`, type hints của params và return types. + +### Bước 3: Phân Loại Mock vs Real Object +- **Mock bằng Mockery**: Các Interface, Repository, 3rd Party Client, Mailer, Logger. +- **Dùng Laravel Fakes**: `Http::fake()`, `Queue::fake()`, `Event::fake()`, `Storage::fake()`. +- **Dùng Object thật (Không Mock)**: DTOs, Value Objects, Collections, Request payload dạng mảng. + +### Bước 4: Lập Ma Trận Test Scenarios (Tối thiểu cho mỗi public method) +- **1 Happy Path**: Đầu vào hợp lệ -> Thực thi thành công -> Trả về đúng kết quả. +- **N Edge Cases**: Input biên (0, số âm, rỗng `""`, `[]`, `null`, unicode). +- **N Exception Cases**: Tham số sai ném `InvalidArgumentException`, service ngoài lỗi ném `PaymentFailedException`,... + +### Bước 5: Sinh Mã Nguồn Test +1. Xác định đường dẫn file test theo quy tắc Mirror: + - Nguồn: `app/Services/OrderService.php` + - Đích: `tests/Unit/Services/OrderServiceTest.php` +2. Tham chiếu hướng dẫn tương ứng trong `docs/components/` và template trong `templates/`. +3. Viết đầy đủ code: + - Tuân thủ mô hình **AAA** (`// Arrange`, `// Act`, `// Assert`). + - Mock expectation có số lần gọi rõ ràng: `->once()`, `->never()`. + - Dùng Strict assertion: `$this->assertSame()` hoặc `expect(...)->toBe()`. + +### Bước 6: Thẩm định & Lưu File +1. Tự kiểm tra file test qua `rules/verification-checklist.md`. +2. Ghi file test vào thư mục `tests/Unit/...`. +3. In ra lệnh chạy test gợi ý: + ```bash + php artisan test --filter=OrderServiceTest + # Hoặc qua docker runner: + ./templates/docker/test-runner.sh --filter=OrderServiceTest + ``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0d9b19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +.DS_Store +.phpunit.cache/ +vendor/ +composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..1136a97 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# 🚀 Laravel Agent Unit Testing Toolkit + +Bộ công cụ, cẩm nang quy chuẩn và Claude Code Skills chuyên biệt dành cho việc **tự động phân tích, thiết lập môi trường và sinh Unit Test chuẩn xác cho bất kỳ mã nguồn PHP Laravel nào** (hỗ trợ Laravel 9, 10, 11; PHPUnit 9/10/11 & Pest PHP v2/v3). + +--- + +## 🌟 Tính Năng Nổi Bật + +1. **Chuẩn Hóa Môi Trường (Step 0)**: + - Tự động phát hiện cấu hình Docker (Sail, Docker Compose) hoặc PHP CLI cục bộ. + - Thiết lập SQLite In-Memory (`:memory:`) siêu tốc, bảo vệ tuyệt đối database dev. + - Script chạy test thông minh `test-runner.sh` tự nhận diện môi trường container. +2. **Triết Lý Kiểm Thử Đỉnh Cao (AAA & Isolation)**: + - Cô lập hoàn toàn logic đơn vị, mock đúng nơi, fake đúng chỗ. + - Tuyệt đối không over-mock DTO, Value Object hay Mock chuỗi Eloquent Builder. +3. **Cẩm Nang Chi Tiết Cho 13 Tầng Kiến Trúc Laravel**: + - Services, Actions, Controllers, FormRequests, Custom Rules, Repositories, Query Builders, Models, Jobs, Events, Listeners, Middlewares, Policies, Notifications, Mails, Commands, View Components, Observers, Exceptions. +4. **Hệ Thống 4 Skills Tự Động Hóa Cho Claude Code**: + - `/laravel-test-setup`: Quét môi trường, cấu hình và chạy Smoke Test. + - `/laravel-unit-test`: Đọc class nguồn -> Sinh file test hoàn chỉnh. + - `/laravel-test-review`: Thẩm định, chấm điểm và chỉ ra lỗi anti-patterns của file test. + - `/laravel-mock-helper`: Sinh code Mockery / Fakes nhanh cho Interface/Class. + +--- + +## 📁 Cấu Trúc Dự Án + +```text +agent-unit-testing/ +├── .claude/ +│ └── skills/ # Bộ 4 Skills cho Claude Code +│ ├── laravel-test-setup/ # Khởi tạo & kiểm tra môi trường test +│ ├── laravel-unit-test/ # Sinh mã Unit Test tự động +│ ├── laravel-test-review/ # Review & chấm điểm file test +│ └── laravel-mock-helper/ # Hỗ trợ sinh mã Mocking +│ +├── docs/ # Cẩm nang kiến thức & quy chuẩn cốt lõi +│ ├── 00-environment-and-setup.md # Hướng dẫn setup dependencies, DB in-memory & Docker +│ ├── 01-overview-and-principles.md # Triết lý Unit Test: AAA, Isolation, Boundary +│ ├── 02-test-frameworks.md # So sánh & Cú pháp PHPUnit 10/11 vs Pest PHP +│ ├── 03-mocking-strategies.md # Toàn tập Mockery vs Laravel Fakes vs Static Facades +│ ├── 04-naming-and-structure.md # Quy chuẩn đặt tên file, namespace, test method +│ ├── 05-anti-patterns.md # 12 lỗi cấm kỵ khi viết Unit Test Laravel +│ ├── 06-future-php84-laravel-latest.md # Hướng dẫn chuyên sâu PHP 8.4+ & Laravel 12/13 +│ ├── 07-troubleshooting-and-debugging.md # Cẩm nang sửa 10 lỗi thường gặp nhất khi test +│ ├── 08-test-data-factories-and-builders.md# Quản lý fixture: Model::make(), States & DTO Builders +│ ├── 09-advanced-edge-cases.md # DB::transaction, Traits, File uploads, Serialization +│ │ +│ └── components/ # Hướng dẫn test cho 13 loại Laravel Component +│ ├── 01-services-and-actions.md # Service Layer, Action classes, DTOs +│ ├── 02-controllers.md # API & Web Controllers +│ ├── 03-form-requests-and-rules.md # FormRequest rules, Custom Validation Rules +│ ├── 04-repositories-and-queries.md # Repositories & Custom Query Builders +│ ├── 05-models.md # Accessors, Mutators, Custom Casts +│ ├── 06-jobs-and-queues.md # Queueable Jobs, handle(), retry logic +│ ├── 07-events-and-listeners.md # Event payloads, Listeners handling +│ ├── 08-middlewares.md # Request interception, Auth, Role, Tenant +│ ├── 09-policies-and-gates.md # Phân quyền Policy methods (can/cannot) +│ ├── 10-notifications-and-mail.md # Mailables & Notification channels +│ ├── 11-artisan-commands.md # Console input/options, exit code, prompts +│ ├── 12-view-components-and-blade.md # Blade View Components & directives +│ └── 13-observers-and-exceptions.md # Model Observers & Custom Exceptions +│ +├── templates/ # Boilerplate mẫu sẵn dùng +│ ├── configs/ # File config: phpunit.xml, .env.testing, Pest.php +│ ├── docker/ # Script test-runner.sh & docker-compose.test.yml +│ ├── phpunit/ # Templates chuẩn PHPUnit 10/11 (Attributes) +│ └── pest/ # Templates chuẩn Pest PHP v2/v3 +│ +└── rules/ # Bộ quy tắc & Checklists + ├── system-prompt-unit-test.md # System prompt tối cao cho AI + ├── edge-cases-matrix.md # Ma trận kịch bản biên (Null, Empty, Boundary, Error) + └── verification-checklist.md # Checklist 10 bước tự kiểm tra trước khi xuất code +``` + +--- + +## ⚡ Cài Đặt Cho Team (1 Dòng Lệnh Duy Nhất) + +Mỗi thành viên trong team chỉ cần mở Terminal, dán lệnh sau và bấm Enter: +```bash +git clone ssh://git@ssh-git.smartgift.io.vn:2222/Smartgift-AI/agent-unit-testing.git ~/.smartgift/agent-unit-testing && ~/.smartgift/agent-unit-testing/sg-install-unit-testing.sh --global +``` +*Lệnh trên tự động clone vào `~/.smartgift/` và kích hoạt toàn cục trên máy tính.* + +--- + +## 🛠️ Hướng Dẫn Sử Dụng Nhanh + +### Bước 1: Khởi Tạo Vào Dự Án Laravel Bất Kỳ +Mở terminal tại thư mục dự án Laravel của bạn: +```bash +cd /duong-dan/toi/du-an-laravel +sg-install-unit-testing.sh +``` + +### Bước 2: Chuẩn Bị Môi Trường +Mở Claude Code (`claude`) tại thư mục dự án và chạy skill: +```bash +/laravel-test-setup --install +``` +Agent sẽ tự động kiểm tra `composer.json`, kiểm tra database test SQLite và tự cài đặt các package còn thiếu. + +### Bước 3: Sinh Unit Test Tự Động +Chỉ định class, thư mục, hoặc phủ toàn bộ dự án: +```bash +# 🌟 1 lệnh duy nhất phủ test toàn bộ dự án (cam kết không sót file): +/laravel-unit-test --all + +# Cho cả thư mục / module: +/laravel-unit-test app/Services/ + +# Cho các file vừa sửa trên Git (trước khi tạo PR): +/laravel-unit-test --changed + +# Cho 1 class đơn lẻ: +/laravel-unit-test app/Services/PaymentService.php +``` + +### Bước 4: Chạy Kiểm Thử +Sử dụng script thông minh tự động nhận diện Docker / Sail / Local: +```bash +./test-runner.sh --filter=PaymentServiceTest +``` + +### Bước 5: Cập Nhật Bộ Công Cụ Khi Có Phiên Bản Mới +Thành viên đứng ở bất kỳ đâu chỉ cần gõ: +```bash +sg-test-update +``` + +--- + +## 🎯 Ma Trận Tương Thích (Compatibility Matrix) + +| Thành phần | Phiên bản hỗ trợ & Tương thích tương lai | +| :--- | :--- | +| **PHP** | 8.0, 8.1, 8.2, 8.3, **8.4+** (Hỗ trợ Property Hooks, Asymmetric Visibility) | +| **Laravel** | 9.x, 10.x, 11.x, **12.x & 13.x (Latest & Future-ready)** | +| **PHPUnit** | 9.x (DocBlock), 10.x, **11.x & 12.x** (PHP 8 Attributes `#[Test]`, `#[DataProvider]`) | +| **Pest PHP** | v2.x & **v3.x** (Hỗ trợ Architectural Testing `arch()`, Mutation testing) | +| **Mocking** | Mockery, PHPUnit Test Doubles, Laravel Built-in Fakes, `Concurrency::fake()`, `Context::fake()` | +| **Database** | SQLite In-Memory (`:memory:`), MySQL Test DB | +| **Môi trường** | Docker Compose, Laravel Sail, Native PHP CLI | + +--- + +## 📜 Giấy Phép & Bản Quyền +Phát triển bởi Smartgift AI Team. Phục vụ tự động hóa quy trình kiểm thử phần mềm đạt chuẩn chất lượng cao. diff --git a/USAGE-GUIDE.md b/USAGE-GUIDE.md new file mode 100644 index 0000000..f8fc20b --- /dev/null +++ b/USAGE-GUIDE.md @@ -0,0 +1,290 @@ +# 📖 CẨM NANG TOÀN TẬP: HƯỚNG DẪN CÀI ĐẶT, SỬ DỤNG & CẬP NHẬT +## BỘ CÔNG CỤ TỰ ĐỘNG HÓA LARAVEL UNIT TESTING (SMARTGIFT AI) + +Tài liệu chuẩn hóa dành cho toàn thể thành viên Team Smartgift: Từ bước cài đặt lần đầu, sử dụng cho dự án bất kỳ, quy trình viết test, cho đến cách cập nhật phiên bản mới nhất khi bộ công cụ có nâng cấp. + +--- + +## 📑 MỤC LỤC +1. [Cơ Chế Hoạt Động Của Bộ Công Cụ](#1-cơ-chế-hoạt-động-của-bộ-công-cụ) +2. [Cài Đặt Lần Đầu (Dành Cho Thành Viên Mới)](#2-cài-đặt-lần-đầu-dành-cho-thành-viên-mới) +3. [Khởi Tạo Cho Một Dự Án Laravel Bất Kỳ](#3-khởi-tạo-cho-một-dự-án-laravel-bất-kỳ) +4. [Quy Trình 5 Bước Sinh & Chạy Unit Test](#4-quy-trình-5-bước-sinh--chạy-unit-test) +5. [Cách Cập Nhật Khi Bộ Công Cụ Có Thay Đổi Mới (Update Guide)](#5-cách-cập-nhật-khi-bộ-công-cụ-có-thay-đổi-mới-update-guide) +6. [Giải Đáp Thắc Mắc & Lỗi Thường Gặp (FAQ & Troubleshooting)](#6-giải-đáp-thắc-mắc--lỗi-thường-gặp-faq--troubleshooting) +7. [Bảng Tra Cứu Toàn Bộ Lệnh (Full Cheat Sheet)](#7-bảng-tra-cứu-toàn-bộ-lệnh-full-cheat-sheet) + +--- + +## 1. Cơ Chế Hoạt Động Của Bộ Công Cụ + +Bộ công cụ này biến **Claude Code** thành một **Chuyên Gia Tự Động Hóa Kiểm Thử (Senior QA Automation Engineer)** thông qua 3 thành phần: + +1. **Bộ 4 Claude Code Skills** (Cài đặt tại `~/.claude/skills/`): + - Tự động quét môi trường, phát hiện thiếu thư viện (`/laravel-test-setup`). + - Tự động phân tích class nguồn và sinh Unit Test chuẩn AAA (`/laravel-unit-test`). + - Tự động thẩm định, bắt lỗi anti-patterns theo thang điểm 100 (`/laravel-test-review`). + - Tự động sinh mã Mockery / Laravel Fakes nhanh (`/laravel-mock-helper`). +2. **Hệ Thống Tri Thức (Knowledge Base - 23 Files Docs & Rules)**: + - Hướng dẫn chi tiết 13 tầng kiến trúc Laravel (Services, Controllers, Jobs, Policies, Rules,...). + - Xử lý các ca khó: `DB::transaction()`, Upload files, Traits, PHP 8.4 Property Hooks. +3. **Bộ Scripts Chạy Test Thông Minh (Smart Runners)**: + - `./test-runner.sh`: Tự nhận diện Laravel Sail, Docker Compose hay PHP Local. + - `./setup-test-deps.sh`: Tự động cài package test vào đúng container. + +--- + +## 2. Cài Đặt Lần Đầu (Dành Cho Thành Viên Mới) + +Mỗi thành viên trong team khi bắt đầu chỉ cần cài đặt **đúng 1 lần duy nhất** trên máy tính theo 1 trong 2 cách sau: + +### 🌟 Cách A: Cài Đặt Siêu Tốc Bằng 1 Dòng Lệnh (KHUYÊN DÙNG NHẤT) +Mở Terminal lên, dán dòng lệnh sau và bấm Enter: +```bash +git clone ssh://git@ssh-git.smartgift.io.vn:2222/Smartgift-AI/agent-unit-testing.git ~/.smartgift/agent-unit-testing && ~/.smartgift/agent-unit-testing/sg-install-unit-testing.sh --global +``` +*Lệnh này sẽ tự động clone repo về thư mục chuẩn `~/.smartgift/agent-unit-testing` (gọn gàng, không làm rác Desktop) và tự động kích hoạt toàn cục.* + +--- + +### 🔹 Cách B: Clone Thủ Công +Nếu muốn lưu repo ở thư mục tùy ý trên máy (ví dụ trong `~/Workspace/`): +```bash +# 1. Clone repository về máy +git clone ssh://git@ssh-git.smartgift.io.vn:2222/Smartgift-AI/agent-unit-testing.git + +# 2. Di chuyển vào thư mục và chạy cài đặt toàn cục +cd agent-unit-testing +./sg-install-unit-testing.sh --global +``` + +🎯 **Lợi ích sau khi cài đặt thành công:** +- Bộ 4 Skills được cài đặt toàn cục vào tài khoản máy của bạn (`~/.claude/skills/`). +- Các lệnh CLI `sg-install-unit-testing.sh`, `laravel-unit-test-setup`, `sg-test-update` được đăng ký vào `$PATH` của hệ thống. +- **Kể từ lúc này, bạn có thể đứng ở BẤT KỲ FOLDER NÀO trên máy tính để sử dụng mà không cần phải quay lại thư mục `agent-unit-testing` nữa!** + +--- + +## 3. Khởi Tạo Cho Một Dự Án Laravel Bất Kỳ + +Giả sử bạn đang làm việc trên một dự án Laravel (ví dụ: `~/Workspace/my-project`, `/var/www/client-app`): + +### Trường hợp A: Dự án đã có sẵn mã nguồn Laravel +Mở terminal tại thư mục dự án đó: +```bash +cd /duong-dan/toi/du-an-laravel-cua-ban + +# Chạy lệnh khởi tạo (KHÔNG CẦN dấu ./ ở đầu): +sg-install-unit-testing.sh +``` +*(Hoặc gõ lệnh: `laravel-unit-test-setup`)* + +Lệnh sẽ tự động sinh ngay 2 file vào thư mục dự án: +- `test-runner.sh`: Script chạy test thông minh tự động bắt Docker / Sail / Local. +- `setup-test-deps.sh`: Script tự động cài package test vào container. + +### Trường hợp B: Thư mục mới/trống (Chưa có `composer.json`) +Nếu bạn muốn cài đặt sẵn công cụ vào một thư mục trống: +```bash +cd /duong-dan/toi/thu-muc-trong +sg-install-unit-testing.sh --force +``` + +--- + +## 4. Quy Trình 5 Bước Sinh & Chạy Unit Test + +Sau khi khởi tạo, bạn mở dự án bằng **Claude Code** (`claude`) và thực hiện quy trình sau: + +```text + [1. Setup Env] --> [2. Generate Test] --> [3. Run Test] --> [4. Review] +/laravel-test-setup /laravel-unit-test ./test-runner.sh /laravel-test-review +``` + +--- + +### Bước 1: Quét Môi Trường & Cài Thư Viện (`/laravel-test-setup`) + +Trước khi viết bài test đầu tiên, hãy kiểm tra và cài đặt đầy đủ các thư viện cần thiết (`mockery`, `faker`, `paratest`): + +1. **Kiểm tra sức khỏe môi trường (Health-Check)**: + ```text + /laravel-test-setup + ``` +2. **Tự động cài đặt các thư viện còn thiếu (Auto-Install)**: + ```text + /laravel-test-setup --install + ``` + *AI sẽ tự động xác định bạn đang dùng Docker Compose hay Sail hay Local và chạy lệnh `composer require --dev ...` chính xác vào container của bạn.* + +--- + +### Bước 2: Sinh Unit Test Tự Động (`/laravel-unit-test`) + +Tùy vào nhu cầu thực tế, bạn chọn 1 trong 5 cách sau: + +#### 🌟 Cách 1: 1 LỆNH DUY NHẤT PHỦ TOÀN BỘ FILE TRONG DỰ ÁN (`--all`) +Khi bạn muốn AI tự động viết test cho toàn bộ dự án từ A-Z mà đảm bảo không bỏ sót bất kỳ file nào: +```text +/laravel-unit-test --all +``` +🎯 **Cơ chế cam kết không sót file (Full Coverage Engine):** +1. **Lập Manifest Kiểm Soát**: AI tự động quét toàn bộ `app/` (và `Modules/`), lọc ra tất cả các class có logic (Services, Actions, FormRequests, Rules, Jobs, Policies, Middlewares, Listeners, Observers, Repositories). Lập file danh sách kiểm soát `tests/.test-manifest.json` (ví dụ: Tổng cộng 42 files). +2. **Thực Thi Tuần Tự & Lưu Tiến Độ**: Duyệt tuần tự từng class (`[1/42]`, `[2/42]`,...), sinh test tương ứng và đánh dấu `[COMPLETED]`. Tránh hoàn toàn việc tràn token/context và có thể chạy tiếp nếu bị gián đoạn. +3. **Tự Động Chạy Thử & Tự Sửa Lỗi (Auto-Healing)**: Mỗi khi sinh xong 1 file test, AI lập tức chạy `./test-runner.sh --filter=`. Nếu fail -> Tự sửa lại code test ngay cho đến khi pass 100%. +4. **Nghiệm Thu Toàn Dự Án**: Chạy `./test-runner.sh --coverage` và in bảng tổng kết: `42/42 files covered, 100% Passed, Code Coverage: > 80%`. + +--- + +#### 🔹 Cách 2: Sinh hàng loạt cho cả một thư mục (Batch Directory Mode) +Khi bạn chỉ muốn phủ test cho 1 module hoặc 1 layer cụ thể: +```text +/laravel-unit-test app/Services/ +# Hoặc toàn bộ Actions: +/laravel-unit-test app/Actions/ +# Hoặc toàn bộ Module (DDD / Modular): +/laravel-unit-test Modules/Order/ +``` + +#### 🔹 Cách 3: Quét & Phủ test có chọn lọc (`--untested`) +Khi muốn AI quét tìm các class chưa có test và hiển thị danh sách để bạn chọn nhóm cần ưu tiên: +```text +/laravel-unit-test --untested +``` + +#### 🔹 Cách 4: Tự động sinh test cho Git Diff (`--changed` - Dành cho PR) +Trước khi tạo Pull Request trên Git: +```text +/laravel-unit-test --changed +``` + +#### 🔹 Cách 5: Sinh cho một class cụ thể (Single File Mode) +Khi bạn đang code hoặc refactor duy nhất 1 file: +```text +/laravel-unit-test app/Services/PaymentService.php +``` + +--- + +### Bước 3: Chạy Test Thông Minh (`test-runner.sh`) + +Không cần nhớ cú pháp docker phức tạp, chỉ cần chạy script thông minh: + +```bash +# Chạy toàn bộ Unit Test: +./test-runner.sh + +# Chạy riêng 1 file test vừa sinh: +./test-runner.sh --filter=PaymentServiceTest + +# Chạy kèm đo độ phủ Code Coverage: +./test-runner.sh --coverage +``` + +*Cơ chế tự động:* +- Nếu Sail đang chạy -> Tự gọi `./vendor/bin/sail test`. +- Nếu có Docker Compose -> Tự gọi `docker compose exec -T php artisan test`. +- Nếu chạy máy Local -> Tự gọi `php artisan test` và kiểm tra extension `pdo_sqlite`. + +--- + +### Bước 4: Thẩm Định & Review Chất Lượng Test (`/laravel-test-review`) + +Để kiểm tra xem file test có đạt chuẩn AAA, có bị over-mocking hay thiếu kịch bản biên không: +```text +/laravel-test-review tests/Unit/Services/PaymentServiceTest.php +``` +AI sẽ chấm điểm theo thang 100, chỉ ra các lỗi Anti-patterns và đưa ra đoạn mã refactor chuẩn. + +--- + +### Bước 5: Sinh Mã Giả Lập Nhanh (`/laravel-mock-helper`) + +Khi tự viết test và cần mock nhanh một Interface phức tạp: +```text +/laravel-mock-helper App\Contracts\PaymentGatewayInterface +``` +AI sẽ sinh sẵn khối code Mockery với 2 kịch bản (Thành công & Thất bại) để bạn copy vào test. + +--- + +## 5. Cách Cập Nhật Khi Bộ Công Cụ Có Thay Đổi Mới (Update Guide) + +Khi Trưởng nhóm (hoặc thành viên khác) cập nhật thêm tài liệu `.md`, thêm rules mới hoặc nâng cấp template lên Git, các thành viên khác làm thế nào để có phiên bản mới nhất? + +### 🌟 Cách 1: Dùng Lệnh Cập Nhật 1-Click Toàn Cục (KHUYÊN DÙNG) +Bất kể bạn đang đứng ở thư mục nào trong Terminal, chỉ cần gõ: +```bash +sg-test-update +``` +*(hoặc `laravel-test-update`)* + +#### Lệnh này sẽ tự động: +1. Chạy `git pull origin main` trên repo `agent-unit-testing` của bạn để kéo toàn bộ thay đổi mới nhất về máy. +2. Tự động đồng bộ toàn bộ Skills, Rules, Docs vào hệ thống Claude Code (`~/.claude/`). +3. Báo cáo hoàn tất thành công! + +#### Nếu bạn muốn cập nhật luôn các script runner trong một dự án cụ thể: +```bash +sg-test-update /duong-dan/toi/du-an-laravel-cua-ban +``` + +--- + +### ⚡ Cách 2: Kỹ Thuật Symlink Tự Động (Dành Cho Thành Viên Giữ Repo) +Nếu bạn đã cài đặt qua `--global`, toàn bộ skills đã được liên kết mềm (Symlink) trực tiếp vào repo `agent-unit-testing`. + +Do đó, bạn chỉ cần: +```bash +cd /duong-dan/toi/agent-unit-testing +git pull origin main +``` +**Chỉ cần `git pull` là xong!** Toàn bộ Claude Code trên máy của bạn sẽ tự động cập nhật nội dung mới nhất ngay lập tức mà không cần chạy thêm bất kỳ lệnh cài đặt nào. + +--- + +## 6. Giải Đáp Thắc Mắc & Lỗi Thường Gặp (FAQ & Troubleshooting) + +### Q1: Tại sao tôi gõ `./sg-install-unit-testing.sh` thì bị báo `zsh: no such file or directory`? +👉 **Trả lời**: Ký hiệu `./` nghĩa là tìm file ngay trong thư mục hiện tại. Khi bạn đã `cd` sang một dự án khác, thư mục đó không có file này. +**Cách sửa**: Bỏ dấu `./` đi, chỉ cần gõ thẳng: +```bash +sg-install-unit-testing.sh +# Hoặc: +laravel-unit-test-setup +``` + +### Q2: Tôi cài đặt vào một thư mục trống nhưng bị báo chưa có `artisan` hoặc `composer.json`? +👉 **Trả lời**: Mặc định script sẽ kiểm tra để tránh cài nhầm vào thư mục không phải Laravel. Nếu bạn muốn cài đặt trước vào thư mục trống, hãy thêm cờ `--force`: +```bash +sg-install-unit-testing.sh --force +``` + +### Q3: Chạy test trên SQLite `:memory:` bị lỗi hàm MySQL (`JSON_CONTAINS`, `DATE_FORMAT`)? +👉 **Trả lời**: Mở tài liệu `docs/07-troubleshooting-and-debugging.md` (mục 5) để xem cách đăng ký hàm giả lập trong `setUp()`, hoặc chuyển kết nối test sang một container MySQL test riêng. + +### Q4: Làm sao chặn merge Pull Request nếu Code Coverage dưới 80%? +👉 **Trả lời**: Bộ công cụ đã tạo sẵn file `.github/workflows/unit-tests.yml` (hoặc `.gitlab-ci.yml`). Pipeline CI sẽ tự động chạy song song trên PHP 8.2, 8.3, 8.4 và tự động từ chối merge nếu độ phủ < 80%. + +--- + +## 7. Bảng Tra Cứu Toàn Bộ Lệnh (Full Cheat Sheet) + +| Nhiệm Vụ | Lệnh Terminal / Claude Code | Ngữ Cảnh Sử Dụng | +| :--- | :--- | :--- | +| **Cài đặt lần đầu (Toàn cục)** | `./sg-install-unit-testing.sh --global` | Chạy 1 lần duy nhất trong repo `agent-unit-testing` | +| **Khởi tạo vào dự án bất kỳ** | `sg-install-unit-testing.sh` | Chạy tại terminal của bất kỳ dự án Laravel nào | +| **Khởi tạo vào thư mục trống** | `sg-install-unit-testing.sh --force` | Dùng khi thư mục chưa có `composer.json` | +| **Cập nhật bộ công cụ (1-Click)**| `sg-test-update` | Kéo bản mới nhất từ Git về máy mọi lúc | +| **Quét & Setup môi trường test** | `/laravel-test-setup` | Chạy trong Claude Code để kiểm tra cấu hình | +| **Tự động cài gói composer thiếu**| `/laravel-test-setup --install` | Cài `mockery`, `faker`, `paratest` vào container | +| **Sinh test TOÀN BỘ dự án (1 lệnh)**| `/laravel-unit-test --all` | Tự động lập manifest, sinh tuần tự, cam kết không sót file | +| **Sinh test cho 1 file** | `/laravel-unit-test app/.../Service.php`| Khi đang code 1 class đơn lẻ | +| **Sinh test cả thư mục** | `/laravel-unit-test app/Services/` | Khi cần phủ test cho cả 1 folder / module | +| **Quét dự án lớn chưa có test** | `/laravel-unit-test --untested` | Tìm các class thiếu test để sinh hàng loạt | +| **Sinh test cho Git Diff** | `/laravel-unit-test --changed` | Trước khi tạo Pull Request trên Git | +| **Review chất lượng file test** | `/laravel-test-review tests/.../Test.php`| Chấm điểm thang 100 & bắt lỗi anti-patterns | +| **Sinh code Mockery nhanh** | `/laravel-mock-helper MyInterface` | Sinh code mock sẵn dùng | +| **Chạy test tự nhận diện** | `./test-runner.sh --filter=MyTest` | Tự bắt Docker / Sail / Local | +| **Tra cứu sửa lỗi** | Đọc `docs/07-troubleshooting-and-debugging.md` | Sửa 10 lỗi thường gặp nhất | +| **Tra cứu 7 ca khó** | Đọc `docs/09-advanced-edge-cases.md` | `DB::transaction()`, Trait, Upload file, Queue | diff --git a/docs/00-environment-and-setup.md b/docs/00-environment-and-setup.md new file mode 100644 index 0000000..0d099b3 --- /dev/null +++ b/docs/00-environment-and-setup.md @@ -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ẻ `` chứa các cấu hình sau: + +```xml + + + + + ./tests/Unit + + + ./tests/Feature + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 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 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. diff --git a/docs/01-overview-and-principles.md b/docs/01-overview-and-principles.md new file mode 100644 index 0000000..954e5cd --- /dev/null +++ b/docs/01-overview-and-principles.md @@ -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). diff --git a/docs/02-test-frameworks.md b/docs/02-test-frameworks.md new file mode 100644 index 0000000..946105f --- /dev/null +++ b/docs/02-test-frameworks.md @@ -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 +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 +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 */`**. diff --git a/docs/03-mocking-strategies.md b/docs/03-mocking-strategies.md new file mode 100644 index 0000000..a59782d --- /dev/null +++ b/docs/03-mocking-strategies.md @@ -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. | diff --git a/docs/04-naming-and-structure.md b/docs/04-naming-and-structure.md new file mode 100644 index 0000000..fe25a6b --- /dev/null +++ b/docs/04-naming-and-structure.md @@ -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 = `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: `_provider` hoặc `provide_`. +- 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], +]); +``` diff --git a/docs/05-anti-patterns.md b/docs/05-anti-patterns.md new file mode 100644 index 0000000..4a45319 --- /dev/null +++ b/docs/05-anti-patterns.md @@ -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. diff --git a/docs/06-future-php84-laravel-latest.md b/docs/06-future-php84-laravel-latest.md new file mode 100644 index 0000000..09fcd1c --- /dev/null +++ b/docs/06-future-php84-laravel-latest.md @@ -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. diff --git a/docs/07-troubleshooting-and-debugging.md b/docs/07-troubleshooting-and-debugging.md new file mode 100644 index 0000000..a002b58 --- /dev/null +++ b/docs/07-troubleshooting-and-debugging.md @@ -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); + ``` diff --git a/docs/08-test-data-factories-and-builders.md b/docs/08-test-data-factories-and-builders.md new file mode 100644 index 0000000..ba93f6b --- /dev/null +++ b/docs/08-test-data-factories-and-builders.md @@ -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); +} +``` diff --git a/docs/09-advanced-edge-cases.md b/docs/09-advanced-edge-cases.md new file mode 100644 index 0000000..8791907 --- /dev/null +++ b/docs/09-advanced-edge-cases.md @@ -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']; + }); +} +``` diff --git a/docs/components/01-services-and-actions.md b/docs/components/01-services-and-actions.md new file mode 100644 index 0000000..eeb281d --- /dev/null +++ b/docs/components/01-services-and-actions.md @@ -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ư'); + }); +}); +``` diff --git a/docs/components/02-controllers.md b/docs/components/02-controllers.md new file mode 100644 index 0000000..d29dc8c --- /dev/null +++ b/docs/components/02-controllers.md @@ -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); + }); +}); +``` diff --git a/docs/components/03-form-requests-and-rules.md b/docs/components/03-form-requests-and-rules.md new file mode 100644 index 0000000..bd4267f --- /dev/null +++ b/docs/components/03-form-requests-and-rules.md @@ -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()); + } +} +``` diff --git a/docs/components/04-repositories-and-queries.md b/docs/components/04-repositories-and-queries.md new file mode 100644 index 0000000..4e942fb --- /dev/null +++ b/docs/components/04-repositories-and-queries.md @@ -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()); + } +} +``` diff --git a/docs/components/05-models.md b/docs/components/05-models.md new file mode 100644 index 0000000..aa2691d --- /dev/null +++ b/docs/components/05-models.md @@ -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); + } +} +``` diff --git a/docs/components/06-jobs-and-queues.md b/docs/components/06-jobs-and-queues.md new file mode 100644 index 0000000..8bdef01 --- /dev/null +++ b/docs/components/06-jobs-and-queues.md @@ -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')); + } +} +``` diff --git a/docs/components/07-events-and-listeners.md b/docs/components/07-events-and-listeners.md new file mode 100644 index 0000000..68f6649 --- /dev/null +++ b/docs/components/07-events-and-listeners.md @@ -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); + } +} +``` diff --git a/docs/components/08-middlewares.md b/docs/components/08-middlewares.md new file mode 100644 index 0000000..dc94752 --- /dev/null +++ b/docs/components/08-middlewares.md @@ -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()); + } +} +``` diff --git a/docs/components/09-policies-and-gates.md b/docs/components/09-policies-and-gates.md new file mode 100644 index 0000000..4feea67 --- /dev/null +++ b/docs/components/09-policies-and-gates.md @@ -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)); + } +} +``` diff --git a/docs/components/10-notifications-and-mail.md b/docs/components/10-notifications-and-mail.md new file mode 100644 index 0000000..ad0e44c --- /dev/null +++ b/docs/components/10-notifications-and-mail.md @@ -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); + } +} +``` diff --git a/docs/components/11-artisan-commands.md b/docs/components/11-artisan-commands.md new file mode 100644 index 0000000..e9bfdf5 --- /dev/null +++ b/docs/components/11-artisan-commands.md @@ -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); + } +} +``` diff --git a/docs/components/12-view-components-and-blade.md b/docs/components/12-view-components-and-blade.md new file mode 100644 index 0000000..7c7a28b --- /dev/null +++ b/docs/components/12-view-components-and-blade.md @@ -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( + 'Nội dung thông báo lưu thành công.' + ); + + $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'); + } +} +``` diff --git a/docs/components/13-observers-and-exceptions.md b/docs/components/13-observers-and-exceptions.md new file mode 100644 index 0000000..660f039 --- /dev/null +++ b/docs/components/13-observers-and-exceptions.md @@ -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']); + } +} +``` diff --git a/rules/edge-cases-matrix.md b/rules/edge-cases-matrix.md new file mode 100644 index 0000000..a1387f4 --- /dev/null +++ b/rules/edge-cases-matrix.md @@ -0,0 +1,27 @@ +# Ma Trận Kịch Bản Biên & Ngoại Lệ (Edge Cases Matrix) + +Bảng phân loại toàn diện các tình huống biên cần phải kiểm thử đối với từng kiểu dữ liệu và nghiệp vụ trong ứng dụng Laravel. + +--- + +## 1. Phân Loại Dữ Liệu Đầu Vào (Input Data Types) + +| Loại dữ liệu | Kịch bản biên bắt buộc phải test | +| :--- | :--- | +| **String (Chuỗi)** | - Chuỗi rỗng: `""`
- Chuỗi chỉ chứa khoảng trắng: `" "`
- Chuỗi cực dài vượt giới hạn database/API (e.g. > 255 ký tự hoặc > 65535 ký tự)
- Chuỗi chứa ký tự Unicode đặc biệt / Emoji: `"Nguyễn Văn A 🚀 🔥"`
- Chuỗi chứa mã độc hại: `` hoặc SQL injection test | +| **Number (Số)** | - Số `0`
- Số âm: `-1`, `-99999`
- Số thực với số thập phân dài: `0.00000001`
- Giá trị cực đại (PHP_INT_MAX)
- Phép chia cho `0` (Division by zero) | +| **Array (Mảng)** | - Mảng rỗng: `[]`
- Mảng thiếu các key bắt buộc
- Mảng chứa giá trị `null` ở các field quan trọng
- Mảng lồng nhau nhiều cấp (Deeply nested)
- Mảng có key trùng lặp hoặc không tuần tự | +| **Date / Time (Thời gian)** | - Thời gian quá khứ vs Hiện tại vs Tương lai
- Ngày nhuận (29/02)
- Chuyển giao năm cũ sang năm mới (31/12 -> 01/01)
- Khác biệt múi giờ (Timezone discrepancies: UTC vs Local) | +| **Object / Model** | - Object bị `null`
- Model không tồn tại ID tương ứng
- Model ở trạng thái Soft-Deleted
- Model thiếu quan hệ (Missing relationship / Relation not loaded) | + +--- + +## 2. Phân Loại Nghiệp Vụ & Dịch Vụ Ngoài (Business & External Services) + +| Nhóm nghiệp vụ | Tình huống cần test | +| :--- | :--- | +| **Authentication & Authorization** | - Người dùng chưa đăng nhập (Unauthenticated / Guest / `null`)
- Người dùng đã đăng nhập nhưng không đủ quyền (Unauthorized / 403)
- Người dùng bị khóa tài khoản (Inactive / Banned)
- Token hết hạn hoặc không hợp lệ | +| **HTTP Client / External API** | - API phản hồi thành công (200, 201)
- API trả về lỗi nghiệp vụ (400 Bad Request, 422 Unprocessable)
- API từ chối xác thực (401, 403)
- API bị sập (500, 502, 503)
- API bị treo kết nối (Network Timeout / SocketException)
- API trả về cấu trúc JSON sai định dạng (Malformed JSON) | +| **Storage & File Upload** | - File rỗng (0 bytes)
- Dung lượng file vượt quá giới hạn (Max upload size exceeded)
- Định dạng đuôi file không được phép (MIME type mismatch)
- Đĩa lưu trữ bị đầy hoặc không có quyền ghi | +| **Queue & Background Jobs** | - Job thực thi thành công
- Job bị fail ở lần thử đầu tiên và kích hoạt retry
- Job bị fail hoàn toàn (hết số lần retry) -> kích hoạt hàm `failed()`
- Job nhận payload bị thiếu hoặc serialized object đã bị xóa | +| **Concurrency & Race Conditions**| - Hai yêu cầu cùng trừ tiền hoặc cùng đặt mua một sản phẩm còn 1 số lượng
- Idempotency: Gửi lại cùng một transaction ID 2 lần | diff --git a/rules/system-prompt-unit-test.md b/rules/system-prompt-unit-test.md new file mode 100644 index 0000000..877c2bf --- /dev/null +++ b/rules/system-prompt-unit-test.md @@ -0,0 +1,40 @@ +# System Prompt: Laravel Unit Test Generator + +Bạn là một Chuyên gia Kỹ thuật Cấp cao về Kiểm thử Phần mềm (Principal QA Automation Engineer & Laravel Core Specialist). Nhiệm vụ của bạn là phân tích mã nguồn PHP Laravel bất kỳ và tạo ra các bộ Unit Test đạt tiêu chuẩn ngành cao nhất. + +--- + +## 1. Mục Tiêu Tối Thượng (Prime Directives) + +1. **Tính Cô Lập Tuyệt Đối (Zero Leakage)**: Mọi Unit Test bạn tạo ra không được phụ thuộc vào Database ngoại vi hoặc Network thật. Mọi external dependencies đều phải được Mock bằng Mockery hoặc Laravel Built-in Fakes. +2. **Tuân Thủ Mô Hình AAA**: Bắt buộc chia rõ 3 khối: `// Arrange`, `// Act`, `// Assert`. +3. **Độ Phủ Biên Toàn Diện (Exhaustive Coverage)**: Không bao giờ chỉ viết Happy Path. Bắt buộc quét sạch: + - Happy Path (Đầu vào chuẩn). + - Negative Path (Đầu vào không hợp lệ / Validation lỗi). + - Boundary Cases (Min, Max, Zero, Null, Empty Array). + - Exception Cases (Lỗi mạng, External Service ném Exception, Unauthorized). +4. **Tự Động Thích Ứng Framework & Phiên Bản (Bao Quát PHP 8.4+ & Laravel 12/13)**: + - Nếu code dùng tính năng **PHP 8.4+** (Property Hooks, Asymmetric Visibility): Test trực tiếp hook logic và xác thực tính toàn vẹn của state. + - Nếu dự án dùng **Pest PHP (v2/v3)**: Sinh test chuẩn Functional syntax (`it()`, `describe()`, `expect()`) và hỗ trợ Architectural Testing (`arch()`). + - Nếu dự án dùng **PHPUnit (10/11/12)**: 100% sử dụng PHP 8 Attributes (`#[Test]`, `#[DataProvider]`, `#[TestDox]`), tuyệt đối không dùng DocBlock cũ. + - Nếu dự án cũ (Laravel 8/9, PHPUnit 9): Fallback về DocBlock `/** @test */`. +5. **Không Vi Phạm 12 Anti-Patterns**: Đọc và tuân thủ tuyệt đối các quy định trong `docs/05-anti-patterns.md`. + +--- + +## 2. Quy Trình 5 Bước Sinh Test (5-Step Workflow) + +Khi nhận một class cần viết test: +- **Bước 1: Phân tích Class**: + - Nhận diện loại component (Action, Service, Controller, FormRequest, Job, Policy,...). + - Liệt kê các constructor dependencies và public methods. + - Phân loại cái gì cần Mock (Services, Repositories, APIs) và cái gì dùng Object thật (DTOs, Value Objects). +- **Bước 2: Lập Ma Trận Kịch Bản Test (Test Matrix)**: + - Liệt kê tối thiểu: 1 Happy Path + N Edge Cases + N Exception Cases. +- **Bước 3: Lựa Chọn Template Tương Ứng**: + - Tham chiếu cấu trúc trong thư mục `docs/components/` và `templates/`. +- **Bước 4: Sinh Mã Nguồn Test Hoàn Chỉnh**: + - Đầy đủ Namespace, Use statements, Typed properties, strict assertions. + - Không viết code tắt, không dùng `// TODO`. +- **Bước 5: Tự Thẩm Định (Self-Verification)**: + - Soát lại qua `rules/verification-checklist.md` trước khi xuất kết quả. diff --git a/rules/verification-checklist.md b/rules/verification-checklist.md new file mode 100644 index 0000000..a552287 --- /dev/null +++ b/rules/verification-checklist.md @@ -0,0 +1,41 @@ +# Checklist Tự Thẩm Định Chất Lượng Unit Test (Verification Checklist) + +Trước khi xuất bất kỳ file test nào cho người dùng, Agent **BẮT BUỘC** phải tự đối chiếu mã nguồn của mình qua 10 câu hỏi kiểm định nghiêm ngặt dưới đây: + +--- + +### [ ] 1. Tính Đơn Vị (Is it really a Unit Test?) +- Test có đang vô tình gọi Database thật hoặc gọi router HTTP không? +- Các dependency nặng (API, Queue, Mail, Storage, Repository) đã được Mock hoặc Fake 100% chưa? + +### [ ] 2. Cấu Trúc AAA Rõ Ràng (Is AAA clearly structured?) +- Các khối `// Arrange`, `// Act`, `// Assert` có được phân định rõ ràng bằng comment không? +- Khối `// Act` có đúng là chỉ thực hiện 1 hành động duy nhất không? + +### [ ] 3. Mock Expectations Đầy Đủ (Are mock calls strictly verified?) +- Mọi mock method có được khai báo số lần gọi cụ thể (`->once()`, `->never()`, `->times(N)`) thay vì bỏ trống không? +- Các tham số truyền vào hàm mock có được kiểm tra qua `->with(...)` hoặc `->withArgs(...)` không? + +### [ ] 4. Không Over-Mock DTO/Value Object (Are value objects kept real?) +- Các đối tượng DTO, Value Object, Collection có được tạo bằng instance thật thay vì mock không? + +### [ ] 5. Độ Phủ Ngoại Lệ & Kịch Bản Biên (Are edge cases & exceptions covered?) +- Đã có test case cho trường hợp ném ngoại lệ (Exception) chưa? +- Đã kiểm tra các trường hợp biên: `null`, mảng rỗng `[]`, số `0`, số âm chưa? + +### [ ] 6. Assertions Chặt Chẽ & Có Nghĩa (Are assertions meaningful?) +- Có sử dụng Strict Assertion (`assertSame`, `toBe`) thay vì loose assertion không? +- Có bị dính assertion rỗng kiểu `$this->assertTrue(true)` không? + +### [ ] 7. Đặt Tên Chuẩn & Dễ Đọc (Are method names expressive?) +- Tên test method có thể hiện rõ "Hành động + Kết quả + Ngữ cảnh" không? (Ví dụ: `it_throws_exception_when_balance_is_insufficient`) + +### [ ] 8. Cú Pháp Đúng Phiên Bản Framework (Is syntax version-accurate?) +- Nếu là PHPUnit 10+: Đã dùng Attribute `#[Test]` và `#[DataProvider]` thay cho DocBlock cũ chưa? +- Nếu là Pest: Đã dùng đúng cú pháp `describe()`, `it()`, `expect()` chưa? + +### [ ] 9. Import Đầy Đủ & Namespace Chính Xác (Are imports complete?) +- Tất cả các class, exception, interface, attribute sử dụng trong file test đã có lệnh `use ...` ở đầu file chưa? (Tránh lỗi `Class not found`). + +### [ ] 10. Không Gây Rò Rỉ Trạng Thái (Zero State Leakage) +- Nếu có dùng `Carbon::setTestNow()` hay mock Facade tĩnh, đã đảm bảo dọn dẹp hoặc reset về trạng thái ban đầu chưa? diff --git a/sg-install-unit-testing.sh b/sg-install-unit-testing.sh new file mode 100755 index 0000000..22facfe --- /dev/null +++ b/sg-install-unit-testing.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +# ============================================================================== +# SMARTGIFT AI - LARAVEL UNIT TESTING TOOLKIT INSTALLER +# Bộ cài đặt công cụ chuẩn hóa và tự động sinh Unit Test cho dự án Laravel +# +# Cách dùng: +# 1. Cài đặt TOÀN CỤC (Khuyên dùng cho cả Team - chạy được ở MỌI thư mục): +# ./sg-install-unit-testing.sh --global +# +# 2. Cài đặt riêng vào một dự án cụ thể: +# ./sg-install-unit-testing.sh /duong-dan/toi/du-an-laravel +# +# 3. Cài đặt ngay tại thư mục hiện tại: +# ./sg-install-unit-testing.sh . +# ============================================================================== + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Tự động tìm đường dẫn gốc của repository (kể cả khi gọi qua symlink ở bất kỳ đâu) +SOURCE="${BASH_SOURCE[0]}" +while [ -L "$SOURCE" ]; do + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +CURRENT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + echo -e "${BLUE}===================================================================${NC}" + echo -e "${CYAN} SMARTGIFT AI: LARAVEL UNIT TESTING TOOLKIT INSTALLER${NC}" + echo -e "${BLUE}===================================================================${NC}" + echo -e "Cách dùng:" + echo -e " 1. Cài đặt toàn cục (dùng ở mọi nơi trên máy):" + echo -e " ${YELLOW}sg-install-unit-testing.sh --global${NC}" + echo -e " 2. Khởi tạo vào dự án tại thư mục hiện tại:" + echo -e " ${YELLOW}sg-install-unit-testing.sh${NC} (hoặc ${YELLOW}sg-install-unit-testing.sh .${NC})" + echo -e " 3. Khởi tạo vào một thư mục dự án cụ thể:" + echo -e " ${YELLOW}sg-install-unit-testing.sh /duong-dan/toi/du-an${NC}" + exit 0 +fi + +# ============================================================================== +# 1. XỬ LÝ CÀI ĐẶT TOÀN CỤC (GLOBAL MODE: DÙNG CHO MỌI THƯ MỤC TRÊN MÁY TÍNH) +# ============================================================================== +if [ "$1" = "--global" ] || [ "$1" = "-g" ]; then + echo -e "${BLUE}===================================================================${NC}" + echo -e "${CYAN} SMARTGIFT AI: CÀI ĐẶT BỘ LARAVEL UNIT TESTING TOOLKIT TOÀN CỤC${NC}" + echo -e "${YELLOW} Thư mục đích: $HOME/.claude/skills/${NC}" + echo -e "${BLUE}===================================================================${NC}" + + # 1. Cài đặt Skills cho Claude Code (Liên kết động để khi git pull tự động cập nhật ngay) + mkdir -p "$HOME/.claude/skills" + echo -e "📦 Đang liên kết bộ 4 Claude Code Skills vào $HOME/.claude/skills/..." + for skill_dir in "$CURRENT_DIR/.claude/skills/"*; do + skill_name="$(basename "$skill_dir")" + ln -sfn "$skill_dir" "$HOME/.claude/skills/$skill_name" + done + + # 2. Lưu trữ Docs và Templates tham khảo toàn cục + mkdir -p "$HOME/.claude/laravel-testing-docs/templates" + cp -R "$CURRENT_DIR/docs/"* "$HOME/.claude/laravel-testing-docs/" + cp -R "$CURRENT_DIR/rules/"* "$HOME/.claude/laravel-testing-docs/" + cp -R "$CURRENT_DIR/templates/"* "$HOME/.claude/laravel-testing-docs/templates/" + + # 3. Tạo lệnh CLI toàn cục: laravel-unit-test-setup & sg-test-init + mkdir -p "$HOME/.local/bin" + cat << 'EOF' > "$HOME/.local/bin/laravel-unit-test-setup" +#!/usr/bin/env bash +# Smartgift AI - Laravel Unit Test Project Initializer + +if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + echo "==================================================================" + echo " SMARTGIFT AI: LARAVEL UNIT TEST PROJECT INITIALIZER" + echo "==================================================================" + echo "Cách dùng: laravel-unit-test-setup [đường-dẫn-dự-án]" + echo "Mặc định khởi tạo vào thư mục hiện tại nếu không truyền tham số." + exit 0 +fi + +TARGET_DIR="${1:-.}" +SOURCE_DIR="$HOME/.claude/laravel-testing-docs" + +if [ ! -d "$TARGET_DIR" ]; then + echo "Lỗi: Thư mục '$TARGET_DIR' không tồn tại!" + exit 1 +fi + +echo "🚀 Đang khởi tạo công cụ test cho dự án tại: $TARGET_DIR" + +if [ -f "$SOURCE_DIR/templates/docker/test-runner.sh" ]; then + cp "$SOURCE_DIR/templates/docker/test-runner.sh" "$TARGET_DIR/test-runner.sh" + chmod +x "$TARGET_DIR/test-runner.sh" + echo " ✓ Đã tạo $TARGET_DIR/test-runner.sh" +fi + +if [ -f "$SOURCE_DIR/templates/setup-dependencies.sh" ]; then + cp "$SOURCE_DIR/templates/setup-dependencies.sh" "$TARGET_DIR/setup-test-deps.sh" + chmod +x "$TARGET_DIR/setup-test-deps.sh" + echo " ✓ Đã tạo $TARGET_DIR/setup-test-deps.sh" +fi + +echo "🎉 Khởi tạo hoàn tất! Bạn có thể mở Claude Code tại thư mục này và gõ /laravel-test-setup" +EOF + chmod +x "$HOME/.local/bin/laravel-unit-test-setup" + + # Tạo thêm các bí danh ngắn gọn + rm -f "$HOME/.local/bin/laravel-test-init" "$HOME/.local/bin/sg-test-init" + cp "$HOME/.local/bin/laravel-unit-test-setup" "$HOME/.local/bin/laravel-test-init" + cp "$HOME/.local/bin/laravel-unit-test-setup" "$HOME/.local/bin/sg-test-init" + chmod +x "$HOME/.local/bin/laravel-test-init" "$HOME/.local/bin/sg-test-init" + + # 4. Tạo lệnh CLI toàn cục: sg-test-update (Cập nhật 1-Click cho Team) + cat << EOF > "$HOME/.local/bin/sg-test-update" +#!/usr/bin/env bash +# Smartgift AI - One-Click Toolkit Updater + +TOOLKIT_REPO="$CURRENT_DIR" +TARGET_DIR="\${1:-}" + +# Tự động nhận diện nếu đang đứng trong một dự án Laravel +if [ -z "\$TARGET_DIR" ] && [ -f "./composer.json" ]; then + TARGET_DIR="." +fi + +echo "🔄 Đang cập nhật bộ công cụ Smartgift AI Unit Testing từ Git..." + +if [ -d "\$TOOLKIT_REPO/.git" ]; then + git -C "\$TOOLKIT_REPO" pull origin main + echo "✓ Đã kéo mã nguồn mới nhất từ remote Git." +else + echo "⚠️ Không tìm thấy git repo tại \$TOOLKIT_REPO" +fi + +# Tự động đồng bộ lại docs và skills +mkdir -p "$HOME/.claude/laravel-testing-docs/templates" +cp -R "\$TOOLKIT_REPO/docs/"* "$HOME/.claude/laravel-testing-docs/" +cp -R "\$TOOLKIT_REPO/rules/"* "$HOME/.claude/laravel-testing-docs/" +cp -R "\$TOOLKIT_REPO/templates/"* "$HOME/.claude/laravel-testing-docs/templates/" + +# Nếu có truyền đường dẫn dự án cụ thể, update luôn cho dự án đó +if [ -n "\$TARGET_DIR" ] && [ -d "\$TARGET_DIR" ]; then + echo "📦 Cập nhật cho dự án: \$TARGET_DIR" + cp "\$TOOLKIT_REPO/templates/docker/test-runner.sh" "\$TARGET_DIR/test-runner.sh" 2>/dev/null || true + cp "\$TOOLKIT_REPO/templates/setup-dependencies.sh" "\$TARGET_DIR/setup-test-deps.sh" 2>/dev/null || true + chmod +x "\$TARGET_DIR/test-runner.sh" "\$TARGET_DIR/setup-test-deps.sh" 2>/dev/null || true + if [ -d "\$TARGET_DIR/.claude/testing-docs" ]; then + cp -R "\$TOOLKIT_REPO/docs/"* "\$TARGET_DIR/.claude/testing-docs/" + cp -R "\$TOOLKIT_REPO/rules/"* "\$TARGET_DIR/.claude/testing-docs/" + fi +fi + +echo "🎉 CẬP NHẬT HOÀN TẤT! Tất cả các Skills & Docs đã ở phiên bản mới nhất." +EOF + chmod +x "$HOME/.local/bin/sg-test-update" + cp "$HOME/.local/bin/sg-test-update" "$HOME/.local/bin/laravel-test-update" + chmod +x "$HOME/.local/bin/laravel-test-update" + + echo -e "${GREEN}✓ CÀI ĐẶT TOÀN CỤC CHO TEAM THÀNH CÔNG RỰC RỠ!${NC}" + echo -e "" + echo -e "${YELLOW}Kể từ bây giờ, ở BẤT KỲ THƯ MỤC NÀO trên máy tính của bạn:${NC}" + echo -e " 1. Khi mở Claude Code (gõ 'claude'), các lệnh sau luôn sẵn sàng 100%:" + echo -e " - ${CYAN}/laravel-test-setup${NC} -> Tự động kiểm tra môi trường & cài package" + echo -e " - ${CYAN}/laravel-unit-test ${NC} -> Sinh test cho file, folder, hoặc --changed" + echo -e " - ${CYAN}/laravel-test-review ${NC} -> Chấm điểm và bắt lỗi anti-patterns" + echo -e " - ${CYAN}/laravel-mock-helper ${NC} -> Sinh mã Mockery nhanh" + echo -e " 2. Lệnh khởi tạo runner cho bất kỳ dự án nào:" + echo -e " ${CYAN}laravel-unit-test-setup${NC} (hoặc ${CYAN}sg-test-init${NC})" + echo -e " 3. Lệnh cập nhật 1-Click khi team có thay đổi mới:" + echo -e " ${CYAN}sg-test-update${NC} (hoặc ${CYAN}laravel-test-update${NC})" + exit 0 +fi + +# ============================================================================== +# 2. XỬ LÝ CÀI ĐẶT VÀO MỘT DỰ ÁN CỤ THỂ (PROJECT-SPECIFIC MODE) +# ============================================================================== +TARGET_DIR="${1:-.}" +FORCE_INSTALL=false + +if [ "$2" = "--force" ] || [ "$1" = "--force" ]; then + FORCE_INSTALL=true + if [ "$1" = "--force" ]; then + TARGET_DIR="${2:-.}" + fi +fi + +# Chuẩn hóa đường dẫn tương đối thành tuyệt đối +TARGET_DIR="$(cd "$TARGET_DIR" 2>/dev/null && pwd || echo "$TARGET_DIR")" + +if [ ! -d "$TARGET_DIR" ]; then + echo -e "${RED}Lỗi: Thư mục '$TARGET_DIR' không tồn tại!${NC}" + exit 1 +fi + +if [ ! -f "$TARGET_DIR/artisan" ] && [ ! -f "$TARGET_DIR/composer.json" ]; then + if [ "$FORCE_INSTALL" = false ]; then + echo -e "${YELLOW}⚠️ Cảnh báo: Thư mục '$TARGET_DIR' chưa có file artisan hoặc composer.json.${NC}" + echo -e "Nếu bạn vẫn muốn cài đặt trước bộ skills vào thư mục này, hãy thêm cờ: ${GREEN}--force${NC}" + echo -e "Ví dụ: ${BLUE}$0 $TARGET_DIR --force${NC}" + exit 1 + else + echo -e "${YELLOW}ℹ Chế độ --force: Tiến hành cài đặt skills dù chưa có artisan/composer.json.${NC}" + fi +fi + +echo -e "${BLUE}===================================================================${NC}" +echo -e "${CYAN} SMARTGIFT AI: CÀI ĐẶT LARAVEL UNIT TESTING TOOLKIT VÀO DỰ ÁN${NC}" +echo -e "${YELLOW} Thư mục đích: $TARGET_DIR${NC}" +echo -e "${BLUE}===================================================================${NC}" + +# Tạo thư mục .claude/skills trong dự án đích +mkdir -p "$TARGET_DIR/.claude/skills" +echo -e "📦 Đang copy bộ skills..." +cp -R "$CURRENT_DIR/.claude/skills/"* "$TARGET_DIR/.claude/skills/" + +# Tạo thư mục chứa docs tham khảo +mkdir -p "$TARGET_DIR/.claude/testing-docs" +cp -R "$CURRENT_DIR/docs/"* "$TARGET_DIR/.claude/testing-docs/" +cp -R "$CURRENT_DIR/rules/"* "$TARGET_DIR/.claude/testing-docs/" + +# Cài đặt test runner script & dependency setup script +if [ ! -f "$TARGET_DIR/test-runner.sh" ]; then + echo -e "🚀 Cài đặt smart test-runner.sh vào root của dự án..." + cp "$CURRENT_DIR/templates/docker/test-runner.sh" "$TARGET_DIR/test-runner.sh" + chmod +x "$TARGET_DIR/test-runner.sh" +fi + +if [ ! -f "$TARGET_DIR/setup-test-deps.sh" ]; then + echo -e "📦 Cài đặt smart setup-test-deps.sh vào root của dự án..." + cp "$CURRENT_DIR/templates/setup-dependencies.sh" "$TARGET_DIR/setup-test-deps.sh" + chmod +x "$TARGET_DIR/setup-test-deps.sh" +fi + +# Cài đặt GitHub Actions CI/CD Pipeline (nếu dự án dùng git) +if [ -d "$TARGET_DIR/.github" ] || [ -d "$TARGET_DIR/.git" ]; then + mkdir -p "$TARGET_DIR/.github/workflows" + if [ ! -f "$TARGET_DIR/.github/workflows/unit-tests.yml" ]; then + echo -e "⚙️ Cài đặt GitHub Actions CI Workflow vào .github/workflows/unit-tests.yml..." + cp "$CURRENT_DIR/templates/ci/github-actions.yml.template" "$TARGET_DIR/.github/workflows/unit-tests.yml" + fi +fi + +echo -e "${GREEN}✓ Cài đặt hoàn tất thành công!${NC}" +echo -e "" +echo -e "${YELLOW}Các lệnh bạn có thể sử dụng ngay trong dự án đích với Claude Code:${NC}" +echo -e " 1. ${CYAN}/laravel-test-setup${NC} -> Quét và kiểm tra môi trường test" +echo -e " 2. ${CYAN}/laravel-unit-test ${NC} -> Tự động sinh Unit Test (hỗ trợ cả file, folder, và --changed)" +echo -e " 3. ${CYAN}/laravel-test-review ${NC} -> Review chất lượng file test" +echo -e " 4. ${CYAN}/laravel-mock-helper ${NC} -> Sinh mã Mockery nhanh" +echo -e " 5. ${CYAN}./test-runner.sh${NC} -> Chạy test tự nhận diện Docker / Sail / Local" +echo -e " 6. ${CYAN}./setup-test-deps.sh${NC} -> Tự động cài thư viện test còn thiếu" diff --git a/templates/ci/github-actions.yml.template b/templates/ci/github-actions.yml.template new file mode 100644 index 0000000..da3ce32 --- /dev/null +++ b/templates/ci/github-actions.yml.template @@ -0,0 +1,56 @@ +name: Laravel Unit Tests & Quality Gate + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + unit-tests: + name: Unit Tests (PHP ${{ matrix.php }} - ${{ matrix.dependency-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + dependency-version: [prefer-stable] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup PHP & Extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, dom, fileinfo, sqlite3, pdo_sqlite, pcov + coverage: pcov + tools: composer:v2 + + - name: Cache Composer Dependencies + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}- + + - name: Install Dependencies + run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-progress + + - name: Setup Environment Variables + run: | + cp .env.example .env.testing 2>/dev/null || true + php artisan key:generate --env=testing + + - name: Run Unit Tests (Parallel Execution with Coverage Check) + run: | + php artisan test --testsuite=Unit --parallel --coverage --min=80 + env: + APP_ENV: testing + DB_CONNECTION: sqlite + DB_DATABASE: ":memory:" + CACHE_STORE: array + QUEUE_CONNECTION: sync + MAIL_MAILER: array diff --git a/templates/ci/gitlab-ci.yml.template b/templates/ci/gitlab-ci.yml.template new file mode 100644 index 0000000..c8e6fb3 --- /dev/null +++ b/templates/ci/gitlab-ci.yml.template @@ -0,0 +1,34 @@ +# GitLab CI/CD Pipeline for Laravel Unit Testing +image: php:8.3-cli-alpine + +stages: + - test + +variables: + APP_ENV: "testing" + DB_CONNECTION: "sqlite" + DB_DATABASE: ":memory:" + CACHE_STORE: "array" + QUEUE_CONNECTION: "sync" + MAIL_MAILER: "array" + +cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - vendor/ + +unit-test: + stage: test + before_script: + - apk add --no-cache sqlite-libs libzip-dev zip unzip git $PHPIZE_DEPS + - pecl install pcov && docker-php-ext-enable pcov + - docker-php-ext-install pdo pdo_sqlite zip + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + - composer install --prefer-dist --no-progress --no-interaction + script: + - php artisan test --testsuite=Unit --coverage --min=80 + coverage: '/^\s*Lines:\s*\d+.\d+\%/' + artifacts: + when: always + reports: + junit: phpunit-report.xml diff --git a/templates/configs/.env.testing.template b/templates/configs/.env.testing.template new file mode 100644 index 0000000..16ce131 --- /dev/null +++ b/templates/configs/.env.testing.template @@ -0,0 +1,28 @@ +# Môi trường cách ly cho Unit & Feature Tests Laravel +APP_NAME="Laravel Test" +APP_ENV=testing +APP_KEY=base64:FakeKeyForTestingPurposesOnly1234567890= +APP_DEBUG=true +APP_URL=http://localhost + +LOG_CHANNEL=null +LOG_LEVEL=emergency + +# Database SQLite in-memory siêu tốc +DB_CONNECTION=sqlite +DB_DATABASE=:memory: + +# Tối ưu mã hóa mật khẩu khi chạy test +BCRYPT_ROUNDS=4 + +# Chặn gửi mail / queue / cache / session thật +CACHE_STORE=array +CACHE_DRIVER=array +QUEUE_CONNECTION=sync +SESSION_DRIVER=array +MAIL_MAILER=array + +# Tắt các monitoring package +TELESCOPE_ENABLED=false +PULSE_ENABLED=false +HORIZON_PREFIX=testing-horizon diff --git a/templates/configs/Pest.template.php b/templates/configs/Pest.template.php new file mode 100644 index 0000000..9c85f80 --- /dev/null +++ b/templates/configs/Pest.template.php @@ -0,0 +1,45 @@ +in('Feature'); + +uses( + Tests\TestCase::class, +)->in('Unit'); + +/* +|-------------------------------------------------------------------------- +| Expectations Custom Helpers +|-------------------------------------------------------------------------- +| +| Tạo các custom expectation mở rộng của Pest nếu cần. +| +*/ + +expect()->extend('toBeValidEmail', function () { + return $this->toBeString()->toMatch('/^.+@.+\..+$/'); +}); + +/* +|-------------------------------------------------------------------------- +| Functions Helpers +|-------------------------------------------------------------------------- +| +| Hàm global hỗ trợ khởi tạo dữ liệu giả lập cho test. +| +*/ + +// function asUser($user = null) { +// return test()->actingAs($user ?? User::factory()->create()); +// } diff --git a/templates/configs/phpunit.xml.dist.template b/templates/configs/phpunit.xml.dist.template new file mode 100644 index 0000000..e488d78 --- /dev/null +++ b/templates/configs/phpunit.xml.dist.template @@ -0,0 +1,40 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./app + + + + + + + + + + + + + + + + + diff --git a/templates/docker/docker-compose.test.yml b/templates/docker/docker-compose.test.yml new file mode 100644 index 0000000..a9d0ad9 --- /dev/null +++ b/templates/docker/docker-compose.test.yml @@ -0,0 +1,21 @@ +services: + test-runner: + image: php:8.3-cli-alpine + container_name: laravel_unit_tester + working_dir: /var/www/html + volumes: + - ./:/var/www/html + environment: + - APP_ENV=testing + - DB_CONNECTION=sqlite + - DB_DATABASE=:memory: + - BCRYPT_ROUNDS=4 + - CACHE_STORE=array + - QUEUE_CONNECTION=sync + - MAIL_MAILER=array + command: > + sh -c " + apk add --no-cache sqlite-libs libzip-dev zip unzip git && + docker-php-ext-install pdo pdo_sqlite zip && + php artisan test + " diff --git a/templates/docker/test-runner.sh b/templates/docker/test-runner.sh new file mode 100755 index 0000000..0172f76 --- /dev/null +++ b/templates/docker/test-runner.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +# ============================================================================== +# Smart Test Runner for Laravel +# Tự động nhận diện môi trường: Laravel Sail -> Docker Compose -> Local PHP +# ============================================================================== + +set -e + +# Màu sắc thông báo +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}🔍 Đang phát hiện môi trường chạy test...${NC}" + +# Hàm chạy test +run_test() { + local runner="$1" + shift + echo -e "${GREEN}🚀 Chạy test bằng: ${runner} $*${NC}" + $runner "$@" +} + +# 1. Kiểm tra Laravel Sail +if [ -f "./vendor/bin/sail" ] && docker info >/dev/null 2>&1; then + if docker ps --format '{{.Names}}' | grep -q "laravel.test"; then + echo -e "${GREEN}✓ Phát hiện Laravel Sail đang hoạt động.${NC}" + run_test "./vendor/bin/sail test" "$@" + exit 0 + fi +fi + +# 2. Kiểm tra Docker Compose tự cấu hình +if [ -f "docker-compose.yml" ] || [ -f "compose.yaml" ]; then + if docker info >/dev/null 2>&1; then + # Tìm container service chạy php + SERVICES=("app" "php" "php-fpm" "backend" "workspace" "web") + for svc in "${SERVICES[@]}"; do + if docker compose ps --services --filter "status=running" 2>/dev/null | grep -q "^${svc}$"; then + echo -e "${GREEN}✓ Phát hiện Docker Compose với service: '${svc}'${NC}" + run_test "docker compose exec -T ${svc} php artisan test" "$@" + exit 0 + fi + done + fi +fi + +# 3. Fallback về PHP CLI cục bộ +if command -v php >/dev/null 2>&1; then + echo -e "${YELLOW}ℹ Không tìm thấy container Docker đang chạy. Chuyển về PHP CLI máy local.${NC}" + + # Kiểm tra extension pdo_sqlite + if ! php -m | grep -q -i sqlite; then + echo -e "${RED}⚠️ Cảnh báo: PHP CLI hiện tại chưa bật extension pdo_sqlite. Unit test dùng In-memory SQLite có thể bị lỗi!${NC}" + fi + + run_test "php artisan test" "$@" + exit 0 +fi + +echo -e "${RED}❌ Không tìm thấy môi trường phù hợp (Sail, Docker Compose hoặc PHP CLI) để chạy test!${NC}" +exit 1 diff --git a/templates/pest/ActionTest.template.php b/templates/pest/ActionTest.template.php new file mode 100644 index 0000000..cfb27ea --- /dev/null +++ b/templates/pest/ActionTest.template.php @@ -0,0 +1,21 @@ +action = new {{ClassName}}(...); +}); + +describe('{{ClassName}}', function () { + it('handles action invocation successfully', function () { + // Arrange + + // Act + // $result = ($this->action)($dto); + + // Assert + expect(true)->toBeTrue(); + }); +}); diff --git a/templates/pest/ArchitectureTest.template.php b/templates/pest/ArchitectureTest.template.php new file mode 100644 index 0000000..e53e1c1 --- /dev/null +++ b/templates/pest/ArchitectureTest.template.php @@ -0,0 +1,55 @@ +expect('App') + ->toUseStrictTypes(); + + test('No debugging functions left behind in codebase') + ->expect(['dd', 'dump', 'ray', 'var_dump', 'print_r', 'die', 'exit']) + ->not->toBeUsed(); + + test('Controllers naming convention & inheritance') + ->expect('App\Http\Controllers') + ->toHaveSuffix('Controller'); + + test('Controllers should not directly query Eloquent Models') + ->expect('App\Http\Controllers') + ->not->toUse('App\Models') + ->ignoring('App\Http\Controllers\Auth'); + + test('Services must be final or readonly') + ->expect('App\Services') + ->toHaveSuffix('Service'); + + test('DTOs and Value Objects must be immutable (readonly)') + ->expect('App\DTOs') + ->toBeReadonly(); + + test('Custom Validation Rules must implement ValidationRule contract') + ->expect('App\Rules') + ->toImplement('Illuminate\Contracts\Validation\ValidationRule'); + + test('Jobs must be queueable') + ->expect('App\Jobs') + ->toImplement('Illuminate\Contracts\Queue\ShouldQueue'); + + test('Events must not have dependencies injected') + ->expect('App\Events') + ->classes() + ->not->toHaveConstructor(); + +}); diff --git a/templates/pest/CommandTest.template.php b/templates/pest/CommandTest.template.php new file mode 100644 index 0000000..6d7f223 --- /dev/null +++ b/templates/pest/CommandTest.template.php @@ -0,0 +1,12 @@ +artisan('{{CommandSignature}}') + ->assertExitCode(Command::SUCCESS); + }); +}); diff --git a/templates/pest/ControllerTest.template.php b/templates/pest/ControllerTest.template.php new file mode 100644 index 0000000..7032d6e --- /dev/null +++ b/templates/pest/ControllerTest.template.php @@ -0,0 +1,25 @@ +controller = new {{ClassName}}(...); +}); + +describe('{{ClassName}}', function () { + it('returns successful response', function () { + // Arrange + // $request = Request::create('/api/resource', 'GET'); + + // Act + // $response = $this->controller->index($request); + + // Assert + // expect($response->getStatusCode())->toBe(Response::HTTP_OK); + expect(true)->toBeTrue(); + }); +}); diff --git a/templates/pest/FormRequestTest.template.php b/templates/pest/FormRequestTest.template.php new file mode 100644 index 0000000..112acf6 --- /dev/null +++ b/templates/pest/FormRequestTest.template.php @@ -0,0 +1,29 @@ +authorize())->toBeTrue(); + }); + + it('passes validation with valid payload', function () { + $request = new {{ClassName}}(); + $validator = Validator::make([ + // Valid data here + ], $request->rules()); + + expect($validator->fails())->toBeFalse(); + }); + + it('fails validation when required fields are missing', function () { + $request = new {{ClassName}}(); + $validator = Validator::make([], $request->rules()); + + expect($validator->fails())->toBeTrue(); + }); +}); diff --git a/templates/pest/JobTest.template.php b/templates/pest/JobTest.template.php new file mode 100644 index 0000000..77fcbf6 --- /dev/null +++ b/templates/pest/JobTest.template.php @@ -0,0 +1,19 @@ +handle($dependencyMock); + + // Assert + expect(true)->toBeTrue(); + }); +}); diff --git a/templates/pest/MiddlewareTest.template.php b/templates/pest/MiddlewareTest.template.php new file mode 100644 index 0000000..5425689 --- /dev/null +++ b/templates/pest/MiddlewareTest.template.php @@ -0,0 +1,28 @@ +middleware = new {{ClassName}}(); +}); + +describe('{{ClassName}}', function () { + it('passes request to next closure when authorized', function () { + $request = Request::create('/test-route', 'GET'); + $nextCalled = false; + + $next = function ($req) use (&$nextCalled) { + $nextCalled = true; + return response('OK', Response::HTTP_OK); + }; + + $response = $this->middleware->handle($request, $next); + + expect($nextCalled)->toBeTrue(); + expect($response->getStatusCode())->toBe(Response::HTTP_OK); + }); +}); diff --git a/templates/pest/PolicyTest.template.php b/templates/pest/PolicyTest.template.php new file mode 100644 index 0000000..7c66aa3 --- /dev/null +++ b/templates/pest/PolicyTest.template.php @@ -0,0 +1,24 @@ +policy = new {{ClassName}}(); +}); + +describe('{{ClassName}}', function () { + it('allows authorized user to perform action', function () { + $user = new User(['id' => 1]); + // expect($this->policy->view($user, $model))->toBeTrue(); + expect(true)->toBeTrue(); + }); + + it('denies unauthorized user from performing action', function () { + $user = new User(['id' => 99]); + // expect($this->policy->update($user, $model))->toBeFalse(); + expect(true)->toBeTrue(); + }); +}); diff --git a/templates/pest/ServiceTest.template.php b/templates/pest/ServiceTest.template.php new file mode 100644 index 0000000..024e85e --- /dev/null +++ b/templates/pest/ServiceTest.template.php @@ -0,0 +1,25 @@ +dependencyMock = Mockery::mock(DependencyInterface::class); + // $this->service = new {{ClassName}}($this->dependencyMock); +}); + +describe('{{ClassName}}', function () { + it('executes successfully under normal conditions', function () { + // Arrange + + // Act + + // Assert + expect(true)->toBeTrue(); + }); + + it('throws exception when input is invalid', function () { + // expect(fn () => $this->service->method(...))->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/templates/phpunit/ActionTest.template.php b/templates/phpunit/ActionTest.template.php new file mode 100644 index 0000000..bdf3190 --- /dev/null +++ b/templates/phpunit/ActionTest.template.php @@ -0,0 +1,40 @@ +action = new {{ClassName}}(...); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + #[Test] + public function it_handles_action_invocation_successfully(): void + { + // Arrange + + // Act + // $result = ($this->action)($dto); + + // Assert + $this->assertTrue(true); + } +} diff --git a/templates/phpunit/ArchitectureTest.template.php b/templates/phpunit/ArchitectureTest.template.php new file mode 100644 index 0000000..ff275b8 --- /dev/null +++ b/templates/phpunit/ArchitectureTest.template.php @@ -0,0 +1,75 @@ +assertDirectoryExists($appPath); + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($appPath, \FilesystemIterator::SKIP_DOTS) + ); + + $forbiddenPatterns = ['/\bdd\(/', '/\bdump\(/', '/\bvar_dump\(/', '/\bprint_r\(/', '/\bray\(/']; + $violations = []; + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $content = file_get_contents($file->getRealPath()); + foreach ($forbiddenPatterns as $pattern) { + if (preg_match($pattern, $content)) { + $violations[] = $file->getRelativePathname() . " matches pattern {$pattern}"; + } + } + } + + $this->assertEmpty( + $violations, + "Found forbidden debugging statements:\n" . implode("\n", $violations) + ); + } + + #[Test] + public function controllers_must_have_controller_suffix(): void + { + $controllersPath = realpath(__DIR__ . '/../../app/Http/Controllers'); + if (!$controllersPath || !is_dir($controllersPath)) { + $this->markTestSkipped('Controllers directory does not exist.'); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($controllersPath, \FilesystemIterator::SKIP_DOTS) + ); + + $invalidFiles = []; + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $filename = $file->getBasename('.php'); + if (!str_ends_with($filename, 'Controller')) { + $invalidFiles[] = $file->getRelativePathname(); + } + } + + $this->assertEmpty( + $invalidFiles, + "Controllers must end with 'Controller':\n" . implode("\n", $invalidFiles) + ); + } +} diff --git a/templates/phpunit/CommandTest.template.php b/templates/phpunit/CommandTest.template.php new file mode 100644 index 0000000..e906f84 --- /dev/null +++ b/templates/phpunit/CommandTest.template.php @@ -0,0 +1,20 @@ +artisan('{{CommandSignature}}') + ->assertExitCode(Command::SUCCESS); + } +} diff --git a/templates/phpunit/ControllerTest.template.php b/templates/phpunit/ControllerTest.template.php new file mode 100644 index 0000000..18d649e --- /dev/null +++ b/templates/phpunit/ControllerTest.template.php @@ -0,0 +1,43 @@ +controller = new {{ClassName}}(...); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + #[Test] + public function it_returns_successful_response(): void + { + // Arrange + // $request = Request::create('/api/resource', 'GET'); + + // Act + // $response = $this->controller->index($request); + + // Assert + // $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + $this->assertTrue(true); + } +} diff --git a/templates/phpunit/FormRequestTest.template.php b/templates/phpunit/FormRequestTest.template.php new file mode 100644 index 0000000..efb96b1 --- /dev/null +++ b/templates/phpunit/FormRequestTest.template.php @@ -0,0 +1,56 @@ +request = new {{ClassName}}(); + } + + #[Test] + public function it_authorizes_request_appropriately(): void + { + // Assert authorize() returns expected boolean + $this->assertTrue($this->request->authorize()); + } + + #[Test] + public function it_passes_validation_with_valid_payload(): void + { + // Arrange + $validPayload = [ + // Cung cấp dữ liệu chuẩn + ]; + + // Act + $validator = Validator::make($validPayload, $this->request->rules()); + + // Assert + $this->assertFalse($validator->fails()); + } + + #[Test] + public function it_fails_validation_when_required_fields_are_missing(): void + { + // Arrange + $emptyPayload = []; + + // Act + $validator = Validator::make($emptyPayload, $this->request->rules()); + + // Assert + $this->assertTrue($validator->fails()); + } +} diff --git a/templates/phpunit/JobTest.template.php b/templates/phpunit/JobTest.template.php new file mode 100644 index 0000000..b02266d --- /dev/null +++ b/templates/phpunit/JobTest.template.php @@ -0,0 +1,35 @@ +shouldReceive('execute')->once(); + + // $job = new {{ClassName}}(...); + + // Act + // $job->handle($dependencyMock); + + // Assert + $this->assertTrue(true); + } +} diff --git a/templates/phpunit/MiddlewareTest.template.php b/templates/phpunit/MiddlewareTest.template.php new file mode 100644 index 0000000..139c31a --- /dev/null +++ b/templates/phpunit/MiddlewareTest.template.php @@ -0,0 +1,42 @@ +middleware = new {{ClassName}}(); + } + + #[Test] + public function it_passes_request_to_next_closure_when_authorized(): void + { + // Arrange + $request = Request::create('/test-route', 'GET'); + $nextCalled = false; + + $next = function ($req) use (&$nextCalled) { + $nextCalled = true; + return response('OK', Response::HTTP_OK); + }; + + // Act + $response = $this->middleware->handle($request, $next); + + // Assert + $this->assertTrue($nextCalled); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); + } +} diff --git a/templates/phpunit/PolicyTest.template.php b/templates/phpunit/PolicyTest.template.php new file mode 100644 index 0000000..a524128 --- /dev/null +++ b/templates/phpunit/PolicyTest.template.php @@ -0,0 +1,43 @@ +policy = new {{ClassName}}(); + } + + #[Test] + public function it_allows_authorized_user_to_perform_action(): void + { + // Arrange + $user = new User(['id' => 1]); + + // Act & Assert + // $this->assertTrue($this->policy->view($user, $model)); + $this->assertTrue(true); + } + + #[Test] + public function it_denies_unauthorized_user_from_performing_action(): void + { + // Arrange + $user = new User(['id' => 99]); + + // Act & Assert + // $this->assertFalse($this->policy->update($user, $model)); + $this->assertTrue(true); + } +} diff --git a/templates/phpunit/ServiceTest.template.php b/templates/phpunit/ServiceTest.template.php new file mode 100644 index 0000000..509605e --- /dev/null +++ b/templates/phpunit/ServiceTest.template.php @@ -0,0 +1,54 @@ +dependencyMock = Mockery::mock({{DependencyInterface}}::class); + // $this->service = new {{ClassName}}($this->dependencyMock); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + #[Test] + public function it_executes_successfully_under_normal_conditions(): void + { + // 1. Arrange + + // 2. Act + + // 3. Assert + $this->assertTrue(true); + } + + #[Test] + public function it_throws_exception_when_input_is_invalid(): void + { + // 1. Arrange + + // 2. Assert Expectation + // $this->expectException(\InvalidArgumentException::class); + + // 3. Act + } +} diff --git a/templates/setup-dependencies.sh b/templates/setup-dependencies.sh new file mode 100755 index 0000000..1e81c09 --- /dev/null +++ b/templates/setup-dependencies.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +# ============================================================================== +# Smart Dependency Installer for Laravel Testing +# Tự động phát hiện gói còn thiếu và cài đặt qua Docker (Sail / Compose) hoặc Local Composer +# ============================================================================== + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' + +echo -e "${BLUE}===================================================================${NC}" +echo -e "${BLUE} Tự Động Cài Đặt Thư Viện Cần Thiết Cho Unit Test Laravel${NC}" +echo -e "${BLUE}===================================================================${NC}" + +if [ ! -f "composer.json" ]; then + echo -e "${RED}❌ Lỗi: Không tìm thấy file composer.json trong thư mục hiện tại!${NC}" + exit 1 +fi + +# 1. Xác định môi trường thực thi lệnh composer +COMPOSER_CMD="" + +if [ -f "./vendor/bin/sail" ] && docker info >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -q "laravel.test"; then + echo -e "${GREEN}✓ Môi trường: Laravel Sail đang hoạt động.${NC}" + COMPOSER_CMD="./vendor/bin/sail composer" +elif [ -f "docker-compose.yml" ] || [ -f "compose.yaml" ]; then + SERVICES=("app" "php" "php-fpm" "backend" "workspace" "web") + for svc in "${SERVICES[@]}"; do + if docker compose ps --services --filter "status=running" 2>/dev/null | grep -q "^${svc}$"; then + echo -e "${GREEN}✓ Môi trường: Docker Compose (service: ${svc}).${NC}" + COMPOSER_CMD="docker compose exec -T ${svc} composer" + break + fi + done +fi + +if [ -z "$COMPOSER_CMD" ]; then + if command -v composer >/dev/null 2>&1; then + echo -e "${YELLOW}ℹ Môi trường: Composer trên máy cục bộ (Local).${NC}" + COMPOSER_CMD="composer" + else + echo -e "${RED}❌ Lỗi: Không tìm thấy Composer trên máy hoặc trong container Docker!${NC}" + exit 1 + fi +fi + +# 2. Phân tích composer.json xem thiếu gói nào +echo -e "\n${YELLOW}🔍 Đang kiểm tra dependencies hiện có...${NC}" + +PACKAGES_TO_INSTALL=() + +# Kiểm tra Mockery +if ! grep -q '"mockery/mockery"' composer.json; then + PACKAGES_TO_INSTALL+=("mockery/mockery") +fi + +# Kiểm tra Faker +if ! grep -q '"fakerphp/faker"' composer.json; then + PACKAGES_TO_INSTALL+=("fakerphp/faker") +fi + +# Kiểm tra Test Runner (Pest vs PHPUnit) +IS_PEST=false +if grep -q '"pestphp/pest"' composer.json; then + IS_PEST=true +fi + +if [ "$IS_PEST" = true ]; then + if ! grep -q '"pestphp/pest-plugin-laravel"' composer.json; then + PACKAGES_TO_INSTALL+=("pestphp/pest-plugin-laravel") + fi +fi + +# Kiểm tra công cụ chạy song song (Paratest) +if ! grep -q '"brianium/paratest"' composer.json; then + PACKAGES_TO_INSTALL+=("brianium/paratest") +fi + +# Kiểm tra Collision +if ! grep -q '"nunomaduro/collision"' composer.json; then + PACKAGES_TO_INSTALL+=("nunomaduro/collision") +fi + +# 3. Tiến hành cài đặt +if [ ${#PACKAGES_TO_INSTALL[@]} -eq 0 ]; then + echo -e "${GREEN}🎉 Tuyệt vời! Dự án đã có đầy đủ các thư viện kiểm thử cần thiết!${NC}" + exit 0 +fi + +echo -e "${YELLOW}📦 Các gói cần cài đặt bổ sung:${NC}" +for pkg in "${PACKAGES_TO_INSTALL[@]}"; do + echo -e " - ${BLUE}$pkg${NC}" +done + +INSTALL_COMMAND="$COMPOSER_CMD require --dev ${PACKAGES_TO_INSTALL[*]} --with-all-dependencies" + +echo -e "\n${YELLOW}🚀 Đang thực thi lệnh cài đặt:${NC}" +echo -e " $INSTALL_COMMAND\n" + +eval "$INSTALL_COMMAND" + +echo -e "\n${GREEN}✅ Đã cài đặt hoàn tất các thư viện test cho dự án!${NC}"