Mục tiêu: Hiểu rõ Unit Test và Feature Test là gì, khi nào dùng loại nào, và chiến lược viết test hiệu quả nhất với thời gian hạn chế.
Ngữ cảnh: Laravel + PHPUnit/Pest · Backend Developer · Phục vụ Refactoring an toàn.
1. Testing Pyramid — Mô hình kim tự tháp kiểm thử
/ E2E \ ← Ít nhất, chậm nhất, đắt nhất
/----------\
/ Integration \ ← Vừa phải
/--------------\
/ Unit Tests \ ← Nhiều nhất, nhanh nhất, rẻ nhất
/------------------\
Chi phí phát hiện bug theo tầng
| Tầng test | Chi phí fix bug | Tốc độ | Số lượng nên có |
|---|---|---|---|
| Unit Test | ~$1 | ⚡ Milliseconds | ~70% tổng test suite |
| Feature/Integration Test | ~$10 | 🔄 Seconds | ~20% tổng test suite |
| E2E Test | ~$100 | 🐢 Minutes | ~10% tổng test suite |
| Bug ở Production | ~$1,000+ | 💀 Hours-Days | 0 (mục tiêu) |
2. Unit Test — Test đơn vị
2.1 Định nghĩa
2.2 Đặc điểm
| Đặc điểm | Mô tả |
|---|---|
| Scope | 1 method hoặc 1 class |
| Tốc độ | Cực nhanh (ms) |
| Database | ❌ Không cần — dùng mock/fake |
| HTTP Request | ❌ Không gọi route |
| Dependencies | Mock hoặc Stub |
| Thư mục Laravel | tests/Unit/ |
| Base class | PHPUnit\Framework\TestCase (pure) hoặc Tests\TestCase (nếu cần app) |
2.3 Khi nào viết Unit Test?
- ✅ Pure business logic — hàm tính toán, validate, transform data
- ✅ Service class methods — logic nghiệp vụ cốt lõi
- ✅ Helper / Utility functions — formatting, parsing, calculating
- ✅ Model accessors, mutators, scopes (khi logic phức tạp)
- ✅ Value Objects, DTOs — đảm bảo data mapping đúng
2.4 Ví dụ — Laravel Unit Test
Ví dụ 1: Pure Unit Test — Không cần Laravel app
// tests/Unit/Helpers/ReadingTimeCalculatorTest.php
namespace Tests\Unit\Helpers;
use App\Helpers\ReadingTimeCalculator;
use PHPUnit\Framework\TestCase; // PHPUnit thuần!
class ReadingTimeCalculatorTest extends TestCase
{
public function test_short_content_returns_1_minute(): void
{
$text = str_repeat('word ', 100); // 100 words
$this->assertEquals(1, ReadingTimeCalculator::calculate($text));
}
public function test_long_content_calculates_correctly(): void
{
$text = str_repeat('word ', 1000); // 1000 words ÷ 200 wpm = 5 phút
$this->assertEquals(5, ReadingTimeCalculator::calculate($text));
}
public function test_empty_content_returns_minimum(): void
{
$this->assertEquals(1, ReadingTimeCalculator::calculate(''));
}
}
🔍 Điểm chú ý:
- Extends
PHPUnit\Framework\TestCase→ không boot Laravel → chạy cực nhanh - Không cần DB, không cần HTTP → pure logic test
- Mỗi test case kiểm tra 1 scenario cụ thể
Ví dụ 2: Service Test với Mock
// tests/Unit/Services/PriceCalculatorTest.php
namespace Tests\Unit\Services;
use App\Services\PriceCalculator;
use App\Repositories\CouponRepository;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
public function test_calculate_total_without_discount(): void
{
$items = [
['price' => 100000, 'qty' => 2],
['price' => 50000, 'qty' => 1],
];
$calculator = new PriceCalculator();
$this->assertEquals(250000, $calculator->calculateTotal($items));
}
public function test_apply_coupon_reduces_total(): void
{
// Mock CouponRepository — không cần DB thật
$couponRepo = $this->createMock(CouponRepository::class);
$couponRepo->method('findByCode')
->with('SAVE10')
->willReturn(['discount_percent' => 10]);
$calculator = new PriceCalculator($couponRepo);
$total = $calculator->applyDiscount(500000, 'SAVE10');
$this->assertEquals(450000, $total);
}
public function test_invalid_coupon_returns_original_total(): void
{
$couponRepo = $this->createMock(CouponRepository::class);
$couponRepo->method('findByCode')
->willReturn(null);
$calculator = new PriceCalculator($couponRepo);
$total = $calculator->applyDiscount(500000, 'INVALID');
$this->assertEquals(500000, $total);
}
}
🔍 Điểm chú ý:
createMock()→ tạo object giả thay cho CouponRepository → không query DB- Test logic nghiệp vụ, không test framework
3. Feature Test — Test tính năng
3.1 Định nghĩa
3.2 Đặc điểm
| Đặc điểm | Mô tả |
|---|---|
| Scope | Cả luồng HTTP request → response |
| Tốc độ | Chậm hơn Unit (seconds) |
| Database | ✅ Có — dùng RefreshDatabase trait |
| HTTP Request | ✅ Gọi route thật qua $this->get(), $this->post()... |
| Dependencies | Chạy thật (có thể mock external services) |
| Thư mục Laravel | tests/Feature/ |
| Base class | Tests\TestCase (boot Laravel app) |
3.3 Khi nào viết Feature Test?
- ✅ CRUD routes — tạo, đọc, sửa, xóa qua HTTP
- ✅ Authentication & Authorization — login, register, middleware guard
- ✅ Validation — kiểm tra request validation rules
- ✅ API endpoints — JSON response format, status codes
- ✅ Complex user flows — checkout, registration multi-step
- ✅ Làm safety net cho refactoring — khóa hành vi hiện tại trước khi refactor
3.4 Ví dụ — Laravel Feature Test
Ví dụ 1: CRUD Feature Test
// tests/Feature/PostControllerTest.php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostControllerTest extends TestCase
{
use RefreshDatabase;
// --- INDEX ---
public function test_index_returns_paginated_posts(): void
{
Post::factory()->count(15)->create(['status' => 'published']);
$response = $this->getJson('/api/posts');
$response->assertOk()
->assertJsonCount(10, 'data') // 10 per page
->assertJsonStructure([
'data' => [['id', 'title', 'slug', 'excerpt']],
'meta' => ['current_page', 'last_page'],
]);
}
// --- STORE ---
public function test_authenticated_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/posts', [
'title' => 'Laravel Testing Guide',
'body' => 'A comprehensive guide to testing.',
]);
$response->assertStatus(201)
->assertJson(['data' => ['title' => 'Laravel Testing Guide']]);
$this->assertDatabaseHas('posts', [
'title' => 'Laravel Testing Guide',
'user_id' => $user->id,
]);
}
// --- STORE VALIDATION ---
public function test_create_post_requires_title(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/posts', [
'title' => '', // rỗng
'body' => 'Some content',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['title']);
}
// --- AUTH GUARD ---
public function test_unauthenticated_user_cannot_create_post(): void
{
$response = $this->postJson('/api/posts', [
'title' => 'Test',
'body' => 'Content',
]);
$response->assertStatus(401);
}
// --- UPDATE ---
public function test_author_can_update_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->putJson("/api/posts/{$post->id}", [
'title' => 'Updated Title',
]);
$response->assertOk();
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title',
]);
}
// --- DELETE ---
public function test_author_can_delete_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->deleteJson("/api/posts/{$post->id}");
$response->assertNoContent();
$this->assertSoftDeleted('posts', ['id' => $post->id]);
}
}
🔍 Điểm chú ý:
RefreshDatabase→ reset DB mỗi test → data sạch, không ảnh hưởng nhauactingAs($user)→ giả lập authenticationassertDatabaseHas→ kiểm tra data thực sự được lưu vào DB- Test cả happy path (tạo thành công) lẫn edge cases (validation fail, unauthorized)
Ví dụ 2: Feature Test cho Refactoring Safety Net
// Viết test này TRƯỚC KHI refactor hàm processOrder()
public function test_order_processing_happy_path(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/orders', [
'items' => [
['product_id' => 1, 'price' => 100000, 'qty' => 2],
['product_id' => 2, 'price' => 50000, 'qty' => 1],
],
'coupon' => 'SAVE10',
]);
$response->assertStatus(200)
->assertJson(['total' => 225000]); // (200000 + 50000) * 0.9
// Verify side effects
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'total' => 225000,
]);
// Verify email was sent
Mail::assertSent(OrderConfirmation::class);
}
🔍 Mục đích: "Khóa" hành vi hiện tại. Sau khi refactor processOrder(), chạy test → PASS = refactor thành công, FAIL = refactor sai.
4. So sánh Unit Test vs Feature Test
| Tiêu chí | Unit Test | Feature Test |
|---|---|---|
| Scope | 1 hàm / 1 class | Cả luồng HTTP |
| Tốc độ | ⚡ Rất nhanh (ms) | 🔄 Chậm hơn (giây) |
| Database | Không cần (mock) | Cần (RefreshDatabase) |
| Phát hiện bug | Bug logic cụ thể trong 1 hàm | Bug integration giữa các layer |
| Chi phí viết | Thấp (code ngắn, không setup) | Trung bình (cần factory, setup data) |
| Chi phí maintain | Cao hơn (dễ vỡ khi refactor internal) | Thấp hơn (test behavior, không test implementation) |
| Phù hợp cho Refactoring | ⚠️ Có thể cần sửa test sau refactor | ✅ Lý tưởng — test behavior không đổi |
| Khi nào dùng | Logic tính toán phức tạp, utility | CRUD routes, auth, validation, user flows |
5. Chiến lược ưu tiên — Viết test gì trước khi thời gian hạn chế?
5.1 Tier System — Hệ thống phân tầng ưu tiên
🔴 Tier 1: BẮT BUỘC — Viết ngay (chiếm ~80% giá trị bảo vệ)
① Feature Test cho CRUD Routes — tests/Feature/<Resource>ControllerTest.php
Tại sao ưu tiên cao nhất?
- 1 file Feature Test cover được toàn bộ luồng: routing → middleware → validation → controller → service → model → DB
- Phát hiện bug ở mọi layer chỉ với 1 test
- Là safety net tốt nhất cho refactoring — test behavior, không test implementation
Effort: ~15-20 test cases / resource · ~2-3 giờ
Bao gồm:
- Happy path cho mỗi CRUD action (index, show, store, update, destroy)
- Validation fail cases (required, max, unique...)
- Authentication guard (401 nếu chưa login)
- Authorization (403 nếu không có quyền)
② Unit Test cho Service Layer — tests/Unit/Services/<Service>Test.php
Tại sao?
- Service chứa toàn bộ business logic (nếu theo "thin controller, fat service")
- Nếu service sai → mọi thứ sai
- Chạy nhanh, dễ viết, feedback loop ngắn
Effort: ~10-12 test cases / service · ~1-2 giờ
🟡 Tier 2: NÊN CÓ — Viết sau khi Tier 1 xong
③ Model Test — Scopes + Slug + Complex Accessors
- Scopes ảnh hưởng trực tiếp đến data user nhìn thấy (
scopePublished,scopeDraft) - Slug ảnh hưởng SEO & URL routing
- Chỉ cần ~5-6 test cases
- Bỏ qua relationships, casts, fillable → Feature test đã gián tiếp cover
④ Form Request Validation Test
- Validation sai → data rác vào DB, hoặc user không submit được
- ~8 test cases cho required, max, unique, format...
🟢 Tier 3: NICE-TO-HAVE — Viết khi có thời gian
⑤ Helper / Utility Tests — Logic đơn giản, ít thay đổi, bug không critical
⑥ Policy Tests — Quan trọng nếu multi-user, bỏ qua nếu single-author blog
⑦ E2E Browser Tests (Dusk) — Đắt nhất, chậm nhất, chỉ cần cho critical user flows
5.2 Tóm tắt chiến lược
| # | Test file | Loại | Effort | Giá trị bảo vệ | Khi nào |
|---|---|---|---|---|---|
| 1 | ResourceControllerTest | Feature | 🟡 2-3h | ⭐⭐⭐⭐⭐ | Ngay |
| 2 | ServiceTest | Unit | 🟢 1-2h | ⭐⭐⭐⭐ | Ngay |
| 3 | ModelTest (scopes only) | Unit | 🟢 30m | ⭐⭐⭐ | Sau Tier 1 |
| 4 | FormRequestTest | Unit | 🟢 1h | ⭐⭐⭐ | Sau Tier 1 |
| 5 | HelperTest | Pure Unit | 🟢 15m | ⭐⭐ | Khi rảnh |
| 6 | PolicyTest | Unit | 🟢 30m | ⭐ | Khi cần auth |
6. Viết test tốn thời gian? — Phân tích ROI thực tế
6.1 Chi phí thực sự của "không viết test"
Không viết test → Ship feature nhanh hơn 3-5 giờ
↓
Refactor không an toàn → Sợ sửa code → Code càng bẩn
↓
Bug lọt ra production → Debug 2-4 giờ/bug
↓
Regression bug (fix chỗ này vỡ chỗ khác) → 1-2 giờ/bug
↓
🔁 Tổng chi phí sau 3 tháng: 20-40 giờ debug > 5 giờ viết test
6.2 So sánh hai approach
| Tiêu chí | ❌ Không viết test | ✅ Viết test Tier 1 |
|---|---|---|
| Thời gian ban đầu | Tiết kiệm 3-5 giờ | Tốn thêm 3-5 giờ |
| Tự tin khi refactor | ❌ Sợ, không dám sửa | ✅ Chạy test → PASS → an tâm |
| Thời gian debug/tháng | ~10-15 giờ | ~2-3 giờ |
| Regression bugs | Thường xuyên | Hiếm khi |
| Sau 3 tháng | -20 giờ (nợ debug) | +15 giờ (tiết kiệm ròng) |
6.3 Mẹo viết test nhanh hơn
- Dùng Pest PHP thay PHPUnit — syntax ngắn hơn ~30%, dễ đọc hơn
- Dùng Factory tốt —
User::factory()->create()thay vì setup thủ công - Copy template test — tạo 1 test file mẫu, copy cho các resource khác
- GitHub Copilot — AI sinh test case từ method signature cực nhanh
- Chỉ test behavior, không test implementation — tránh test vụn vặt, dễ vỡ
7. Mối liên hệ với Refactoring
Chiến lược test cho Refactoring
| Giai đoạn | Loại test | Mục đích |
|---|---|---|
| TRƯỚC refactor | Feature Test | "Khóa" observable behavior — đảm bảo output không đổi |
| TRONG refactor | Chạy lại Feature Test | Mỗi thay đổi nhỏ → chạy test → PASS? → commit |
| SAU refactor | Unit Test | Viết Unit Test cho các hàm nhỏ vừa tách ra |
Tại sao Feature Test trước, Unit Test sau?
- Feature Test test hành vi bên ngoài → không bị ảnh hưởng khi bạn đổi cấu trúc bên trong
- Unit Test test implementation cụ thể → dễ vỡ khi refactor (đổi tên method, tách class...)
- → Feature Test là safety net ổn định cho refactoring
- → Unit Test viết sau để bảo vệ code mới đã sạch
8. Các loại test khác cần biết
| Loại test | Mô tả | Tool trong Laravel | Ưu tiên |
|---|---|---|---|
| Unit Test | Test 1 hàm/class cô lập | PHPUnit / Pest | 🔴 Cao |
| Feature Test | Test luồng HTTP đầy đủ | PHPUnit / Pest + TestCase | 🔴 Cao |
| Browser Test (E2E) | Test UI thật qua browser | Laravel Dusk / Cypress | 🟢 Thấp |
| Contract Test | Test API contract giữa services | Pact / custom assertions | 🟡 Khi có microservices |
| Mutation Test | Kiểm tra chất lượng test suite | Infection PHP | 🟢 Nâng cao |
| Load/Stress Test | Test hiệu năng dưới tải cao | k6 / Artillery / JMeter | 🟡 Khi scale |
| Static Analysis | Phát hiện bug không cần chạy code | PHPStan / Larastan | 🔴 Cao (miễn phí, tự động) |
9. Checklist — Bắt đầu viết test cho project
- Cài PHPStan/Larastan (static analysis) — phát hiện bug miễn phí
- Tạo file Feature Test đầu tiên cho resource quan trọng nhất
- Viết test cho happy path CRUD (index, show, store, update, destroy)
- Thêm test cho validation errors (422)
- Thêm test cho auth guard (401, 403)
- Tạo file Unit Test cho Service class chính
- Test business logic cốt lõi (calculate, transform, validate)
- Chạy
php artisan testvà đảm bảo tất cả PASS ✅ - Thêm test vào CI/CD pipeline (GitHub Actions)
- Dần bổ sung Model Test, Request Test khi có thời gian