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 suite | Toàn bộ tập test chạy chung một lượt. |
| Fixture | Dữ liệu dựng sẵn để test có cái mà chạy. |
| Factory | Lớ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 trap | Bẫ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 build | Dockerfile 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, promote | Build 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 Postgres | Dùng ở đâu | SQLite làm được không |
|---|---|---|
text[] (mảng native) | products.image_urls, video_urls — có cast TextArray riêng | Không. Phải giả lập bằng JSON → test sai thứ. |
ORDER BY … NULLS LAST | Sắp xếp sản phẩm có giá null | Khác hành vi mặc định. |
| Recursive CTE | Duyệt cây category (subtree) | Có nhưng khác cú pháp/hiệu năng. |
| Exclusion constraint | Chặn 1 sản phẩm vừa nằm trong deal vừa trong flash sale | Khô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,--filtersẽ bay vào nhầm binary. Vì vậy scripttesttrongcomposer.jsonbắ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:freshkhông nhận cờ--database. Nó luôn đánh vàoconfig('database.default'). Nên nếu bạn viết guard trongsetUp()sauparent::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óm | Method | Làm gì |
|---|---|---|
| Dựng | Livewire::test(Class::class, [...]) | Mount component, truyền tham số vào mount() |
| Dựng | Livewire::actingAs($user) | Đặt user đang đăng nhập |
| Dựng | Livewire::withQueryParams([...]) | Giả lập query string trên URL |
| Dựng | Livewire::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:
- Một GitLab Runner self-hosted, executor kiểu
shell, chạy trên chính con VPS, tagvps,concurrent = 2. - Không có registry, không push image, không SSH. Runner build image trên đúng docker daemon đang chạy các container production.
- Image
clicon:<sha>nằm trên đĩa chính là release artifact.
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ó rulemerge_request_eventkhi đó 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
RUNtrong 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: stagetestchỉ 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ằngdocker run --network.
Vì sao chỉ lấy đúng 4 biến DB_*, và lấy từ dev?
- Từ dev, không phải production: job này chạy từ nhánh MR của bất kỳ ai — lấy file có ít quyền nhất.
- Chỉ 4 dòng
DB_*: nạp cả file sẽ set luônAPP_ENV=dev, mà thẻ<env>của PHPUnit không ghi đè biến môi trường đã có → suite sẽ chạy nhầm environment. Ở đây kỹ thuật ở CI và kỹ thuật ở PHPUnit ăn khớp nhau.
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.example là hợ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ểm | stage test | stage runtime |
|---|---|---|
| Composer | install đủ dev deps (phpunit, mockery, faker) | --no-dev, copy vendor từ stage riêng |
| Gói thêm | unzip (base không có ext zip), bash (alpine chỉ có ash) | không |
| Assets | Có — mọi test render layout đều đụng @vite và cần manifest | Có |
| APP_KEY | Sinh lúc build (cp .env.example .env && key:generate) | Từ app.env lúc runtime |
| DB coordinates | Không nướng vào image — tới lúc docker run mới có | Từ --env-file app.env |
| User | root (container dùng-một-lần) | www-data |
| Entrypoint | CMD bash scripts/test-postgres.sh | entrypoint.sh → php-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ày | Mức đau |
|---|---|---|
| Test chậm hơn SQLite in-memory | Mỗ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ống | Host 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ình | Cù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ác | Bị 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ể drift | Migration 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 test | Khô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 pipeline | Stage 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 slot | Mộ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 production | Xấp xỉ — text[], NULLS LAST, exclusion constraint đều không có |
| Hạ tầng cần | Cần server + network + credentials | Không cần gì |
| Rủi ro | Chạm nhầm db thật (nên mới có 3 lớp guard) | Bằng không |
| Loại bug bắt được | Bug SQL, bug kiểu dữ liệu, bug constraint | Chỉ 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 Dockerfile | Build --target test rồi docker run (đang dùng) | |
|---|---|---|
| Số bước CI | 1 | 3 (build, lấy env, run) |
| Truy cập database | Không — build không vào được network | Có — --network |
| Cache layer | Test xanh thì được cache → lần sau khỏi chạy (nguy hiểm!) | Luôn chạy thật |
| Bí mật | Dễ lộ nếu COPY nhầm | Env 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ản | Thực tế ở đây |
|---|---|
| CI minutes | 0đ — self-hosted runner trên VPS đã trả tiền sẵn |
| Đổi lại | Bạn tự vận hành runner: nó chết thì không ai báo |
| Disk | Image test bị xoá trong after_script; clicon:<sha> giữ 20 bản gần nhất |
| Ai sửa được pipeline | Cần biết cả GitLab CI, Docker multi-stage, bash, và Laravel — bus factor thấp |
| Secret | GITHUB_TOKEN ở CI variables; app.env điền tay trên host, không có trong git |
5.3. Cái không được monitor
- Thời gian chạy suite — không đo. Suite chậm dần thì không ai biết cho tới lúc đau.
- Database mồ côi —
prunechạy im lặng (>/dev/null 2>&1 || true). Nếu nó hỏng, không có tín hiệu gì. - Sức khoẻ runner — không có heartbeat. Runner offline thì MR chỉ nằm chờ mãi.
- Coverage — không có số, không có xu hướng.
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:
- Job đỏ ở dòng script đầu tiên, message nói thẳng lý do — không phải đoán.
- Bỏ biến khỏi CI variables. Đây là lỗi cấu hình, không phải lỗi code.
- 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.
- 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ý:
- Không cần làm gì ngay. Lần chạy sau, bước
pgtest:db prunesẽ dọn — với điều kiện db đã quá 120 phút và không ai kết nối. - 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. - 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; - Root cause mới là việc thật: vì sao OOM? Suite chạy song song với deploy?
Chỉnh
concurrenthoặc đặtresource_groupcho 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ý:
- Phát hiện:
pg_dump --schema-onlyproduction, diff với baseline migration. Nên có job cron chạy việc này chứ không đợi tình cờ. test_schema_uses_postgres_native_array_columnschỉ bắt được drift một cột. Nó là chốt chặn, không phải lưới đầy đủ.- Chữa: viết migration mô tả đúng thay đổi tay đó, chạy qua pipeline ở dev/staging,
rồi
migrate --forcelên production (nó sẽ no-op vì thay đổi đã có), để migration table khớp lại. - 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 DatabaseMigrations và DatabaseTruncation chỗ nào?"
| Trait | Cách làm | Tốc độ |
|---|---|---|
RefreshDatabase | Migrate 1 lần (nếu schema chưa đúng), rồi mỗi test bọc trong transaction và rollback | Nhanh nhất |
DatabaseMigrations | Chạy lại migration trước mỗi test | Chậm nhất |
DatabaseTruncation | Migrate 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?"
Vì 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 migrate và deploy 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:
- 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.
pint --test— rẻ, nhanh, dọn nhiễu khỏi code review.- Cron so schema production với baseline — chặn drift im lặng (Case 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 đề.
- 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 shell → docker, 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:
- 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ứ.
- 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.
- 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.