Unit Test & Feature Test — Hướng dẫn chi tiết & Chiến lược ưu tiên

15 phút đọc

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\TestCasekhô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 nhau
  • actingAs($user) → giả lập authentication
  • assertDatabaseHas → 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 Routestests/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
  • 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 Layertests/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

  1. Dùng Pest PHP thay PHPUnit — syntax ngắn hơn ~30%, dễ đọc hơn
  2. Dùng Factory tốtUser::factory()->create() thay vì setup thủ công
  3. Copy template test — tạo 1 test file mẫu, copy cho các resource khác
  4. GitHub Copilot — AI sinh test case từ method signature cực nhanh
  5. 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 test và đả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

Tham khảo

Bài viết liên quan

Đang cập nhật...