Cơ chế chạy test của project laravel-livewire

Tài liệu này mổ xẻ đúng bộ test đang chạy trong repo laravel-livewire: 56 file test, chạy trên PostgreSQL thật (không phải SQLite), mỗi lần chạy tự tạo một database dùng-một-lần rồi xoá đi. Và luồng đầy đủ khi GitLab CI bấm nút chạy nó.

Câu chốt để nói trong phỏng vấn: "Suite chạy trên chính engine database mà production dùng. Nó không bao giờ chạm được vào database thật vì có 3 lớp chặn độc lập, và lớp chặn nằm trước câu lệnh phá huỷ đầu tiên chứ không phải sau."

Từ điển thuật ngữ dùng trong bài

Thuật ngữNghĩa ngắn
Test suiteToàn bộ tập test chạy chung một lượt.
FixtureDữ liệu dựng sẵn để test có cái mà chạy.
FactoryLớp sinh dữ liệu giả cho một model (ProductFactory…).
Disposable database (database dùng-một-lần)Database tạo ra lúc bắt đầu chạy test, xoá lúc kết thúc.
Transaction rollback (huỷ giao dịch)Mở transaction đầu test, cuối test huỷ — dữ liệu test biến mất mà không cần xoá bảng.
EXIT trapBẫy của bash: chạy một hàm khi script thoát, kể cả khi lỗi hay bị Ctrl-C.
Multi-stage buildDockerfile nhiều tầng; mỗi tầng là một image riêng, chọn tầng nào thì build tầng đó.
Trust boundary (ranh giới tin cậy)Chỗ dữ liệu từ client đi vào server — mọi thứ vượt qua đó phải validate lại.
Orphan database (database mồ côi)Database test còn sót vì tiến trình bị giết trước khi kịp dọn.
Build once, promoteBuild image đúng 1 lần, các môi trường sau dùng lại đúng bytes đó, không build lại.

1. Nó là gì / chạy thế nào

1.1. Bức tranh tổng thể

Có đúng một lệnh để chạy test: composer test. Đằng sau nó là 5 lớp, mỗi lớp một việc:

flowchart TD
  A["composer test"] --> B["scripts/test-postgres.sh
(bash — vòng đời database)"] B --> C["php artisan pgtest:db
name / create / drop / prune"] C --> D["App Testing PostgresTestDatabase
(nơi duy nhất có logic chặn)"] D --> E[("PostgreSQL server
container chiennn_commerce_postgres")] B --> F["vendor/bin/phpunit
--configuration=phpunit.xml"] F --> G["Tests TestCase
(kiểm tra lại kết nối mỗi test)"] G --> H["RefreshDatabase
(migrate 1 lần + transaction mỗi test)"] H --> E style D fill:#3b2f1e,stroke:#d9a441 style G fill:#3b2f1e,stroke:#d9a441

Điểm cần nhớ: bash lo vòng đời database (tạo/xoá), PHPUnit chỉ lo chạy test. Hai việc này tách hẳn nhau. Đó là lý do không được gọi vendor/bin/phpunit trực tiếp — gọi thẳng thì không ai tạo database cho bạn.

1.2. Tại sao PostgreSQL thật chứ không SQLite?

Đa số project Laravel test bằng SQLite in-memory cho nhanh. Project này cố tình không làm thế, vì repo sở hữu schema (ADR-0009): file migration duy nhất 2026_08_23_000000_init_schema.php là bản pg_dump nguyên văn của schema production. Schema đó dùng những thứ SQLite không có:

Tính năng PostgresDùng ở đâuSQLite làm được không
text[] (mảng native)products.image_urls, video_urls — có cast TextArray riêngKhông. Phải giả lập bằng JSON → test sai thứ.
ORDER BY … NULLS LASTSắp xếp sản phẩm có giá nullKhác hành vi mặc định.
Recursive CTEDuyệt cây category (subtree)Có nhưng khác cú pháp/hiệu năng.
Exclusion constraintChặn 1 sản phẩm vừa nằm trong deal vừa trong flash saleKhông có.

Nguyên tắc: test cái mình chạy thật. Nếu test chạy trên engine khác production, bạn đang chứng minh một schema mà ứng dụng không bao giờ chạy trên đó. Đánh đổi: test chậm hơn và cần một Postgres server sống — xem mục 3.

1.3. Vòng đời một lần chạy (local)

sequenceDiagram
  autonumber
  participant Dev as Lập trình viên
  participant Sh as test-postgres.sh
  participant Art as artisan pgtest:db
  participant PG as PostgreSQL
  participant PU as PHPUnit

  Dev->>Sh: composer test
  Sh->>Sh: php artisan config:clear
  Sh->>Art: pgtest:db name
  Art-->>Sh: laravel_pgsql_test_1756368000_4711
  Sh->>Sh: export DB_TEST_DATABASE
  Sh->>Art: pgtest:db prune (dọn database mồ côi)
  Art->>PG: DROP các db cũ hơn 120 phút, không ai kết nối
  Sh->>Art: pgtest:db create
  Art->>PG: nối vào db "postgres" → DROP IF EXISTS → CREATE DATABASE
  Note over Sh: trap teardown EXIT — từ đây trở đi
chắc chắn có người dọn Sh->>PU: vendor/bin/phpunit --configuration=phpunit.xml "$@" PU->>PG: migrate:fresh (1 lần) + chạy 56 file test PU-->>Sh: exit code (0 hoặc khác 0) Sh->>Art: pgtest:db drop (trong trap) Art->>PG: pg_terminate_backend + DROP DATABASE Sh-->>Dev: thoát với đúng exit code của PHPUnit

Ba chi tiết trong script mà interviewer hay soi:

# 1) Tên database có timestamp + PID → hai lần chạy song song không giết nhau
DB_TEST_DATABASE=$(php artisan pgtest:db name)   # laravel_pgsql_test_<unix>_<pid>

# 2) teardown giữ nguyên exit code — dọn dẹp không được che lỗi test
teardown() {
  local status=$?
  php artisan pgtest:db drop || echo "warning: could not drop ${DB_TEST_DATABASE}" >&2
  exit $status
}
trap teardown EXIT

# 3) Gọi thẳng phpunit, KHÔNG qua `php artisan test`
#    → tiến trình con kế thừa DB_TEST_DATABASE vừa export, không thêm một lớp process nữa
vendor/bin/phpunit --configuration=phpunit.xml "$@"

Bẫy Composer ít người biết: composer test -- --filter=Harness — Composer nối tham số vào lệnh đầu tiên của script. Nếu "test" là mảng nhiều lệnh, --filter sẽ bay vào nhầm binary. Vì vậy script test trong composer.json bắt buộc chỉ có một lệnh; mọi thứ cần chạy trước đều nhét vào file bash.

1.4. Vòng đời một test đơn lẻ — chỗ tinh vi nhất

Đây là phần đáng nói nhất khi phỏng vấn. Laravel gọi các bước theo thứ tự cố định, và đặt guard sai chỗ thì guard vô dụng.

sequenceDiagram
  autonumber
  participant PU as PHPUnit
  participant TC as Tests TestCase
  participant LV as Laravel setUpTheTestEnvironment
  participant RD as RefreshDatabase
  participant PG as PostgreSQL

  PU->>LV: setUp()
  LV->>TC: refreshApplication()
  TC->>TC: parent::refreshApplication() — dựng container
  rect rgb(59,47,30)
    TC->>TC: assert connection == "pgsql_test"
    TC->>TC: assert driver == "pgsql"
    TC->>TC: assert database == PostgresTestDatabase::name()
    Note over TC: CHECK Ở ĐÂY — trước mọi lệnh phá huỷ
  end
  LV->>RD: setUpTraits()
  RD->>PG: migrate:fresh (chỉ lần đầu trong process)
  RD->>PG: BEGIN TRANSACTION
  PU->>PU: chạy thân test (factory, Livewire::test, assert…)
  PU->>RD: tearDown()
  RD->>PG: ROLLBACK

Comment trong tests/TestCase.php nói thẳng lý do:

/**
 * Verify the target database *before* anything destructive touches it.
 *
 * This deliberately hooks refreshApplication() rather than setUp(). Laravel's
 * setUpTheTestEnvironment() calls refreshApplication() and only then
 * setUpTraits(), which is where RefreshDatabase runs `migrate:fresh` — and
 * migrate:fresh takes no `--database`, so it drops every table on
 * `config('database.default')`. A guard placed after parent::setUp() would
 * therefore run *after* the tables were already dropped.
 */
protected function refreshApplication(): void
{
    parent::refreshApplication();

    $connection = $this->app['db']->connection();

    $this->assertSame(PostgresTestDatabase::CONNECTION, $connection->getName(), '…');
    $this->assertSame('pgsql', $connection->getDriverName());
    $this->assertSame(PostgresTestDatabase::name(), $connection->getDatabaseName());
}

Ý chính: migrate:fresh không nhận cờ --database. Nó luôn đánh vào config('database.default'). Nên nếu bạn viết guard trong setUp() sau parent::setUp(), lúc guard chạy thì bảng đã bị drop rồi — guard chỉ còn tác dụng báo tang lễ. Đây là loại câu hỏi "bạn có thật sự hiểu vòng đời không" mà interviewer rất thích.

1.5. Ba lớp chặn — tại sao phải ba

Database test nằm cùng container, cùng server, cùng credentials với database commerce thật. Cô lập ở đây là logic, không phải cô lập tiến trình. Nên guard phải load-bearing (chịu lực thật), không phải trang trí.

flowchart TD
  S["Muốn chạy CREATE / DROP / migrate:fresh"] --> L1{"Lớp 1 — config/database.php
pgsql_test.database = env('DB_TEST_DATABASE')
KHÔNG fallback về DB_DATABASE"} L1 -- "trống" --> X1["RuntimeException:
chưa set DB_TEST_DATABASE"] L1 -- "có tên" --> L2{"Lớp 2 — PostgresTestDatabase guard"} L2 --> C1{"tên == 'commerce'?"} C1 -- có --> X2["Từ chối"] C1 -- không --> C2{"tên == database của
connection pgsql?"} C2 -- có --> X2 C2 -- không --> C3{"tên có chứa chữ 'test'?"} C3 -- không --> X3["Từ chối: gõ nhầm 1 ký tự
không được phép drop db thật"] C3 -- có --> L3{"Lớp 3 — Tests TestCase refreshApplication
assert lại 3 thứ, MỖI test"} L3 -- sai --> X4["Test fail ngay,
trước migrate:fresh"] L3 -- đúng --> OK["Cho chạy"] style X1 fill:#3a1f1f,stroke:#c0555a style X2 fill:#3a1f1f,stroke:#c0555a style X3 fill:#3a1f1f,stroke:#c0555a style X4 fill:#3a1f1f,stroke:#c0555a style OK fill:#1f3a2a,stroke:#4fb477

Lớp 2 có một luật đáng chú ý: tên phải chứa chữ "test".

// Không chỉ chặn hai cái tên ta biết trước.
// Connection này dùng chung host + credentials với app, nên đặt tên bất kỳ
// database thật nào khác cũng sẽ drop nó. Bắt buộc có "test" trong tên
// → thu hẹp bán kính sát thương về đúng những db tự khai là dùng-một-lần.
if (! preg_match('/test/i', $name)) {
    throw new RuntimeException(sprintf(
        'Refusing to use "%s" as the Postgres test database: its name must contain "test"…',
        $name
    ));
}

Đây là khác biệt giữa blocklist (chặn danh sách đen — chỉ chặn cái đã biết) và allowlist (chỉ cho phép cái khớp mẫu). Blocklist luôn thiếu; allowlist thì không.

1.6. Chạy song song và dọn database mồ côi

Vấn đề thật: nhiều phiên làm việc cùng dùng một checkout, hoặc CI có concurrent = 2. Nếu tên database cố định thì lần chạy thứ hai sẽ DROP DATABASE ngay dưới chân lần chạy thứ nhất.

flowchart LR
  subgraph gen["Đặt tên theo từng lần chạy"]
    N["laravel_pgsql_test_<unix-seconds>_<pid>"]
    N --> T["timestamp → prune biết db bao nhiêu tuổi"]
    N --> P["pid → hai run trong cùng 1 giây vẫn khác tên"]
  end
  subgraph pr["prune() — điều kiện AND, phải đúng cả hai"]
    A["Tuổi > 120 phút"] --> D["DROP"]
    B["Không có backend nào
đang kết nối pg_stat_activity"] --> D end

Vì sao phải AND cả hai điều kiện? Chỉ xét tuổi thì một run dài hơn 2 tiếng bị giết giữa chừng. Chỉ xét "không ai kết nối" thì một run đang nghỉ giữa hai câu lệnh cũng bị thu gom. AND cả hai → không bao giờ dọn nhầm một run đang sống.

1.7. Kiến trúc test: chia theo seam (điểm nối), không chia theo class

56 file test không chia theo kiểu "mỗi class một file test". Chia theo seam (điểm nối — chỗ dữ liệu đi qua):

flowchart TD
  U["Người dùng / client"] --> H["HTTP seam
$this->get('/category/…')"] U --> L["Livewire seam
Livewire::test(CategoryBrowse::class)"] H --> C["Livewire Component"] L --> C C --> S["Service / Support
CategoryBrowseSort, PriceBands…"] S --> M["Model (read-only)"] M --> DB[("PostgreSQL")] H -.- HT["tests/Feature/CategoryBrowse/*PageTest
render, filter, SEO, phân trang"] L -.- LT["tests/Feature/CategoryBrowse/*InputTest
property client ghi được → validate"] S -.- ST["tests/Unit/*
PriceBandsTest, EffectivePriceTest
không chạm DB"] style HT fill:#22303f,stroke:#5b8db8 style LT fill:#22303f,stroke:#5b8db8 style ST fill:#22303f,stroke:#5b8db8

Lý do tách HTTP seam và Livewire seam: public property của Livewire là client-writable (client ghi thẳng được). Một client xấu gọi thẳng endpoint update của component, set appliedMin = "1) or 1=1--", không cần đi qua URL nào cả. Nên HTTP seam không với tới được bề mặt đó — phải có test riêng ở seam Livewire:

/**
 * Livewire's public properties are client-writable: a hostile client can set the
 * applied `min`/`max` straight through the component update endpoint without ever
 * requesting a URL, so the HTTP seam cannot reach this surface.
 */
public static function hostilePriceProvider(): array
{
    return [
        'non-numeric minimum'          => ['appliedMin', 'abc'],
        'negative minimum'             => ['appliedMin', '-5'],
        'minimum beyond the ceiling'   => ['appliedMin', '99999'],
        'SQL fragment'                 => ['appliedMin', '1) or 1=1--'],
    ];
}

private function browse(): Testable
{
    // Mount đúng cách route mount nó — không mount kiểu "cho tiện test"
    return Livewire::test(CategoryBrowse::class, ['category' => $this->category]);
}

API test của Livewire 4 dùng trong repo (đã kiểm tra với tài liệu Livewire 4.x):

NhómMethodLàm gì
DựngLivewire::test(Class::class, [...])Mount component, truyền tham số vào mount()
DựngLivewire::actingAs($user)Đặt user đang đăng nhập
DựngLivewire::withQueryParams([...])Giả lập query string trên URL
DựngLivewire::withoutLazyLoading()Tắt lazy load — repo có shelf lazy ở trang chủ
Tương tác->set('prop', $v) / ->toggle()Ghi vào public property (đúng đường mà client xấu đi)
Tương tác->call('method', ...$args)Gọi public method
Kiểm tra->assertSet(), ->assertSee(), ->assertStatus()State và HTML sau render
Kiểm tra->assertRedirect(), ->assertForbidden(), ->assertDispatched()Điều hướng, quyền, sự kiện

1.8. Hai chi tiết nhỏ nhưng hay bị hỏi

a) force="true" trong phpunit.xml

<!-- force="true" để dòng này thắng .env: suite tuyệt đối không được
     resolve về connection `pgsql` (trỏ vào database dùng chung). -->
<env name="DB_CONNECTION" value="pgsql_test" force="true"/>

Mặc định, thẻ <env> của PHPUnit không ghi đè biến môi trường đã tồn tại. Đó là hành vi mong muốn với DB_HOST/DB_PASSWORD (CI truyền vào lúc docker run), nhưng là thảm hoạ với DB_CONNECTION. Nên chỉ dòng này có force.

b) Test tự canh gác schema

/**
 * The baseline migration is a verbatim dump of the live schema… If this
 * assertion ever fails, the baseline has drifted and every test above it is
 * proving the wrong schema.
 */
public function test_schema_uses_postgres_native_array_columns(): void
{
    $type = DB::selectOne(
        'SELECT udt_name FROM information_schema.columns
         WHERE table_name = ? AND column_name = ?',
        ['products', 'image_urls']
    );

    // `_text` là cách Postgres đặt tên nội bộ cho kiểu text[]
    $this->assertSame('_text', $type->udt_name);
}

Đây là meta-test: nó không test business logic, nó test rằng cái nền để test vẫn còn đúng. Nếu baseline drift (lệch khỏi schema thật), test này đỏ trước, thay vì để 55 file kia âm thầm chứng minh một schema không tồn tại.

2. Luồng khi GitLab CI trigger

2.1. Runner và mô hình tổng thể

Setup ở đây khá đặc biệt, cần nói rõ trong phỏng vấn:

flowchart TD
  subgraph vps["Một con VPS"]
    R["GitLab Runner (shell executor)
tag: vps, concurrent = 2"] D["Docker daemon"] R --> D D --> IT["image clicon-test:<job-id>
(build rồi RUN, rồi xoá)"] D --> IR["image clicon:<sha>
(artifact để promote)"] D --> PGC[("container postgres
commerce + laravel_pgsql_test_*")] D --> APP["container app dev / staging / prod"] end GL["GitLab.com"] -- "polling job" --> R IT -. "docker run --network commerce_commerce_net" .-> PGC

2.2. Pipeline nào được tạo — và không được tạo

Khối workflow.rules quyết định pipeline có sinh ra hay không. Đây là chỗ hay bị bỏ sót:

workflow:
  rules:
    - if: $CI_COMMIT_TAG                                # v1.5.1 / -rc.N — deploy
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"  # mở/push vào MR — test
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH       # merge vào master — test, build, deploy dev
    - when: never                                       # push nhánh không có MR — không tạo gì

Vì sao cần when: never? Không có khối này, GitLab tạo branch pipeline cho mọi lần push. Một job có rule merge_request_event khi đó sẽ chạy trong hai pipeline mỗi lần push khi MR đang mở — branch pipeline và MR pipeline. Đây là bug "duplicate pipeline" kinh điển của GitLab. Ngoài ra: chỉ có 1 runner với 2 slot; push WIP mà không ai chờ thì không nên chiếm slot của người khác. Muốn có test trên nhánh → mở MR ở trạng thái draft.

2.3. Ba loại pipeline

flowchart TD
  subgraph mr["MR đang mở (push vào nhánh)"]
    M1["stage: test
job test"] --> M2["hết"] end subgraph master["Merge vào master"] A1["test"] --> A2["env-contract:dev"] --> A3["build → clicon:<sha>"] --> A4["migrate:dev"] --> A5["deploy:dev
(recreate + smoke)"] --> A6["cleanup:images"] end subgraph tag["Tag v1.5.1 hoặc v1.5.1-rc.1"] B1["KHÔNG chạy test"] --> B2["env-contract"] --> B3["KHÔNG build
tra cứu clicon:<sha> sẵn có"] --> B4["migrate"] --> B5["deploy blue-green"] --> B6["release page"] end style B1 fill:#3a1f1f,stroke:#c0555a style B3 fill:#3a1f1f,stroke:#c0555a

Tag không chạy lại test, và đó là chủ ý:

rules:
  - if: $CI_COMMIT_TAG
    when: never
  - when: on_success

Lý do trong comment: "A tag deploys an image that already passed this on the commit it points at; re-running would cost minutes and could go red for reasons outside the code." Chạy lại test trên đúng commit đã xanh chỉ tạo ra flaky risk (rủi ro đỏ vì lý do ngoài code: mạng, rate limit, database chậm) mà không cho thêm thông tin gì.

2.4. Job test — luồng chi tiết

test:
  stage: test
  extends: .with_composer_auth
  interruptible: true
  script:
    - docker build -f "$DOCKERFILE" --target test
      --secret id=composer_auth,src=auth.json -t "$IMAGE-test:$CI_JOB_ID" .
    - grep -E '^DB_(HOST|PORT|USERNAME|PASSWORD)=' "$DEPLOY_ROOT/dev/app.env" > db.env
    - docker run --rm --network "$DB_NETWORK" --env-file db.env "$IMAGE-test:$CI_JOB_ID"
  after_script:
    - rm -f auth.json db.env
    - docker image rm "$IMAGE-test:$CI_JOB_ID" || true

Ba dòng script, mỗi dòng một quyết định thiết kế:

sequenceDiagram
  autonumber
  participant GL as GitLab
  participant R as Runner shell trên VPS
  participant BK as BuildKit
  participant DK as Docker daemon
  participant PG as container Postgres

  GL->>R: job "test" (MR hoặc master)
  R->>R: before_script — ghi auth.json từ $GITHUB_TOKEN, chmod 600
  R->>BK: docker build --target test --secret id=composer_auth
  Note over BK: stage assets (npm ci + vite build)
stage base (php:8.3-fpm-alpine + pdo_pgsql, redis, pcntl)
stage test (composer install CÓ dev deps) BK->>BK: cp .env.example .env && artisan key:generate BK-->>R: image clicon-test:<job-id>, CMD = bash scripts/test-postgres.sh R->>R: grep chỉ 4 dòng DB_* từ dev/app.env → db.env R->>DK: docker run --rm --network commerce_commerce_net --env-file db.env DK->>PG: pgtest:db create → laravel_pgsql_test_<ts>_<pid> DK->>PG: phpunit — migrate:fresh + 56 file test DK->>PG: pgtest:db drop (EXIT trap) DK-->>R: exit code R->>R: after_script — rm auth.json db.env, xoá image test R-->>GL: job xanh / đỏ

Vì sao build rồi RUN, chứ không chạy test trong một RUN layer?

Một RUN trong Dockerfile không có đường mạng tới database network. Trước đây suite chạy SQLite nên nhét vào RUN layer được. Từ khi chuyển sang Postgres thật thì không: stage test chỉ build ra một image biết cách chạy test (CMD ["bash", "scripts/test-postgres.sh"]), còn CI mới là bên khởi động nó trên database network bằng docker run --network.

Vì sao chỉ lấy đúng 4 biến DB_*, và lấy từ dev?

interruptible: true — chỉ job này có. Push tiếp vào MR đang mở thì huỷ luôn lần chạy của commit trước. An toàn vì test không đổi state gì trên host. Các job build, migrate, deploy không có cờ này: chúng thay đổi state thật (một image, một schema, một màu đang chạy) và không được phép chết giữa chừng.

2.5. Stage test còn có env-contract

.env-contract:
  stage: test
  script:
    - scripts/check-env-keys.sh .env.example "$DEPLOY_ROOT/$DEPLOY_ENV/app.env"

.env.examplehợp đồng về những key mà app đọc; giá trị thật nằm trong app.env trên host và không bao giờ vào git. Không có gì tự động giữ hai bên khớp nhau. Job này so sánh tên key (không đọc, không in giá trị nào) và chạy trước migrate và deploy — nên quên một key sẽ thành pipeline đỏ, thay vì container mới boot lên rồi chết.

Đây là một dạng test khác hẳn PHPUnit: không test code, mà test cấu hình môi trường. Kể được cái này trong phỏng vấn cho thấy bạn hiểu "test" không chỉ là unit/feature test.

2.6. Multi-stage Dockerfile — stage test khác runtime ở đâu

flowchart TD
  A["Stage 1: assets
node:22-alpine
npm ci → vite build"] --> T A --> RT V["Stage 2: vendor
composer:2
install --no-dev"] --> RT B["Stage 3: base
php:8.3-fpm-alpine
pdo_pgsql, pgsql, pcntl, opcache, redis"] --> T B --> RT T["Stage 4: test
+ unzip, bash, composer
composer install CÓ dev deps
CMD bash scripts/test-postgres.sh"] RT["Stage 5: runtime
vendor --no-dev + assets
USER www-data, php-fpm"] style T fill:#22303f,stroke:#5b8db8 style RT fill:#1f3a2a,stroke:#4fb477
Điểmstage teststage runtime
Composerinstall đủ dev deps (phpunit, mockery, faker)--no-dev, copy vendor từ stage riêng
Gói thêmunzip (base không có ext zip), bash (alpine chỉ có ash)không
AssetsCó — mọi test render layout đều đụng @vite và cần manifest
APP_KEYSinh lúc build (cp .env.example .env && key:generate)Từ app.env lúc runtime
DB coordinatesKhông nướng vào image — tới lúc docker run mới cóTừ --env-file app.env
Userroot (container dùng-một-lần)www-data
EntrypointCMD bash scripts/test-postgres.shentrypoint.shphp-fpm -F

Chuyện rate limit — chi tiết đời thực đáng kể:

# codeload.github.com chặn theo IP. VPS này đã quá ngưỡng → composer install
# chết giữa chừng với HTTP 429. GITHUB_TOKEN (PAT không cần scope nào) đẩy
# composer sang API có xác thực. Truyền bằng BuildKit secret nên không rơi vào layer.
ENV COMPOSER_CACHE_DIR=/composer-cache \
    COMPOSER_MAX_PARALLEL_HTTP=4     # mặc định 12 chính là thứ kéo 429 về
RUN --mount=type=secret,id=composer_auth,target=/var/www/html/auth.json \
    --mount=type=cache,target=/composer-cache,sharing=locked \
    for i in 1 2 3; do \
        composer install --no-scripts --no-autoloader --no-interaction && exit 0; \
        echo "== composer install attempt $i failed, retrying in 15s"; sleep 15; \
    done; exit 1

Ba thứ ghép lại: cache bền (mỗi lần thử giữ lại phần đã tải được → hội tụ dần thay vì làm lại từ đầu), retry 3 lần, và giảm song song. Còn auth.json truyền qua --secret chứ không COPY — nên token không nằm trong bất kỳ layer nào của image.

3. Tác hại / hạn chế

Hạn chếCụ thể trong repo nàyMức đau
Test chậm hơn SQLite in-memoryMỗi run: CREATE DATABASE + migrate:fresh một schema pg_dump 32KB. Không thể chạy test trong 2 giây.Trung bình
Cần một Postgres sốngHost không cài Postgres. Không có container chiennn_commerce_postgres thì toàn bộ suite chết, kể cả test Unit thuần.Cao
Cô lập là logic, không phải tiến trìnhCùng server, cùng credentials với commerce. Guard là thứ duy nhất chắn. Guard sai/bị xoá = mất database production.Rất cao
Có thể còn rácBị SIGKILL thì EXIT trap không chạy → database mồ côi tồn tại tối đa 120 phút, tốn disk.Thấp
Baseline migration có thể driftMigration là pg_dump nguyên văn. Ai đó sửa schema production bằng tay thì test đang chứng minh một schema đã lỗi thời.Cao
Không có browser testKhông có Dusk/Playwright. Alpine.js của Livewire (client-side) không được test.Trung bình
Không có pint / static analysis trong pipelineStage test chỉ có PHPUnit + env-contract. Xem tài liệu riêng.Trung bình
Coverage không đo<source> có khai báo app/ nhưng không có job coverage. 18 class chưa được test trực tiếp.Thấp–Trung bình
Runner đơn, 2 slotMột VPS. Pipeline thứ ba phải xếp hàng. Runner chết = không deploy được gì.Cao

4. Trade-off

4.1. Postgres thật vs SQLite in-memory

Postgres dùng-một-lần (đang dùng)SQLite in-memory
Tốc độChậm (có I/O, có network)Rất nhanh
Độ trung thựcĐúng engine productionXấp xỉ — text[], NULLS LAST, exclusion constraint đều không có
Hạ tầng cầnCần server + network + credentialsKhông cần gì
Rủi roChạm nhầm db thật (nên mới có 3 lớp guard)Bằng không
Loại bug bắt đượcBug SQL, bug kiểu dữ liệu, bug constraintChỉ bug logic

Tiêu chí chọn: schema có dùng tính năng riêng của một engine không? Không → SQLite thoải mái. Có → Postgres thật, hoặc bạn đang test cái mình không chạy. Repo này rơi vào vế thứ hai và còn sở hữu schema, nên không có lựa chọn khác.

4.2. Database riêng mỗi run vs database dùng chung

flowchart LR
  subgraph shared["Tên cố định (laravel_pgsql_test)"]
    S1["Đơn giản"] --> S2["Chạy song song = giết nhau"]
    S2 --> S3["Không cần prune"]
  end
  subgraph perrun["Tên theo run (đang dùng)"]
    P1["Song song an toàn"] --> P2["Cần prune dọn mồ côi"]
    P2 --> P3["Debug khó hơn: tên db đổi mỗi lần"]
  end

Script vẫn để ngỏ cả hai: nếu DB_TEST_DATABASE được set sẵn (CI, hoặc lúc debug) thì tên đó thắng — nhưng lúc đó trách nhiệm không chạy song song thuộc về người gọi.

4.3. Chạy test trong build layer vs build image rồi run

RUN phpunit trong DockerfileBuild --target test rồi docker run (đang dùng)
Số bước CI13 (build, lấy env, run)
Truy cập databaseKhông — build không vào được networkCó — --network
Cache layerTest xanh thì được cache → lần sau khỏi chạy (nguy hiểm!)Luôn chạy thật
Bí mậtDễ lộ nếu COPY nhầmEnv truyền lúc run, không vào layer

4.4. Tag không chạy lại test — được và mất

Được: tiết kiệm vài phút; tránh đỏ vì lý do ngoài code; và quan trọng nhất — đảm bảo đúng bytes đã test được deploy (build once, promote).

Mất: nếu image clicon:<sha> bị pipeline cleanup:images dọn mất, tag sẽ fail ở bước tra cứu image. Đó cũng là guard duy nhất về việc tag được đặt ở đâu:

- docker image inspect "$IMAGE:$TAG" >/dev/null 2>&1 ||
  { echo "!! no image $IMAGE:$TAG — commit $CI_COMMIT_SHORT_SHA never built on $CI_DEFAULT_BRANCH"; exit 1; }

Tag một commit chưa từng đi qua pipeline master → không có image → fail. Đơn giản và hiệu quả.

5. Khó khăn khi vận hành thực tế

5.1. Debug lúc 3h sáng: test đỏ trên CI, xanh ở máy mình

flowchart TD
  Q["Test đỏ trên CI, xanh ở local"] --> A{"Đỏ ở bước nào?"}
  A -- "docker build" --> B["429 từ codeload
→ GITHUB_TOKEN hết hạn? retry đã hết 3 lần?"] A -- "pgtest:db create" --> C["Container postgres còn sống không?
db.env có đủ 4 dòng DB_* không?
Network commerce_commerce_net còn không?"] A -- "một test cụ thể" --> D{"Có phụ thuộc thứ tự không?"} D -- "chạy riêng thì xanh" --> E["Rò rỉ state: Cache::flush() thiếu,
hoặc thứ tự id không ổn định"] D -- "chạy riêng vẫn đỏ" --> F["Bug thật, hoặc
schema baseline đã drift"] A -- "guard trong TestCase" --> G["DB_TEST_DATABASE bị ghi đè bởi
biến môi trường ở CI"] style E fill:#3b2f1e,stroke:#d9a441

Repo đã xử lý sẵn hai nguồn không-ổn-định (flakiness) hay gặp nhất:

// tests/Concerns/CreatesSequencedProducts.php
//
// products không có created_at → id CHÍNH LÀ độ mới. HasUuids sinh UUIDv7, chỉ
// sắp xếp theo thời gian ở mức mili-giây. Hai product tạo trong cùng 1 ms sẽ
// xếp thứ tự tuỳ ý → test seed 3 product rồi assert thứ tự sẽ xanh/đỏ tuỳ máy
// chạy nhanh hay chậm.
//
// Phát id từ bộ đếm là bỏ hẳn chuyện đó: thứ tự tạo = thứ tự id, trên mọi máy.
'id' => sprintf('01890000-0000-7000-8000-%012d', ++$this->idSequence),
// Trong setUp() của các test có cache
protected function setUp(): void
{
    parent::setUp();
    $this->withoutVite();   // không cần manifest thật khi render layout
    Cache::flush();         // CACHE_STORE=array, nhưng vẫn sống qua các test cùng process
}

Bài học chung: flaky test gần như luôn đến từ ba nguồn — thời gian (timestamp, UUIDv7, TTL), thứ tự (test này để lại state cho test kia), và song song (hai run tranh nhau tài nguyên). Repo này đụng cả ba và xử lý cả ba.

5.2. Chi phí và con người

KhoảnThực tế ở đây
CI minutes0đ — self-hosted runner trên VPS đã trả tiền sẵn
Đổi lạiBạn tự vận hành runner: nó chết thì không ai báo
DiskImage test bị xoá trong after_script; clicon:<sha> giữ 20 bản gần nhất
Ai sửa được pipelineCần biết cả GitLab CI, Docker multi-stage, bash, và Laravel — bus factor thấp
SecretGITHUB_TOKEN ở CI variables; app.env điền tay trên host, không có trong git

5.3. Cái không được monitor

6. Case thực tế và cách xử lý

Case 1 — Ai đó thêm DB_TEST_DATABASE=commerce vào CI variables

Bối cảnh: một người muốn "test trên dữ liệu thật cho giống production", set biến ở project settings của GitLab.

sequenceDiagram
  participant CI as GitLab CI
  participant Sh as test-postgres.sh
  participant G as PostgresTestDatabase guard
  participant PG as PostgreSQL

  CI->>Sh: DB_TEST_DATABASE=commerce (đã set sẵn)
  Sh->>Sh: bỏ qua bước sinh tên (biến đã có)
  Sh->>G: pgtest:db create → name() → guard()
  G->>G: name == "commerce" → khớp danh sách cấm
  G-->>Sh: RuntimeException, exit 1
  Sh-->>CI: "error: could not create the disposable Postgres test database"
  Note over PG: KHÔNG có câu lệnh nào chạm tới commerce

Xử lý từng bước:

  1. Job đỏ ở dòng script đầu tiên, message nói thẳng lý do — không phải đoán.
  2. Bỏ biến khỏi CI variables. Đây là lỗi cấu hình, không phải lỗi code.
  3. Nếu thật sự cần test trên dữ liệu giống production: restore một bản dump vào database có chữ "test" trong tên, rồi set biến trỏ vào đó. Guard cho phép — nó chỉ chặn những cái tên không tự khai là dùng-một-lần.
  4. Ghi lại vào CLAUDE.md để người sau không lặp lại.

Câu để nói: "Guard không chặn người dùng làm việc họ cần. Nó chặn đúng một thứ: gõ nhầm khiến database thật bị drop."

Case 2 — Pipeline chết giữa chừng, để lại database mồ côi

Bối cảnh: VPS hết RAM lúc 2h sáng, OOM killer giết container test. SIGKILL không bắt được → EXIT trap không chạy → laravel_pgsql_test_1756…_4711 còn nguyên.

Xử lý:

  1. Không cần làm gì ngay. Lần chạy sau, bước pgtest:db prune sẽ dọn — với điều kiện db đã quá 120 phút không ai kết nối.
  2. Cần dọn ngay thì: php artisan pgtest:db prune --minutes=0. Vẫn an toàn vì điều kiện "không có backend nào kết nối" vẫn được kiểm tra.
  3. Xem còn sót bao nhiêu:
    SELECT datname, pg_size_pretty(pg_database_size(datname))
    FROM pg_database
    WHERE datname LIKE 'laravel_pgsql_test_%'
    ORDER BY datname;
  4. Root cause mới là việc thật: vì sao OOM? Suite chạy song song với deploy? Chỉnh concurrent hoặc đặt resource_group cho job test.

Case 3 — Test đỏ chỉ khi chạy cả suite, chạy riêng thì xanh

Bối cảnh: CategoryBrowseSortTest xanh khi chạy --filter=CategoryBrowseSort, đỏ khi chạy cả bộ.

Quy trình chẩn đoán:

flowchart TD
  A["Đỏ khi chạy cả suite"] --> B["composer test -- --filter=TenTest
→ xanh?"] B -- có --> C["Là rò rỉ state giữa các test"] C --> D["composer test -- --order-by=defects
hoặc --random-order-seed=<seed>
để tái hiện đúng thứ tự"] D --> E{"State rò rỉ ở đâu?"} E --> F1["Cache — CACHE_STORE=array sống
suốt process → Cache::flush() trong setUp"] E --> F2["Static property trong Service"] E --> F3["Bộ đếm id của trait —
đã reset theo instance, kiểm tra lại"] E --> F4["Config bị test khác sửa qua config()->set()"] style C fill:#3b2f1e,stroke:#d9a441

Nghi phạm số một trong repo này là cache. HomeFeed cache dữ liệu denormalize, CategoryBrowse cache khoảng giá. CACHE_STORE=array nghĩa là cache nằm trong bộ nhớ PHP — không bị RefreshDatabase rollback, vì rollback chỉ đụng database. Test A ghi cache, test B đọc trúng dữ liệu cũ trong khi bảng đã rollback rỗng.

Cách sửa: Cache::flush() trong setUp() của mọi test có đụng cache — đúng cách CategoryBrowsePriceInputTest đang làm. Đừng sửa bằng cách đổi thứ tự test.

Case 4 — Schema production bị sửa tay, baseline drift

Bối cảnh: có người ALTER TABLE thẳng trên production (thêm index, đổi kiểu cột) mà không qua migration.

Cái xảy ra: test vẫn xanh — vì test chạy trên baseline cũ. Suite đang chứng minh một schema không còn tồn tại. Đây là kiểu hỏng im lặng, nguy hiểm nhất.

Xử lý:

  1. Phát hiện: pg_dump --schema-only production, diff với baseline migration. Nên có job cron chạy việc này chứ không đợi tình cờ.
  2. test_schema_uses_postgres_native_array_columns chỉ bắt được drift một cột. Nó là chốt chặn, không phải lưới đầy đủ.
  3. Chữa: viết migration mô tả đúng thay đổi tay đó, chạy qua pipeline ở dev/staging, rồi migrate --force lên production (nó sẽ no-op vì thay đổi đã có), để migration table khớp lại.
  4. Phòng: bỏ quyền DDL của tài khoản app trên production. Chỉ pipeline được đổi schema.

Case 5 — MR đỏ ở env-contract nhưng test xanh

Bối cảnh: MR thêm PAYMENT_GATEWAY_URL vào .env.example và code có đọc nó. Test xanh (test không đụng gateway). env-contract:dev đỏ.

Xử lý: đây là pipeline làm đúng việc của nó. Key mới chưa có giá trị trong /srv/clicon/dev/app.env. Điền giá trị vào file trên host (bằng tay, không qua git), chạy lại job. Nếu không có job này, deploy sẽ thành công rồi container mới chết lúc boot — hoặc tệ hơn, chạy được nhưng gọi vào URL rỗng.

7. Câu hỏi phụ interviewer hay hỏi tiếp

"Vì sao không dùng SQLite in-memory cho nhanh?"

Vì repo sở hữu schema và schema dùng text[], NULLS LAST, recursive CTE, exclusion constraint — SQLite phải xấp xỉ hết. Test trên bản xấp xỉ nghĩa là chứng minh một schema mà app không bao giờ chạy trên đó. Nếu schema chỉ toàn kiểu dữ liệu phổ thông thì tôi sẽ chọn SQLite.

"RefreshDatabase khác DatabaseMigrationsDatabaseTruncation chỗ nào?"

TraitCách làmTốc độ
RefreshDatabaseMigrate 1 lần (nếu schema chưa đúng), rồi mỗi test bọc trong transaction và rollbackNhanh nhất
DatabaseMigrationsChạy lại migration trước mỗi testChậm nhất
DatabaseTruncationMigrate lần đầu, sau đó truncate bảngỞ giữa

Bẫy cần biết: RefreshDatabase không dùng được cho browser test (Dusk) — transaction không đi xuyên qua HTTP request từ trình duyệt thật. Dusk phải dùng DatabaseMigrations hoặc DatabaseTruncation.

"Vì sao guard nằm ở refreshApplication() mà không phải setUp()?"

Vì Laravel gọi refreshApplication() trước setUpTraits(), mà setUpTraits() mới là chỗ RefreshDatabase chạy migrate:fresh. Và migrate:fresh không nhận --database — nó luôn drop mọi bảng trên config('database.default'). Guard đặt sau parent::setUp() sẽ chạy khi bảng đã bị drop xong rồi.

"Sao tag không chạy lại test? Không sợ à?"

Ngược lại mới đáng sợ. Tag deploy đúng image đã build từ commit đó, mà commit đó đã xanh trên master. Chạy lại test trên cùng bytes không cho thêm thông tin, chỉ thêm rủi ro đỏ vì lý do ngoài code (mạng, rate limit). Đó là ý nghĩa của "build once, promote the same bytes".

"Vì sao build image test rồi docker run, không chạy test ngay trong RUN?"

RUN trong Dockerfile không có đường ra database network. Suite cần một Postgres server thật. Nên stage test chỉ build ra image biết cách chạy test; CI mới là bên khởi động nó bằng docker run --network commerce_commerce_net. Bonus: build layer được cache, nên nếu test chạy trong RUN thì một lần xanh sẽ được cache lại và những lần sau không chạy nữa — nguy hiểm.

"Sao chỉ lấy 4 biến DB từ app.env, không nạp cả file?"

Hai lý do. Một: nạp cả file sẽ set APP_ENV=dev, mà thẻ <env> của PHPUnit không ghi đè biến môi trường đã tồn tại → suite chạy nhầm environment. Hai: least privilege — job này chạy từ nhánh MR của bất kỳ ai, nên lấy file ít quyền nhất (dev) và chỉ lấy đúng thứ cần.

"interruptible: true là gì, sao chỉ job test có?"

Nó cho GitLab huỷ job khi có commit mới push vào cùng MR (cần bật "Auto-cancel redundant pipelines"). An toàn với test vì test không đổi state gì trên host. Không đặt cho build/migrate/deploy vì chúng thay đổi state thật — giết một job migrate giữa chừng để lại schema nửa vời.

"Test song song thì sao? Hai pipeline cùng lúc có đụng nhau không?"

Không, vì mỗi run có database riêng tên laravel_pgsql_test_<timestamp>_<pid>. Timestamp để prune() biết tuổi, pid để hai run trong cùng một giây vẫn khác tên. Còn migratedeploy thì dùng resource_group: $DEPLOY_ENV — GitLab tuần tự hoá chúng theo môi trường, vì một môi trường chỉ có một schema.

"Đang thiếu gì? Bạn sẽ thêm gì trước?"

Theo thứ tự ưu tiên:

  1. Static analysis (PHPStan/Larastan) — phủ 100% code, không phụ thuộc test coverage. Bắt lỗi ở những nhánh chưa ai viết test.
  2. pint --test — rẻ, nhanh, dọn nhiễu khỏi code review.
  3. Cron so schema production với baseline — chặn drift im lặng (Case 4).
  4. Đo thời gian chạy suite — phát hiện suite chậm dần trước khi nó thành vấn đề.
  5. Browser test cho luồng quan trọng — hiện tại phần Alpine.js client-side không được test.

"Nếu phải chuyển pipeline này lên GitLab.com runner (không self-hosted) thì sao?"

Đổi executor shelldocker, và dùng CI services để có Postgres:

test:
  image: php:8.3-cli
  services:
    - name: postgres:16-alpine
      alias: postgres
  variables:
    POSTGRES_PASSWORD: secret
    DB_HOST: postgres
  script:
    - composer install
    - composer test

Lúc đó Postgres là container dùng-một-lần theo từng job, nên guard "không được chạm commerce" bớt load-bearing hẳn — nhưng vẫn nên giữ, vì nó bảo vệ cả lúc chạy ở máy lập trình viên. Đổi lại: phải push image lên registry (không còn build-once-trên-cùng-host nữa), và bắt đầu tốn CI minutes.


Tóm tắt một trang

flowchart TD
  subgraph L["Ở máy lập trình viên"]
    L1["composer test"] --> L2["bash: create db → phpunit → drop (EXIT trap)"]
  end
  subgraph C["Ở GitLab CI"]
    C1["Push vào MR / merge master"] --> C2["docker build --target test"]
    C2 --> C3["grep 4 dòng DB_* từ dev/app.env"]
    C3 --> C4["docker run --network db-net
→ chạy đúng bash script đó"] C4 --> C5{"Xanh?"} C5 -- không --> C6["Dừng. Không build, không deploy."] C5 -- "có, và là master" --> C7["build clicon:<sha> → migrate dev → deploy dev"] C7 --> C8["Tag v1.5.1-rc.1 → staging (dùng lại image)"] C8 --> C9["Tag v1.5.1 → production (dùng lại image)"] end L2 -. "cùng một script, cùng một guard" .-> C4 style C6 fill:#3a1f1f,stroke:#c0555a style C9 fill:#1f3a2a,stroke:#4fb477

Ba câu nếu chỉ được nói ba câu:

  1. Test chạy trên PostgreSQL thật vì repo sở hữu schema và schema dùng tính năng riêng của Postgres — test trên SQLite là chứng minh sai thứ.
  2. Ba lớp guard độc lập chặn suite chạm vào database production, và lớp trong PHP nằm trước câu lệnh phá huỷ đầu tiên, không phải sau.
  3. CI build một image test rồi chạy nó trên database network — vì build layer không có mạng — và tag deploy lại đúng image đã xanh chứ không build lại.