Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP

PHP EP 21: Tổ chức dự án và kiểm thử tự động PHP

Thiết kế cấu trúc thư mục dự án PHP tách biệt nghiệp vụ và giao diện, thực hành viết kịch bản kiểm thử tự động xác minh nhánh lỗi và đọc Stack Trace để gỡ lỗi.

Kiểm thử tự động giúp phát hiện lỗi lặp lại

Khi dự án mới có vài trăm dòng mã, lập trình viên thường kiểm tra tính năng bằng cách mở trình duyệt, nhập thử biểu mẫu, rồi chèn var_dump($x); die(); vào giữa hàm để xem giá trị biến. Tuy nhiên, khi ứng dụng tăng lên 20 lớp dịch vụ và cần sửa đổi một quy tắc tính toán ở tầng lõi, việc bấm tay lại toàn bộ màn hình là bất khả thi. Hệ quả là sửa xong lỗi ở phân hệ A lại vô tình làm hỏng phân hệ B (lỗi hồi quy - regression), hoặc quên xóa lệnh var_dump() trước khi đẩy mã lên Production. Mặt khác, nếu trộn lẫn mã HTML, đọc $_POST và tính toán nghiệp vụ vào chung một tệp, bạn không thể chạy kiểm thử tự động từ dòng lệnh CLI.

Bài học áp chót của khóa học hướng dẫn bạn tổ chức cấu trúc thư mục dự án PHP 8.4 theo kiến trúc phân lớp (tách biệt public/, src/ và tests/), áp dụng mô hình kiểm thử tự động Arrange - Act - Assert (AAA) kiểm chứng cả nhánh thành công lẫn nhánh ném ngoại lệ, và kỹ năng đọc ngược Stack Trace để định vị lỗi chính xác. Bài viết kế thừa kiến thức từ PHP EP 14: Namespace, PSR-4 và Composer trong PHP cùng PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP, chuẩn bị cho bài tổng hợp tại PHP EP 22: Xây dựng ứng dụng Ticket bằng PHP và PDO. Tài liệu tham chiếu gồm PHP Manual: Assertions, Error Reporting và PHPUnit Documentation.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) và Composer version 2.10.3 trên Alpine Linux 3.21 (aarch64).
  • Giới hạn kiểm chứng: Kiểm chứng cấu trúc dự án PSR-4, kịch bản kiểm thử tự động với zend.assertions=1 và phân tích Stack Trace trong container cô lập; không tuyên bố kiểm chứng cấu hình trình gỡ lỗi bước Xdebug qua mạng TCP.

Cấu trúc dự án và mô hình Arrange, Act, Assert

Bảng dưới đây tổng hợp hợp đồng kỹ thuật của từng lớp thư mục và công cụ kiểm chứng trong dự án PHP 8.4:

Thành phần / Công cụ Đường dẫn / Cú pháp chuẩn Vai trò & Hành vi thực tế trong PHP 8.4 Điều kiện / Không nên giả định
Thư mục public/ public/index.php (DocumentRoot duy nhất của Web Server) Chứa điểm vào HTTP duy nhất và tài sản tĩnh; nạp ../vendor/autoload.php rồi chuyển điều khiển vào src/. Tuyệt đối không đặt tệp .env, composer.json, src/ hay tệp .sqlite bên trong thư mục public/!
Thư mục src/ Ánh xạ PSR-4 "App\\": "src/" Chứa các lớp Thực thể (Domain), Kho lưu trữ (Repository) và Dịch vụ (Service) độc lập giao diện. Các lớp trong src/ không được đọc trực tiếp $_GET, $_POST để có thể kiểm thử dễ dàng từ CLI.
Thư mục tests/ Ánh xạ "Tests\\": "tests/" trong autoload-dev Chứa kịch bản kiểm thử đơn vị (Unit Test) và kiểm thử tích hợp (Integration Test với sqlite::memory:). Mỗi bài kiểm thử phải tự tạo dữ liệu mẫu riêng (isolated state), không phụ thuộc vào thứ tự chạy.
Kiểm tra khẳng định (assert) php -d zend.assertions=1 -d assert.exception=1 Khi bật zend.assertions=1, biểu thức assert($actual === $expected, 'Msg') ném ngay AssertionError nếu sai. Trên Production, zend.assertions thường đặt là -1 (bỏ qua lúc biên dịch), nên không bao giờ dùng assert() để kiểm tra dữ liệu người dùng!
Phân tích Stack Trace $e->getFile(), $e->getLine(), $e->getTrace() Liệt kê chuỗi lời gọi hàm từ điểm phát sinh lỗi (`#0`) lùi dần về điểm vào chương trình (`{main}`). Khi gỡ lỗi, luôn đọc dòng thông báo lỗi và dòng #0 thuộc thư mục src/ trước tiên.

Tách phụ thuộc để mã nghiệp vụ dễ kiểm thử

Bí quyết để viết mã nguồn dễ kiểm thử nằm ở nguyên tắc Tiêm phụ thuộc (Dependency Injection) và tách biệt tính toán khỏi giao vận: thay vì để lớp dịch vụ tự khởi tạo kết nối cơ sở dữ liệu cứng bên trong hàm, bạn truyền đối tượng PDO (hoặc Repository) qua hàm tạo __construct(). Khi chạy kiểm thử tự động trên CI/CD, bạn chỉ cần truyền new PDO('sqlite::memory:') chạy hoàn toàn trong RAM.

Mỗi ca kiểm thử tự động luôn tuân theo cấu trúc 3 bước Arrange - Act - Assert (AAA):

  1. Arrange (Chuẩn bị): Khởi tạo đối tượng cần kiểm thử (SlaCalculator) và chuẩn bị dữ liệu đầu vào xác định.
  2. Act (Thực thi): Gọi đúng phương thức mục tiêu ($hours = $calc->deadlineHours(1)) và thu nhận kết quả.
  3. Assert (Đối chiếu): So sánh chặt kết quả thực tế với kỳ vọng (assert($hours === 2)), đồng thời kiểm tra cả nhánh lỗi bằng try / catch để xác nhận phương thức ném đúng loại ngoại lệ như cam kết.
Chu trình viết kiểm thử tự động: chuẩn bị dữ liệu (Arrange), thực thi (Act), đối chiếu (Assert).
Hình 1. Chu trình viết kiểm thử tự động: chuẩn bị dữ liệu (Arrange), thực thi (Act), đối chiếu (Assert). Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Tạo dự án PSR-4 và lớp nghiệp vụ

Chúng ta sẽ khởi tạo cấu trúc thư mục src/ và tests/, viết lớp nghiệp vụ App\SlaCalculator tính thời hạn xử lý sự cố theo mức độ nghiêm trọng.

Bước 1: Tạo vùng làm việc tạm và cấu trúc thư mục src/, tests/

Mục đích: Khởi tạo không gian dự án chuẩn trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep21-XXXXXX)"
cd "$WORKDIR"
mkdir -p "$WORKDIR/src" "$WORKDIR/tests"
pwd

Bước 2: Tạo composer.json và lớp nghiệp vụ src/SlaCalculator.php

Mục đích: Khai báo autoloading PSR-4 và viết lớp tính cam kết thời gian phản hồi (SLA) sử dụng biểu thức match kết hợp throw của PHP 8.4.

cat <<'EOF' > "$WORKDIR/composer.json"
{
    "name": "thanhnh/php-ep21-lab",
    "autoload": { "psr-4": { "App\\": "src/" } }
}
EOF

cat <<'EOF' > "$WORKDIR/src/SlaCalculator.php"
<?php
declare(strict_types=1);

namespace App;

use InvalidArgumentException;

final class SlaCalculator
{
    public function deadlineHours(int $severity): int
    {
        return match ($severity) {
            1 => 2,
            2 => 8,
            3 => 24,
            default => throw new InvalidArgumentException("Unsupported severity: {$severity}"),
        };
    }
}
EOF

composer dump-autoload --working-dir="$WORKDIR" --quiet

Kiểm tra nhánh thành công và nhánh ngoại lệ

Sau khi có lớp nghiệp vụ trong src/, chúng ta viết kịch bản kiểm thử trong tests/run_tests.php tách biệt hoàn toàn để kiểm chứng tự động mọi nhánh logic.

Bước 3: Tạo và thực thi kịch bản kiểm thử tự động tests/run_tests.php

Mục đích: Kiểm chứng tự động 2 trường hợp hợp lệ (severity = 1, severity = 3) và 1 trường hợp ngoại lệ (severity = 99).

cat <<'EOF' > "$WORKDIR/tests/run_tests.php"
<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\SlaCalculator;

$calc = new SlaCalculator();
assert($calc->deadlineHours(1) === 2, 'Severity 1 must be 2h');
assert($calc->deadlineHours(3) === 24, 'Severity 3 must be 24h');

$caught = false;
try {
    $calc->deadlineHours(99);
} catch (InvalidArgumentException) {
    $caught = true;
}
assert($caught === true, 'Invalid severity must throw');

echo "TestsPassed=3 | CaughtInvalid=true\n";
EOF

php -d zend.assertions=1 -d assert.exception=1 "$WORKDIR/tests/run_tests.php"

Kết quả quan sát được:

  • Đầu ra hiển thị: TestsPassed=3 | CaughtInvalid=true.
  • Cú pháp catch (InvalidArgumentException) kiểm tra chính xác rằng truyền mức độ 99 vào deadlineHours() chắc chắn bị chặn bởi InvalidArgumentException.

Cảnh báo an toàn: Khi chạy kiểm thử bằng assert(), luôn truyền cờ -d zend.assertions=1 -d assert.exception=1 trên CLI để đảm bảo mọi biểu thức assert() đều được kích hoạt.

Đọc Stack Trace và kiểm tra nhánh thất bại

Khi một bài kiểm thử thất bại (AssertionError) hoặc ứng dụng ném ngoại lệ chưa bắt, PHP xuất ra một Stack Trace (dấu vết ngăn xếp). Hãy thử cố tình làm sai một kỳ vọng để đọc cấu trúc của Stack Trace và kiểm chứng mã thoát CLI.

Bước 4: Kích hoạt lỗi AssertionError có kiểm soát và phân tích Stack Trace

Mục đích: Quan sát cách PHP 8.4 báo cáo chính xác tệp, số dòng, thông điệp khẳng định và trả về mã thoát 255 (giúp CI/CD chặn bản dựng lỗi).

set +e
php -d zend.assertions=1 -d assert.exception=1 -r '
require "'"$WORKDIR"'/vendor/autoload.php";
$calc = new App\SlaCalculator();
assert($calc->deadlineHours(2) === 999, "Expected 8h for severity 2");
'
RC=$?
set -e
echo "RC=$RC"

Kết quả quan sát được:

  • Tiến trình dừng ngay lập tức với RC=255 kèm thông báo: Fatal error: Uncaught AssertionError: Expected 8h for severity 2 in Command line code:4.
  • Dòng đầu tiên cho biết nơi ngoại lệ được ném ra; các dòng #0, #1 tiếp theo liệt kê ngược lại chuỗi hàm đã gọi dẫn tới điểm lỗi đó.
Kiến trúc phân lớp tách biệt logic miền nghiệp vụ khỏi lớp giao vận CLI và HTTP.
Hình 2. Kiến trúc phân lớp tách biệt logic miền nghiệp vụ khỏi lớp giao vận CLI và HTTP. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Mở rộng với PHPUnit, PHPStan và CI/CD

Trong các dự án doanh nghiệp, quy trình kiểm định tự động gồm 3 tầng:

  1. Bộ khung kiểm thử PHPUnit / Pest: Cài đặt qua composer require --dev phpunit/phpunit để dùng $this->assertSame(), $this->expectException() và đo độ bao phủ mã (Code Coverage).
  2. Phân tích mã tĩnh (Static Analysis) với PHPStan: Quét toàn bộ cây cú pháp mã nguồn PHP 8.4 để phát hiện lỗi lệch kiểu dữ liệu mà chưa cần chạy chương trình.
  3. Ghi nhật ký gỡ lỗi ra stderr thay cho var_dump(): Dùng error_log(json_encode($context, JSON_UNESCAPED_UNICODE)) để in ra stderr mà không làm hỏng luồng JSON/HTML trả về trên stdout.

Tự kiểm tra và giới hạn

Hãy tự kiểm tra lại kiến thức qua 4 câu hỏi tình huống:

  • Câu hỏi 1: Vì sao chỉ thư mục public/ được cấu hình làm DocumentRoot của Web Server còn src/, vendor/, tests/ phải nằm bên ngoài public/? -> Để ngăn người dùng bên ngoài truy cập trực tiếp qua URL vào các tệp cấu hình, mã nguồn lớp hoặc tệp cơ sở dữ liệu nội bộ.
  • Câu hỏi 2: Tại sao không bao giờ được dùng hàm assert($amount > 0) để kiểm tra dữ liệu đầu vào của người dùng trên Production? -> Vì trên Production zend.assertions thường đặt bằng -1 khiến câu lệnh assert() bị trình biên dịch bỏ qua hoàn toàn! Phải dùng if (...) { throw new InvalidArgumentException(...); }.
  • Câu hỏi 3: Ba chữ cái AAA trong quy trình viết ca kiểm thử tự động đại diện cho những bước nào? -> Arrange (Chuẩn bị), Act (Thực thi) và Assert (Đối chiếu kết quả).
  • Câu hỏi 4: Làm sao kiểm thử lớp Repository dùng PDO nhanh chóng mà không làm bẩn tệp DB thật? -> Truyền kết nối new PDO('sqlite::memory:') vào hàm tạo của Repository trong bài kiểm thử.

Hoàn thành bài học này, bạn đã có đầy đủ tư duy tổ chức dự án và kiểm thử tự động. Hãy bước vào bài học cuối cùng PHP EP 22: Xây dựng ứng dụng Ticket bằng PHP và PDO để kết nối toàn bộ kiến thức thành một ứng dụng hoàn chỉnh.

Ôn bài trước: PHP EP 20: Transaction PDO, khóa ngoại và Repository trong PHP. Học tiếp: PHP EP 22: Xây dựng ứng dụng Ticket bằng PHP và PDO.

Bài liên quan trong bộ PHP

Tìm hiểu thêm