Hướng Dẫn Tránh Bẫy Khi Viết Unit Test PHP: 8 Vấn Đề Thường Gặp và Giải Pháp

Giá trị cốt lõi và cách tiếp cận mới với unit test trong PHP

Trong phát triển phần mềm hiện đại, unit test không còn là công cụ tùy chọn mà đã trở thành hạ tầng thiết yếu để đảm bảo chất lượng và tăng hiệu quả hợp tác giữa các thành viên. Việc viết test không chỉ nhằm xác minh hành vi của các hàm hoặc lớp, mà còn đóng vai tròthen chốt trong quy trình CI/CD, hỗ trợ重构, và tạo tài liệu sống động.

Vì sao unit test là điều kiện cần?

  • Tăng độ tin cậy: Tự động kiểm tra các logic cốt lõi giúp giảm rủi ro do người dùng (dev) bỏ漏.
  • Hỗ trợ refactor an toàn: Khi hệ thống phát triển, bộ test giúp đảm bảo chức năng cũ không bị ảnh hưởng sau khi thay đổi.
  • Là tài liệu thực thi được: Test code trực quan thể hiện cách sử dụng API, hiệu quả hơn so với comment.

Sân chơi từ "lắp ráp – check thủ công" sang "dự báo – kiểm chứng"

Trước đây, nhiều hệ thống phụ thuộc vào log hoặc kiểm tra bằng tay. Ngược lại, unit test thúc đẩy tư duy lập trình theo mô hình "giả định – khẳng định". Ví dụ, một lớp tính toán đơn giản:

// Calculator.php
class Calculator {
    public function add(int $a, int $b): int {
        return $a + $b;
    }
}

Cần đi kèm với test case rõ ràng:

// CalculatorTest.php
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase {
    public function testAddsTwoNumbersCorrectly(): void {
        $calc = new Calculator();
        $result = $calc->add(2, 3);
        self::assertEquals(5, $result, '2 + 3 phải bằng 5');
    }
}

wherever test định hình kiến trúc hệ thống

Để code có thể test được, lập trình viên buộc phải áp dụng các nguyên tắc như dependency injection, single responsibility... Điều này vô hình trung thúc đẩy hệ thống trở nên module và dễ bảo trì hơn.

Yếu tố Không có test Có unit test
Mức độ耦合 Cao, khó kiểm tra từng thành phần Thấp, rõ ràng và dễ thay thế phụ thuộc
Thời điểm phát hiện lỗi Giai đoạn build hoặc production Ngay sau commit (CI feedback)
Độ tự tin khi refactor Thấp, dễ引入 регresses Cao, yếu tố kiểm chứng được đảm bảo

Thiết lập PHPUnit và những bước đầu tiên

Cài đặt PHPUnit trong dự án sử dụng Composer

Để tích hợp PHPUnit vào quy trình phát triển, cần thêm thư viện vào phần dev dependencies thông qua lệnh sau:

composer require --dev phpunit/phpunit ^10

Giờ đây, PHPUnit 10 đã sẵn sàng để sử dụng trong môi trường phát triển, không làm ảnh hưởng môi trường production.

Thiết lập cấu hình test suite cơ bản

Tạo file cấu hình phpunit.xml để định nghĩa cách chạy test và đường dẫn thư mục chứa các file test:

<phpunit bootstrap="vendor/autoload.php"
         colors="true"
         failOnRisky="true"
         failOnWarning="true">
  <testsuites>
    <directory suffix="Test.php">tests/Unit</directory>
  </testsuites>
  <source>
    <include>
      <directory suffix=".php">src</directory>
    </include>
  </source>
</phpunit>

Chi tiết cấu hình này giúp chỉ định thư mục chứa source code và test, đồng thời tối ưu việc chạy tự động qua CI.

Câu lệnh kiểm thử §101: Viết và chạy test đầu tiên

Giả sử có một class tính điểm trung bình như sau:

// src/AverageCalculator.php
namespace App;

class AverageCalculator {
    public function calculate(array $scores): float {
        if (empty($scores)) {
            throw new InvalidArgumentException("Danh sách điểm không được rỗng");
        }
        return array_sum($scores) / count($scores);
    }
}

Test case Kiểm tra giá trị hợp lệ:

// tests/Unit/AverageCalculatorTest.php
namespace App\Tests\Unit;

use App\AverageCalculator;
use PHPUnit\Framework\TestCase;

class AverageCalculatorTest extends TestCase {
    public function testCalculatesAverageOfValidScores(): void {
        $calc = new AverageCalculator();
        $avg = $calc->calculate([8.5, 9.0, 7.5]);
        self::assertEquals(8.3333333333333, $avg, '', 0.0001);
    }

    public function testThrowsExceptionOnEmptyInput(): void {
        $calc = new AverageCalculator();
        $this->expectException(InvalidArgumentException::class);
        $calc->calculate([]);
    }
}

Chạy toàn bộ test bằng lệnh:

./vendor/bin/phpunit

Quản lý trạng thái và dữ liệu trong quá trình test

Thiết lập và dọn dẹp môi trường: setUp / tearDown

Hai phương thức quan trọng giúp đảm bảo từng test chạy trong môi trường sạch và độc lập:

class UserServiceTest extends TestCase {
    private $userRepoMock;
    protected function setUp(): void {
        $this->userRepoMock = $this->createMock(UserRepository::class);
        $this->userService = new UserService($this->userRepoMock);
    }

    protected function tearDown(): void {
        $this->userRepoMock = null;
        $this->userService = null;
    }
}

Mỗi phương thức test sẽ tự động được bao bọc bởi setUp() trước khi chạy và tearDown() sau khi hoàn thành. Thao tác này giúp loại bỏ rủi ro về shared state giữa các test case.

Data Provider – Tăng hiệu quả kiểm thử đa biến

Thay vì viết nhiều test case lặp lại, data provider cho phép dùng cùng một logic test với nhiều tập dữ liệu:

public function feedDataForAvgCalc(): array {
    return [
        'positive numbers' => [[1, 2, 3, 4], 2.5],
        'negative values' => [[-5, -10], -7.5],
        'mixed values' => [[-2, 5, 3], 2],
        'single item' => [[42], 42],
    ];
}

/**
 * @dataProvider feedDataForAvgCalc
 */
public function testComputesCorrectAverage(array $input, float $expected): void {
    $calc = new AverageCalculator();
    $result = $calc->calculate($input);
    self::assertSame($expected, $result, message: 'Giá trị trả về sai');
}

Mỗi entry trong data provider là một bộ input – output mẫu, giúp kiểm tra toàn diện hơn và mở rộng dễ dàng trong tương lai.


Mocking và ảnh hưởng đến thiết kế hệ thống

Nguyên lý và ứng dụng của mock object

Mock object là đối tượng giả lập hành vi của một real dependency (ví dụ như API, database, mailer...), dùng để cô lập logic đang test khỏi môi trường bên ngoài.

Ví dụ với client gọi API:

interface HttpClient {
    public function get(string $url): string;
}

class WeatherService {
    private HttpClient $client;

    public function __construct(HttpClient $client) {
        $this->client = $client;
    }

    public function getTemperature(string $city): float {
        $response = $this->client->get("https://api.weather.com/$city");
        return json_decode($response, true)['temp'] ?? 0.0;
    }
}

Khi test WeatherService, không cần gọi vào API thật – chỉ cần mock HttpClient:

class WeatherServiceTest extends TestCase {
    public function testReturnsTemperatureFromMockedResponse(): void {
        $httpClientMock = $this->createMock(HttpClient::class);
        $httpClientMock->method('get')
            ->willReturn('{"temp": 23.5}');

        $service = new WeatherService($httpClientMock);
        $temp = $service->getTemperature('Hanoi');

        self::assertEquals(23.5, $temp);
    }
}

Prophecy – Tool linh hoạt hơn cho việc mô phỏng hành vi phức tạp

Ngoài createMock(), PHPUnit là wrapper của thư viện phpspec/prophecy, giúp dễ dàng mô phỏng degraded behavior:

composer require --dev phpspec/prophecy-phpunit

Ví dụ:

$logger = $this->prophesize(LoggerInterface::class);
$logger->info('Processing order #12345')->shouldBeCalled();

$orderService = new OrderService($logger->reveal());
$orderService->process(12345);

Đảm bảo rằng phương thức log chỉ được gọi đúng một lần với nội dung chính xác.

Tránh "over-mocking" – giữ đúng giới hạn cô lập

Việc mock toàn bộ flow nội bộ khiến test mất khả năng phản ánh đúng hành vi thực of hệ thống. Chỉ nên mock phụ thuộc ngoài:

class CartServiceTest extends TestCase {
    public function testTotalIncludesTaxCorrectly(): void {
        $taxCalculator = new RealTaxCalculator(); // không mockInside
        $cart = new CartService($taxCalculator);
        
        $total = $cart->calculateTotal(100000);
        self::assertGreaterThan(100000, $total);
    }
}

Chiến lược test cho scenarios phức tạp

Tách biệt logic và persistence layer để dễ test hơn

Một mẫu code thường gặp:

class UserRepository {
    public function find(int $id): array|null {
        // gọi PDO trực tiếp => khó test
    }
}

→ Nên xóa bỏ logic.GetAllInsideRepository, thay vào đó:

interface UserRepositoryInterface {
    public function findById(int $id): array|null;
    public function save(array $user): void;
}

class DbUserRepository implements UserRepositoryInterface {
    private PDO $pdo;

    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    public function findById(int $id): array|null {
        // logic DB ở đây
    }
}

class UserService {
    private UserRepositoryInterface $repo;

    public function __construct(UserRepositoryInterface $repo) {
        $this->repo = $repo;
    }

    public function getUser(int $id): array|null {
        return $this->repo->findById($id);
    }
}

Khi test UserService, inject MockObject thay vì cần DB thật:

$mockRepo = $this->createMock(UserRepositoryInterface::class);
$mockRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Alice']);

$userService = new UserService($mockRepo);
$user = $userService->getUser(1);

self::assertEquals('Alice', $user['name']);

Kiểm tra exception và边界 condition một cách chủ động

Code intro đã ⇒develop robust exception handling pattern:

public function divide(float $a, float $b): float {
    if ($b == 0) {
        throw new DivisionByZeroError("Không thể chia cho 0");
    }
    return $a / $b;
}

Test case liên quan:

public function testThrowsErrorOnZeroDivisor(): void {
    $math = new MathUtils();
    $this->expectException(DivisionByZeroError::class);
    $math->divide(10, 0);
}
Ví dụ test boundary case:
Input A Input B Expected Result
99999999 1 99999999
-10000000 -1 10000000
INF 2 INF
10 0 Exception

Góc nhìn dài hạn: Văn hóa test bền vững

Xây dựng luồng phản hồi tự động

Dùng CI/CD pipeline để chạy test tự động mỗi lần commit:

# .gitlab-ci.yml
test:
  image: php:8.3-cli
  script:
    - composer install --no-interaction --prefer-dist
    - ./vendor/bin/phpunit --coverage-text --colors=always
  coverage: '/^Coverage: \s*\K\d+(\.\d+)?%/'
Đo lường bằng chỉ số minh bạch
Chỉ số Mục tiêu Thực tế Phụ trách
Tỷ lệ phủ test đơn vị ≥ 80% 76% Team Backend
Tỷ lệ pass API test ≥ 95% 98.2% QA Team
Thời gian phục hồi SLA ≤ 2h 3h17p DevOps + Dev
Đào tạo test cho người mới và nâng cao
  • Tổ chức workshop "Designing testable code with DI"
  • Each new member completes an offboarding task: Viết và chạy test cho module services
  • Agenda 2 buổi/mỗi quý: "Sandboxing external APIs", "Stateful domain testing in PHP"

Các hoạt động trên giúp xây dựng thói quen test trước, fix sau — nền tảng cho hệ thống bền vững.

Thẻ: PHPUnit mocking prophecy dependency-injection TDD

Đăng vào ngày 29 tháng 5 lúc 05:00