pint --test và Static analysis

Hai job này hay bị gộp chung một chỗ trong pipeline và gọi luôn là "lint", nhưng chúng bắt hai loại lỗi khác hẳn nhau. Trả lời phỏng vấn mà gộp làm một là mất điểm ngay. Tài liệu này tách rõ: mỗi cái là gì, bắt được gì, thiếu thì mất gì thật.

Trả lời ngắn: pint --test chỉ kiểm tra hình thức code — thiếu nó thì không có bug nào lọt production, chỉ tốn thời gian review và diff bẩn. Static analysis (PHPStan/Larastan) kiểm tra ngữ nghĩa và kiểu dữ liệu — thiếu nó thì bug thật sự lọt ra, ở đúng những nhánh code mà test không phủ tới. Nếu chỉ được thêm một job vào CI: chọn static analysis.

Từ điển nhanh

Thuật ngữNghĩa ngắn gọn
linterCông cụ đọc code và báo chỗ sai quy ước — không chạy code
formatter / code style fixerCông cụ sửa hình thức code cho đồng nhất (khoảng trắng, thứ tự import, dấu ngoặc)
static analysis (phân tích tĩnh)Đọc code như một compiler và suy ra kiểu dữ liệu, tìm lỗi mà không chạy code
dry runChạy thử: làm hết mọi việc trừ việc ghi file, chỉ báo kết quả
exit codeSố lệnh trả về khi kết thúc; 0 = ok, khác 0 = lỗi. CI dựa vào đây để đỏ/xanh
preset (Pint)Bộ quy tắc định sẵn: laravel (mặc định), psr12, symfony, empty
rule level (PHPStan)Mức nghiêm khắc 0→10, càng cao càng bắt nhiều, cộng dồn từ mức dưới
baselineFile ghi lại toàn bộ lỗi đang có để bỏ qua, chỉ bắt lỗi mới sinh ra
risky fixerQuy tắc sửa code có thể đổi hành vi chứ không chỉ đổi hình thức
false positiveCông cụ báo lỗi nhưng thật ra code đúng
LarastanExtension dạy PHPStan hiểu "phép thuật" của Laravel (facade, Eloquent, container)
result cachePHPStan nhớ kết quả lần trước, lần sau chỉ phân tích file đã đổi

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

1.1. Ba tấm lưới bắt ba loại cá khác nhau

Trước khi đi vào chi tiết, cần thấy bức tranh chung. Một pipeline có ba tầng kiểm tra, mỗi tầng bắt loại lỗi mà tầng kia không thể bắt được:

flowchart TD
  CODE["Code vừa viết"] --> F["Formatter
pint --test"] CODE --> S["Static analysis
phpstan analyse"] CODE --> T["Test
phpunit / pest"] F --> F1["Hình thức:
thụt lề, thứ tự import,
khoảng trắng, trailing comma"] S --> S1["Ngữ nghĩa:
gọi method không tồn tại,
truyền sai kiểu, null không kiểm tra"] T --> T1["Hành vi:
logic nghiệp vụ đúng chưa,
tích hợp DB/queue chạy được không"] F1 --> R["Merge được"] S1 --> R T1 --> R
Chạy code?Phủ được bao nhiêuBắt lỗi gìTốc độ
PintKhông100% fileChỉ hình thứcVài giây
PHPStanKhông100% dòng codeKiểu dữ liệu, code chết, gọi saiChục giây → vài phút
TestChỉ nhánh nào có testLogic sai, tích hợp hỏngPhút → chục phút

Điểm mấu chốt để trả lời phỏng vấn: static analysis phủ 100% dòng code mà không cần viết một dòng test nào. Test phủ sâu nhưng chỉ ở nơi bạn nhớ viết test. Đó là lý do hai thứ này bổ sung nhau chứ không thay thế nhau.

1.2. Pint là gì — và --test làm gì

Laravel Pint là code style fixer cho PHP, bọc bên ngoài PHP-CS-Fixer với cấu hình sẵn theo phong cách Laravel. Laravel 9+ ship kèm luôn trong composer.json, không cần cài thêm.

Chạy trần thì nó ghi đè file:

# Sửa thẳng vào file — dùng ở máy dev
vendor/bin/pint

# Chỉ file đang sửa dở (theo git status) — nhanh hơn nhiều
vendor/bin/pint --dirty

# Chỉ file khác so với branch chỉ định
vendor/bin/pint --diff main

Trên CI thì không được ghi đè file — pipeline sửa code rồi vứt đi thì vô nghĩa, mà commit ngược lại thì sinh cả đống rắc rối (xem phần 4). Nên có --test:

# Dry run: chỉ báo file nào sai, KHÔNG sửa gì
vendor/bin/pint --test

Cơ chế bên trong đúng như tên gọi — Pint vẫn tính ra bản sửa, nhưng thay vì ghi file thì nó so sánh và trả exit code:

flowchart LR
  A["Đọc file .php"] --> B["Áp bộ rule
của preset"] B --> C{"Bản sau khác
bản gốc?"} C -- "Không" --> D["exit 0
CI xanh"] C -- "Có" --> E{"--test?"} E -- "Có" --> F["In danh sách file sai
exit 1 — CI đỏ"] E -- "Không" --> G["Ghi đè file
exit 0"]

Logic exit code thật trong source Pint (rút gọn) — đáng nhớ vì nó giải thích cả --repair:

$failure = (
    // dry run (--test) hoặc --repair mà có thay đổi
    ($summary->isDryRun() || $this->input->getOption('repair')) && count($changes) > 0
)
|| count($this->errors->getInvalidErrors()) > 0    // file parse không được
|| count($this->errors->getExceptionErrors()) > 0
|| count($this->errors->getLintErrors()) > 0;

return $failure ? Command::FAILURE : Command::SUCCESS;

Chú ý dòng getInvalidErrors(): Pint còn đỏ khi file không parse được — tức là syntax error. Đây là lợi ích phụ ít người để ý: pint --test chạy vài giây đã bắt được lỗi cú pháp trước khi test chạy vài phút mới chết.

Các flag CLI đáng biết:

FlagLàm gìDùng khi nào
--testDry run, không sửa file, exit 1 nếu có saiCI
--repairSửa file exit 1 nếu đã sửa gìPre-commit hook (sửa xong chặn commit để bạn xem lại)
--bailDừng ngay ở lỗi đầu tiên trong dry runRepo lớn, muốn CI đỏ nhanh
--dirtyChỉ file thay đổi theo git statusMáy dev
--diff mainChỉ file khác so với branch mainCI trên MR — nhanh hơn quét cả repo
--format=jsonXuất JSONĐẩy kết quả sang tool khác / annotate MR
--parallel, --max-processesChạy nhiều tiến trìnhRepo lớn
--cache-fileTrỏ file cacheCI có cache giữa các lần chạy
-vIn ra rule nào đã sửa từng fileDebug khi bất đồng với Pint

Cấu hình bằng pint.json ở gốc repo:

{
    "preset": "laravel",
    "rules": {
        "declare_strict_types": true,
        "no_unused_imports": true,
        "ordered_imports": { "sort_algorithm": "alpha" }
    },
    "exclude": ["database/migrations/0001_01_01_000000_baseline.php"]
}

1.3. Static analysis là gì — chạy thế nào bên dưới

Static analysis = đọc code và suy luận, không chạy code. PHP là ngôn ngữ động, lỗi kiểu chỉ nổ lúc runtime; static analysis mô phỏng lại việc "chạy" ở mức kiểu dữ liệu để tìm ra chỗ chắc chắn sẽ nổ.

Cơ chế của PHPStan qua 4 bước:

flowchart TD
  A["Parse code → AST
(cây cú pháp)"] --> B["Đọc PHPDoc + type hint
+ extension (Larastan)
→ dựng bảng kiểu"] B --> C["Duyệt từng nhánh code,
suy ra kiểu của mọi biểu thức
tại mọi điểm"] C --> D["Áp rule theo level
→ báo lỗi"] D --> E{"Có lỗi?"} E -- "Có" --> F["exit 1"] E -- "Không" --> G["exit 0"]

Ví dụ cụ thể nhất để hiểu "suy ra kiểu":

function getUser(int $id): ?User
{
    return User::find($id);
}

// PHPStan biết getUser() trả về User|null.
// Sau dòng dưới, kiểu của $user là User|null.
$user = getUser(5);

// → Lỗi: "Cannot access property $name on User|null"
echo $user->name;

// Sau if, PHPStan "thu hẹp" (narrow) kiểu xuống còn User → hết lỗi.
if ($user !== null) {
    echo $user->name;
}

Không có test nào cần viết. Không cần biết dòng đó có được gọi hay không. Bug Attempt to read property "name" on null — lỗi phổ biến bậc nhất trong Laravel — bị chặn ngay ở CI.

1.4. Rule level 0 → 10

PHPStan có 11 mức, cộng dồn: mức cao bao gồm mọi check của mức thấp. Ý đồ thiết kế là để repo cũ áp dụng dần, không phải sửa 5000 lỗi trong một ngày.

LevelBắt thêm gìGhi chú thực tế
0Class/function/method không tồn tại; biến chưa định nghĩaGần như repo nào cũng qua được
1Biến có thể chưa định nghĩa; magic method/property lạBắt được bug "biến chỉ gán trong nhánh if"
2Method lạ trên mọi biểu thức; kiểm tra PHPDoc có hợp lệBắt PHPDoc viết sai/lỗi thời
3Kiểu của giá trị return và gán vào propertyBắt đầu thấy giá trị thật
4Dead code cơ bản: instanceof luôn false, code không bao giờ chạy tớiRất hay lộ ra logic thừa sau refactor
5Kiểu của tham số truyền vào hàm/methodMốc thực tế tốt cho dự án Laravel
6Bắt buộc phải khai báo kiểu (thiếu type hint = lỗi)Repo cũ sẽ nổ hàng nghìn lỗi ở đây
7Union type dùng sai — từ đây "có thể đúng" bị coi là lỗiRanh giới logic: ≤6 chấp nhận maybe, ≥7 đòi yes
8Gọi method trên giá trị có thể nullBắt đúng loại bug hay gặp nhất; đắt để đạt được
9Siết mixedCần code base có type đầy đủ
10Siết cả mixed ngầm định từ thư viện ngoài / baselineDự án greenfield kỷ luật cao mới nên nhắm

Với Laravel bắt buộc cài larastan/larastan. Không có nó, PHPStan không hiểu User::where(...) (static call vào method không tồn tại), không hiểu facade Cache::get(), không hiểu $model->relation. Chạy PHPStan trần trên Laravel = hàng nghìn false positive, cả team bỏ cuộc sau một ngày.

File phpstan.neon điển hình cho dự án Laravel:

includes:
    - vendor/larastan/larastan/extension.neon
    - phpstan-baseline.neon

parameters:
    level: 5
    paths:
        - app
        - config
        - routes
        - tests
    tmpDir: build/phpstan   # result cache — nhớ cache thư mục này trên CI

1.5. Baseline — cách áp dụng vào repo cũ mà không chết

Repo 5 năm tuổi chạy PHPStan level 5 lần đầu: 3000 lỗi. Không ai sửa hết được. Baseline giải quyết đúng chuyện đó — đóng băng nợ cũ, siết code mới:

vendor/bin/phpstan analyse --level 5 \
    --configuration phpstan.neon \
    app/ tests/ --generate-baseline
flowchart LR
  A["3000 lỗi cũ"] --> B["--generate-baseline
→ phpstan-baseline.neon"] B --> C["CI xanh trở lại"] C --> D["Code MỚI vẫn bị
soi ở level 5"] D --> E["Đụng file cũ →
tiện tay xoá vài dòng baseline"] E --> F["Baseline teo dần"]

Chiêu hay dùng thêm: chạy level cao hơn mức đang enforce rồi baseline hết, để code mới tự động bị soi khắt khe hơn code cũ.

2. Lợi ích

2.1. pint --test

2.2. Static analysis

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

3.1. Hạn chế của pint --test

3.2. Hạn chế của static analysis

4. Trade-off

4.1. --test vs bot tự sửa và commit ngược lại

pint --test (CI chỉ báo)CI chạy pint rồi commit ngược
Ai sửaDevPipeline
Rắc rốiDev phải push thêm 1 lầnCần token ghi repo; dễ vòng lặp pipeline; commit không ký được; xung đột với protected branch
Bảo mậtKhông cần quyền ghiPhải cấp quyền ghi cho CI — thêm bề mặt tấn công
Khi nào chọnMặc địnhRepo rất lớn, team rất đông, đã có bot infra sẵn

Cách tốt nhất là kết hợp: pint --dirty ở pre-commit hook để dev gần như không bao giờ thấy CI đỏ vì style, còn pint --test ở CI làm chốt chặn cuối. Hook có thể bị --no-verify bỏ qua, nên CI vẫn phải có. Đừng chỉ có hook.

4.2. Chọn level PHPStan nào

Tình huốngLevel nên chọnVì sao
Repo cũ, chưa từng chạy5 + baselineLevel 5 đã bắt được lỗi kiểu tham số — giá trị/công sức tốt nhất
Dự án mới, team quen type8Bắt null safety ngay từ đầu, chưa có nợ nên không đau
Team chưa quen static analysis0 → 2, tăng dầnƯu tiên team chấp nhận công cụ hơn là bắt được nhiều lỗi
Muốn ép code mới sạch hơn code cũ8 + baseline lớnNợ cũ đóng băng, code mới bị soi ở mức cao

Chống chỉ định: nhảy thẳng lên level 9/10 trên repo cũ. Ngàn lỗi, cả team ghét công cụ, kết cục là ai đó xoá job khỏi pipeline. Level 5 chạy được còn hơn level 9 bị vô hiệu hoá.

4.3. Đặt job ở đâu trong pipeline

flowchart LR
  subgraph S1["stage: quality (song song, ~1-2 phút)"]
    P["pint --test"]
    ST["phpstan analyse"]
    AU["composer audit"]
  end
  subgraph S2["stage: test (~5-15 phút)"]
    T["phpunit / pest
+ PostgreSQL service"] end subgraph S3["stage: build"] B["docker build"] end S1 --> S2 --> S3

Trade-off ở đây là fail fast đấu với phản hồi đầy đủ:

Cách xếpĐượcMất
Quality là stage trước testTiết kiệm compute (test không chạy nếu style sai)Dev sửa dấu phẩy xong mới biết test cũng đỏ → hai vòng CI
Quality song song với testMột lần chạy biết hết mọi lỗiTốn compute minutes hơn

Với team nhỏ (3–4 người) thì chạy song song gần như luôn đúng — thời gian của người đắt hơn compute minutes rất nhiều.

4.4. Vậy thiếu nó ở stage test thì sao? (câu hỏi chính)

Đây là chỗ phải trả lời trung thực, đừng thổi phồng:

Thiếu cái gìBug lọt production?Mất gì thậtMức nghiêm trọng
pint --testGần như khôngDiff bẩn, review chậm, conflict giả, code base dần lộn xộn, git blame vô dụngThấp — nhưng tích luỹ theo thời gian
static analysisCó, thường xuyênLỗi null/kiểu nổ ở production, refactor thành trò may rủi, phải viết nhiều test hơn để bùCao

Diễn giải cho câu trả lời phỏng vấn:

Một chỗ dễ bị hỏi ngược: "Nếu pint --test không bắt bug, sao còn thêm vào CI?" — Trả lời: vì nó gần như miễn phí (vài giây, không cần service) và nó bảo vệ khả năng đọc diff. Mà đọc được diff chính là điều kiện để code review bắt được bug thật. Nó không bắt bug trực tiếp, nó làm cho người bắt bug dễ hơn.

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

5.1. Cache — không có là job static analysis chậm gấp 5–10 lần

PHPStan có result cache: nhớ kết quả cũ, lần sau chỉ phân tích file đổi. Trên CI, mỗi job là container mới nên cache biến mất nếu không khai báo:

phpstan:
  stage: quality
  script:
    - composer install --no-interaction --prefer-dist
    - vendor/bin/phpstan analyse --memory-limit=1G --no-progress
  cache:
    key: "phpstan-$CI_COMMIT_REF_SLUG"
    paths:
      - build/phpstan/     # trùng với tmpDir trong phpstan.neon
      - vendor/

Bẫy hay gặp: cache key theo branch → branch mới luôn chạy lạnh (chậm). Cách hay dùng là fallback_keys trỏ về cache của main. Còn đổi phpstan.neon hoặc nâng version PHPStan thì cache tự bị vô hiệu — đúng như mong muốn.

5.2. Memory limit

PHPStan mặc định theo memory_limit của PHP. Repo lớn dễ gặp Allowed memory size of X bytes exhausted. Luôn đặt --memory-limit=1G (hoặc -1 nếu runner có RAM thoải mái). Nếu vẫn chết: giảm level hoặc chia paths thành nhiều job.

5.3. Áp vào repo đang chạy — thứ tự việc quan trọng

flowchart TD
  A["1. Merge hết MR đang mở
(giảm conflict)"] --> B["2. Chạy pint 1 lần
commit riêng: 'style: apply pint'"] B --> C["3. Thêm hash commit đó vào
.git-blame-ignore-revs"] C --> D["4. Thêm job pint --test vào CI"] D --> E["5. Chạy phpstan, sinh baseline,
commit baseline"] E --> F["6. Thêm job phpstan vào CI"] F --> G["7. Thoả thuận: baseline chỉ được teo,
không được phình"]

Bước 3 là bước hay bị quên nhất và tiếc nhất:

# .git-blame-ignore-revs
# style: apply pint to whole codebase
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

# Bật một lần cho repo:
git config blame.ignoreRevsFile .git-blame-ignore-revs

Không có file này thì git blame mọi dòng đều trỏ về commit format, mất luôn lịch sử "ai viết dòng này, vì sao" — đúng thứ bạn cần lúc 3h sáng. GitHub và GitLab đều tự đọc file này.

5.4. Con người mới là phần khó

5.5. Debug lúc 3h sáng

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

Case 1 — Thêm pint --test vào repo 3 năm tuổi, CI đỏ 380 file

Tình huống: thêm job, pipeline đỏ ngay, 380/500 file sai style. Có 6 MR đang mở.

Xử lý theo bước:

  1. Không merge job vào master ngay. Tạo branch riêng.
  2. Báo team dừng tạo MR mới trong nửa ngày; merge hoặc rebase hết MR đang mở trước.
  3. Chạy vendor/bin/pint một lần trên toàn repo.
  4. Commit riêng biệt, không lẫn thay đổi logic: style: apply pint (laravel preset) to whole codebase.
  5. Đọc lướt diff xem có rule nào đổi hành vi không (chú ý import bị xoá, so sánh =====). Nếu preset laravel mặc định thì gần như không có, nhưng vẫn phải liếc qua.
  6. Thêm hash commit vào .git-blame-ignore-revs.
  7. Merge, rồi mới bật job pint --test.
  8. Ai còn MR dở: git rebase master rồi chạy vendor/bin/pint --dirty để dọn phần conflict.

Sai lầm cần tránh: gộp commit format với commit tính năng. Reviewer sẽ không thể tìm ra 5 dòng logic giữa 380 file đổi format, và bug lọt qua ngay lần đó.

Case 2 — Bug null lọt production ở đúng chỗ test không phủ

Tình huống: Sản phẩm mới thêm cột brand_id nullable. Code hiển thị viết:

public function render()
{
    $product = Product::find($this->productId);

    return view('product.detail', [
        'brandName' => $product->brand->name,   // brand có thể null
    ]);
}

Test chạy xanh vì factory luôn tạo sản phẩm có brand. Production có 200 sản phẩm cũ không brand → Attempt to read property "name" on null → trang 500.

PHPStan level 8 + Larastan bắt được ngay ở CI: Cannot access property $name on App\Models\Brand|null.

Xử lý:

  1. Trước mắt: sửa thành $product->brand?->name ?? 'Không rõ', deploy hotfix.
  2. Sau đó: khai báo quan hệ đúng kiểu để PHPStan hiểu — @return BelongsTo<Brand, Product> và nullable ở nơi phù hợp.
  3. Bật PHPStan level 8 cho thư mục app/Livewire trước (chia nhỏ theo path), baseline phần còn lại — thay vì cố lên level 8 cả repo cùng lúc.
  4. Thêm test cho sản phẩm không brand — nhưng đây là bổ sung, không phải giải pháp chính. Không ai nhớ viết test cho mọi tổ hợp null.

Case 3 — Baseline phình từ 300 lên 900 lỗi trong 4 tháng

Tình huống: mỗi lần CI đỏ, dev chạy --generate-baseline cho nhanh rồi commit. Job vẫn xanh nhưng không còn bắt được gì.

Xử lý:

  1. Chặn ở CI: kiểm tra baseline không được nhiều dòng hơn bản trên main. Vài dòng script là đủ, không cần tool riêng.
  2. Xoá baseline, sinh lại từ đầu để biết con số thật hiện tại.
  3. Thoả thuận rõ với team: đụng vào file nào thì dọn lỗi baseline của file đó (boy scout rule). Không đặt mục tiêu "sửa hết trong sprint này" — sẽ không xảy ra.
  4. Ai thật sự cần bỏ qua một lỗi cụ thể thì dùng @phpstan-ignore-next-line kèm comment giải thích ngay tại chỗ — nhìn thấy trong review, khác hẳn với việc giấu vào file baseline.

Case 4 — Pint đánh nhau với dev, người ta bắt đầu dùng --no-verify

Tình huống: pre-commit hook chạy pint toàn repo, mất 25 giây mỗi lần commit. Dev bực, ai cũng git commit --no-verify, CI đỏ liên tục.

Xử lý:

  1. Đổi hook sang vendor/bin/pint --dirty — chỉ file đang sửa, còn dưới 1 giây.
  2. Trên CI dùng --diff $CI_MERGE_REQUEST_TARGET_BRANCH_NAME cho MR, quét toàn bộ chỉ trên nhánh chính.
  3. Giữ pint --test ở CI làm chốt chặn — hook luôn có thể bị bỏ qua, đừng bao giờ chỉ dựa vào hook.

Bài học chung: công cụ chất lượng bị vô hiệu hoá vì chậm hoặc ồn, gần như không bao giờ vì người ta không đồng ý với nó. Tối ưu cho tốc độ phản hồi quan trọng hơn tối ưu cho độ phủ.

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

Câu hỏiTrả lời ngắn
Pint khác PHP-CS-Fixer chỗ nào? Pint bọc PHP-CS-Fixer, cấu hình sẵn theo preset Laravel, dùng JSON thay vì file PHP, và ship sẵn trong Laravel 9+. Bên dưới vẫn là PHP-CS-Fixer.
Static analysis có thay được test không? Không. Nó kiểm tra kiểu và cấu trúc, không biết nghiệp vụ. Hàm tính thuế sai công thức vẫn xanh hết. Hai thứ phủ hai chiều khác nhau: static phủ rộng-nông, test phủ hẹp-sâu.
Chọn level PHPStan nào cho dự án Laravel? Bắt đầu level 5 + Larastan + baseline. Level 8 nếu là dự án mới. Nhảy thẳng lên 9/10 trên repo cũ là cách nhanh nhất để cả team bỏ công cụ.
Vì sao không để CI tự chạy pint rồi commit lại? Cần cấp quyền ghi repo cho CI (rủi ro bảo mật), dễ tạo vòng lặp pipeline, xung đột với protected branch và commit ký. --test + pre-commit hook giải quyết cùng vấn đề mà không có mấy cái đó.
Baseline có phải là gian lận không? Không, nếu nó chỉ teo. Nó là cách nói "nợ cũ đóng băng, code mới phải sạch". Thành gian lận khi nó phình — nên cần luật và cần kiểm tra tự động.
Đặt job này trước hay song song với test? Team nhỏ: song song, để một lần chạy biết hết lỗi. Trước test chỉ hợp lý khi compute minutes đắt hơn thời gian chờ của dev.
Còn composer audit thì sao? Là tầng thứ ba trong stage quality: quét dependency có CVE đã biết. Rẻ như Pint, nhưng bắt lỗi bảo mật thật. Nên có cùng chỗ. Chú ý advisory mới có thể làm master đỏ dù không ai đổi code — cần thoả thuận trước cách xử lý.
Nếu chỉ được thêm 1 job, chọn cái nào? Static analysis. Pint bảo vệ khả năng đọc code; PHPStan bảo vệ production.
Làm sao biết static analysis có ích thật hay chỉ làm đẹp CI? Đo bằng số lỗi baseline giảm theo tháng, và số lần PHPStan chặn được lỗi trước khi lên production (đếm thủ công trong retro cũng được). Nếu baseline đứng yên và không ai nhớ nó từng bắt được gì thì đúng là chỉ để làm đẹp.

Tóm tắt để nhớ

flowchart TD
  Q["Thiếu ở stage test
thì sao?"] --> A["pint --test"] Q --> B["static analysis"] A --> A1["Không mất bug
Mất: diff bẩn, review chậm,
quy ước tự tan rã"] B --> B1["MẤT BUG THẬT
Ở đúng nhánh code
không có test phủ"] A1 --> C["Nên có — vì gần như miễn phí"] B1 --> D["Phải có"]

Liên quan: Deploy Laravel bằng GitLab CI/CD (cấu trúc stage, cache, compute minutes) và Git workflow (chỗ đặt hook và protected branch).