Bài này nói về kiểm thử frontend mức senior cho phỏng vấn: unit Vitest, component Vue Testing Library, E2E Playwright, policy coverage, cách cân nhắc TDD/BDD, và cách đưa contract vào app Vue legacy. Người phỏng vấn chấm strategy: tầng nào bắt lỗi nào, thứ bạn từ chối test, cách giữ CI xanh khi flake. Không phải kata sum(2, 3).
Đọc 8.1–8.3 trước (unit, component, E2E). Rồi 8.4–8.5 cho coverage và cách cân nhắc TDD. 8.6 là nước đi production: contract, phạm vi visual regression, và lưới an toàn cho legacy. Cấu trúc thường thắng là testing trophy: nhiều integration quanh hành vi user, vài path Playwright cho tiền và auth, unit cho domain thuần, và static analysis làm đế rẻ. Vue 3 + TypeScript trước; cùng philosophy trên app Next là React Testing Library / Playwright.
Tóm tắt: Unit test là arrange, act, assert trên contract của hàm: tiền, permission, parser, không phải một cây component. Vitest là runner (Vite-native, tương thích Jest). HTTP nằm sau MSW (Mock Service Worker: chặn fetch/xhr ở network). Composable kéo Router, Pinia và useFetch là integration.
Vì sao quan trọng: Composable search catalog kéo Vue Router, Pinia, useFetch và plugin toast. “Bạn unit-test app Vue thế nào?” là câu mở: họ đang nghe thứ bạn từ chối mock, không phải tour config Vitest. Mock “cả thế giới” thì test vẫn xanh trong khi checkout gãy.
- Cách hoạt động: Arrange input và seam (hàm thuần, hoặc handler MSW). Act bằng cách gọi hàm hoặc gán
ref. Assert contract domain hoặc thứ user thấy (invoiceTotalCents,items.value), không phải tênrefnội bộ. Khôi phục fake trongafterEach:vi.useRealTimers(),vi.unstubAllGlobals(). - MSW (
setupServer+http.get) là seam HTTP: composable vẫn gọifetch.vi.mock("./api")buộc test vào path của module; đổi tên vẫn xanh. Fake timer (vi.useFakeTimers+advanceTimersByTimeAsync) phụ trách debounce; quênflushPromisessau việc async của Vue là false-red số 1. - Tách lõi thuần khi composable kéo cả thế giới:
invoiceTotalCents(lines, taxBps)là unit;useCatalogSearchvới Pinia thật và MSW là integration. Tương đương React: cùng cách tách, hàm thuần so với React Testing Library kèm MSW. Đừng snapshot markup lớn: nó fail vì class name đổi liên tục.
const server = setupServer(
http.get("/api/catalog", ({ request }) => {
const q = new URL(request.url).searchParams.get("q") ?? ""
return HttpResponse.json({ items: q ? [{ sku: "A", name: q }] : [] })
}),
)
beforeAll(() => server.listen())
afterEach(() => { server.resetHandlers(); vi.useRealTimers() })
it("surfaces results after the debounce settles", async () => {
vi.useFakeTimers()
const { query, items } = useCatalogSearch({ debounceMs: 300 })
query.value = "sku-9"
await vi.advanceTimersByTimeAsync(300)
await flushPromises() // flush Vue + microtask fetch; bỏ bước này → false red
expect(items.value).toEqual([{ sku: "A", name: "sku-9" }])
})
export function invoiceTotalCents(lines: Line[], taxBps: number): number {
const sub = lines.reduce((sum, l) => sum + l.qty * l.unitCents, 0)
return sub + Math.round((sub * taxBps) / 10_000) // unit cái này; đừng mock
}Điều gì xảy ra: Test search nói với MSW, không phải api.ts bị mock. Tăng 300 ms thì debounce chạy; flushPromises để Vue và fetch settle. Đổi tên ref nội bộ không fail. Đổi toán thuế trong invoiceTotalCents thì fail: đó là unit bạn giữ cạnh integration.
| Cách làm | Được | Mất |
|---|---|---|
| Unit thuần + integration MSW | Nhanh; test contract | Cần seam (hàm domain / module API) |
vi.mock tất cả | Dễ viết | Dính vào internals; tự tin giả |
| Testing trophy | Bắt “user không thanh toán được” | Chậm hơn sum() |
| Snapshot HTML lớn | Rẻ để thêm | Fail vì class name đổi |
Góc nhìn senior
- Unit-test domain thuần và composable nhỏ mà contract là hàm, không phải cây component.
- Để HTTP sau MSW.
vi.mockaxios hoặc internals Vue vẫn xanh khi checkout gãy. - Fail khi contract domain hoặc thứ user thấy đổi, không phải khi bạn đổi tên
refnội bộ. - Fake timer và
flushPromiseslà một phần contract. Khôi phục chúng trongafterEach. - Theo dõi flake và time-to-signal. Coverage dòng nằm ở 8.4. Đừng trả lời câu này bằng 80%.
Lỗi hay gặp
- Triệu chứng: suite xanh, checkout gãy. Nguyên nhân:
vi.mockcả thế giới. Cách xử lý: MSW kèm hàm domain thuần.- Triệu chứng: false red sau debounce. Nguyên nhân: quên
flushPromises/ khôi phục timer. Cách xử lý: cleanupafterEach.- Triệu chứng: PR cháy một ngày vì
-u. Nguyên nhân: snapshot cả markup. Cách xử lý: assertitems.value.- Triệu chứng: test composable mount Vue, Pinia và toast. Nguyên nhân: bạn gọi đó là unit. Cách xử lý: gọi là integration, hoặc tách lõi.
Nhà tuyển dụng hay hỏi gì
Composable search catalog kéo Router, Pinia, useFetch và một toast. Họ muốn nghe thứ bạn từ chối mock, không phải một tour config Vitest.
Trả lời khi phỏng vấn
Mình unit-test domain thuần: tiền, permission, parser, và để HTTP sau MSW. Composable kéo Router, Pinia và useFetch là integration, hoặc mình tách lõi thuần rồi unit lõi ấy. Mình không mock internals Vue hay cả module axios; những test đó vẫn xanh khi checkout gãy. Mình flushPromises sau việc async và khôi phục fake timer trong afterEach. Mình theo dõi flake và time-to-signal, không phải coverage dòng. Ở sản phẩm admin, test search catalog tăng debounce, chờ MSW, rồi assert items: đổi tên ref nội bộ không fail. Toán thuế nằm ở invoiceTotalCents cạnh test đó.
Nhớ nhanh
- Unit = domain thuần. HTTP = MSW. Composable kéo cả thế giới là integration.
- Đừng mock internals Vue, cả module axios, hoặc snapshot của cả trang.
- Fail khi contract đổi, không phải khi đổi tên
refnội bộ. - Khôi phục fake timer.
flushPromisessau việc async của Vue.
Câu hỏi nối tiếp thường gặp
- Test composable dùng
onMountedthế nào? Mount bằng Testing Library, hoặc tách hàmonMountedgọi rồi unit hàm ấy. - Vitest + Vue Testing Library khác Jest + RTL? Cùng contract: query theo role, MSW cho HTTP. Vitest là Vite-native.
- Vì sao không lấy Cypress component test làm mặc định năm 2026? Runner nặng hơn; giữ component ở Vitest/VTL và E2E ở Playwright.
- Thứ bạn từ chối test ở tầng unit? Internals Vue, CSS, và composable kéo cả app.
Tóm tắt: Test component assert hành vi user thấy, không phải implementation. Query theo role, label và text; click như user. mount (cả cây) là mặc định. shallowMount stub child và giấu bug bạn đưa lên. Vue Testing Library (VTL) là wrapper Testing Library; Vue Test Utils (VTU) là wrapper tầng thấp.
Vì sao quan trọng: Họ đưa nút Pay rồi hỏi mount khác shallowMount thế nào, cách chờ UI async, và bạn assert gì. wrapper.find(".btn-primary") kèm snapshot cả HTML là câu junior. Đổi tên class không được fail; gỡ emit("paid") thì phải fail.
- Cách hoạt động: Render bằng VTL
render(hoặc VTUmount). TìmgetByRole("button", { name: /pay/i }). Click bằnguserEvent. Chờ bằngwaitFor/flushPromises: Vue flush DOM bất đồng bộ, nên click trả về trước khi handler xong là nói dối. Assert disabled / text / có mặt, không phảivm.loading. shallowMountstub child. Bạn không còn test slot, provide/inject, Teleport, hoặcPayButtonlồng nhau mà không bao giờ disable. Chỉ dùng khi child là canvas hoặc map bạn không thể render. Query dialog Teleport trêndocument.body, không trên wrapper.- Chạy Pinia thật và memory router. Stub mọi action là đang test cái mock. Tương đương React: cùng contract là React Testing Library:
getByRole,userEvent, không Enzymeshallow. Playwright component dành cho layout hoặc focus thật; thường E2E path đó (8.3).
it("disables Pay while the payment intent is in flight", async () => {
const user = userEvent.setup()
const pinia = createPinia() // store thật; stub pay() là test cái mock
render(PayButton, {
props: { amountCents: 1999, currency: "USD" },
global: { plugins: [pinia] },
})
await user.click(screen.getByRole("button", { name: /pay \$19\.99/i }))
await waitFor(() => {
expect(screen.getByRole("button", { name: /processing/i })).toBeDisabled()
})
// đổi tên .btn-primary → vẫn xanh; gỡ emit("paid") → đỏ
})Điều gì xảy ra: Người test click nút Pay theo tên, không phải .btn-primary. Khi intent đang gửi, tên accessible thành Processing và control bị disable. waitFor phủ flush async của Vue. Đổi tên class vẫn xanh; gỡ emit paid thì không.
| Công cụ | Dùng khi | Bỏ qua khi |
|---|---|---|
| Vue Testing Library | Form, dialog, table | Bạn cần wrapper.emitted tầng thấp |
VTU mount | Emit, slot, vm | Bạn chỉ quan tâm thứ user thấy |
shallowMount | Child là canvas/map | Mặc định: nó giấu bug bạn đưa lên |
| Playwright component | Layout / focus thật | Case thường: E2E path đó |
Góc nhìn senior
- Query theo role, label và text. Đừng
.btn-primarykèm snapshot cả trang. mountbắt bug integration (slot, Teleport, Pay lồng nhau).shallowMountthì giấu mất.- Async:
flushPromises/waitFor. Click trả về trước khi handler xong là nói dối. - Pinia thật và memory router. Stub mọi action là test cái mock.
v-ifgỡ khỏi cây (exists()false).v-showvẫn còn, chỉ ẩn: assert state accessible.
Lỗi hay gặp
- Triệu chứng: đổi tên class fail CI. Nguyên nhân: selector CSS + snapshot. Cách xử lý: role và tên.
- Triệu chứng: Pay không disable ở prod. Nguyên nhân:
shallowMountstubPayButton. Cách xử lý:mount.- Triệu chứng: query dialog ra null. Nguyên nhân: Teleport ra
body. Cách xử lý: querydocument.body.- Triệu chứng: flake lúc click. Nguyên nhân: không
waitForsau Vue async. Cách xử lý:waitFor/flushPromises.
Nhà tuyển dụng hay hỏi gì
Một nút Pay: mount khác shallowMount, bạn chờ thế nào, và assert cái gì. Class CSS kèm snapshot là câu junior.
Trả lời khi phỏng vấn
Mình query theo role, label và text rồi click như user. Greenfield thì dùng Vue Testing Library, cùng contract với React Testing Library. mount là mặc định vì shallowMount stub những child mình ship. Mình chờ bằng flushPromises hoặc waitFor, không dùng kiểu click trả về trước khi handler xong. Mình dùng Pinia thật và memory router; stub mọi action là test cái mock. Dialog Teleport thì query trên document.body. Ở sản phẩm admin, Pay disable khi intent đang xử lý; đổi tên btn-primary không fail, gỡ emit paid thì fail.
Nhớ nhanh
- Theo role / label / text. Đừng
.btn-primary+ snapshot cả trang. mountbắt bug integration.shallowMountthì giấu mất.- Async:
flushPromises/waitFor. Pinia thật, memory router. - Query Teleport trên
document.body. Assert state accessible, không phảivm.
Câu hỏi nối tiếp thường gặp
- Test listbox kiểu Headless UI / Radix thế nào? Mở như user; assert option theo role và tên; đừng chọc
vm.open. - Query dialog Teleport ở đâu?
document.body(hoặcscreen), không bao giờ wrapper component. v-ifkhácv-show: exists() khác state accessible?v-ifthì mất.v-showthì ẩn. Ưu tiên assert accessible.- Khi nào VTU
mountvẫn hơn Testing Library? Khi bạn phải assertemitted()hoặc slot props ở tầng thấp.
Tóm tắt: Playwright chạy browser thật, chỉ trên critical path: login, permission-denied, checkout, refund, SSO. Chọn theo role và tên. Auto-wait trên assert (toBeVisible), đừng waitForTimeout. Auth là storageState từ project setup, kèm một spec login riêng.
Vì sao quan trọng: Checkout đỏ trên CI cứ thứ Ba cách tuần vì waitForTimeout(3000) và email staging dùng chung. Câu “Bạn E2E gì?” rồi đến flake, POM khác fixture, auth, trace, và worker. E2E là thứ CI bỏ qua đầu tiên khi đỏ. Hãy budget một nắm spec.
- Cách hoạt động: Playwright auto-wait: action trên locator retry đến khi element actionable;
expect(locator).toBeVisible()retry đến timeout.waitForTimeoutlà sleep: máy chậm thì flake, máy nhanh thì nói dối. Ưu tiên assert locator hơnnetworkidle. Websocket và analytics không bao giờ idle. - Auth: project setup ghi
storageState; project khác dùng lại. Giữ một spec login UI để regression login không giấu được. Worker > 1 cần user riêng (email unique), không dùng chung một cashier. Trace-on-first-retry giúp job đỏ chẩn đoán được mà không SSH. - Fixture (
test.extend) hơnPage Objectbọc mọi click. POM đáng dùng khi cùng widget checkout xuất hiện trong ba spec. Tắt animation (prefers-reduced-motion). Playwright hơn Cypress năm 2026: nhiều context, trace,storageState, không magic retry ngầm trên click. Analog Vue: cùng path trên app Nuxt; analog Next: cũng vậy, chú ý payload RSC khác nav client.
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
use: {
trace: "on-first-retry", // job đỏ không có trace là tiền bỏ đi
screenshot: "only-on-failure",
storageState: "playwright/.auth/user.json",
},
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{ name: "chromium", dependencies: ["setup"] },
],
})
test("checkout completes with a saved card", async ({ page }) => {
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible()
// đừng waitForTimeout(3000): auto-wait heading
})Điều gì xảy ra: Setup login một lần rồi ghi cookie. Chromium dùng lại state đó, nên checkout không gõ password. Assert heading được auto-wait. Không còn sleep timeout. Fail trên CI thì retry kèm trace bạn mở được local.
| Lựa chọn | Giá nếu chọn | Ưu tiên |
|---|---|---|
| Thêm E2E | Chậm, flake, ngốn env | Một nắm: tiền + auth |
| Login UI mọi test | Phá runtime | storageState + một spec login |
networkidle | Treo trên SPA websocket | Assert locator |
| Email staging dùng chung | Worker đụng nhau | User riêng theo worker |
Góc nhìn senior
- E2E mặc định chỉ tiền và auth. Integration (8.2) chịu phần còn lại của trophy.
- Role + tên. CSS và XPath là cửa flake đi vào. Auto-wait; đừng
waitForTimeout. storageStatekèm một spec login riêng. Worker cần user riêng.- Trace-on-retry và p95 duration là cách giữ CI trung thực. Job đỏ không có trace là tiền bỏ đi.
- Playwright hơn Cypress năm 2026: context, trace,
storageState, không magic retry click ngầm.
Lỗi hay gặp
- Triệu chứng: đỏ cứ thứ Ba cách tuần. Nguyên nhân:
waitForTimeout+ email dùng chung. Cách xử lý: assert locator + user riêng.- Triệu chứng: CI treo. Nguyên nhân:
networkidletrên SPA websocket. Cách xử lý:toBeVisibletrên heading.- Triệu chứng: regression login lên prod. Nguyên nhân: mọi spec chỉ dùng
storageState. Cách xử lý: một spec login UI.- Triệu chứng: đỏ không chẩn đoán được. Nguyên nhân: không trace. Cách xử lý:
trace: "on-first-retry".
Nhà tuyển dụng hay hỏi gì
Checkout đỏ trên CI vì sleep ba giây và email staging dùng chung. Họ muốn biết bạn E2E gì, cách dẹp flake, và job đỏ có trace không.
Trả lời khi phỏng vấn
Mình E2E login, checkout, refund và SSO, mặc định không thêm gì khác. Mình chọn theo role và tên, không bao giờ waitForTimeout. Auth là storageState từ project setup, kèm một spec login riêng để regression login không giấu được. Worker trên một thì cần user riêng. Job đỏ không có trace là tiền bỏ đi. Playwright hơn Cypress năm 2026: nhiều context, trace, storageState, không magic retry ngầm trên click. Ở sản phẩm admin team mình có bốn path tiền và một spec login; dashboard là p95 và tỉ lệ flake, không phải số spec.
Nhớ nhanh
- E2E chỉ tiền và auth. Role + tên. Đừng
waitForTimeout. storageState+ fixture hơn một POM bọc mọi click.- Trace-on-retry và user riêng giữ CI trung thực.
networkidletreo trên SPA websocket. Assert heading.
Câu hỏi nối tiếp thường gặp
- Vì sao Playwright hơn Cypress năm 2026? Context, trace,
storageState, đa tab. Cypress vẫn retry click theo cách giấu bug. - E2E SSO thế nào? Một setup đi hết flow IdP; dùng lại
storageState; đừng click OAuth trong từng spec. networkidlelàm gì trên app websocket? Nó không bao giờ fire. Dùng assert locator.- Khi nào
Page Objectvẫn đáng? Cùng widget checkout trong ba spec, không phải wrapper quanh mọi click.
Tóm tắt: Coverage là nhánh nào của code rủi ro đã chạy, không phải điểm chất lượng. Coverage dòng dễ bị làm đẹp. Coverage nhánh trên tiền, auth và permission mới là số hữu ích. v8 (mặc định của Vitest) không thấy Playwright. Báo cáo unit 90% vẫn có thể nghĩa là chưa ai click checkout trên browser.
Vì sao quan trọng: “Bạn yêu cầu coverage bao nhiêu?” Nếu bạn nói 80% làm gate cả repo, nhiều người phỏng vấn chấm junior, nhất là khi canRefund() chưa test và snapshot đệm số. Team thêm /* istanbul ignore */ và file generate để bảo vệ metric. Đó là diễn kịch coverage.
- Cách hoạt động: Instrument Vitest (
coverage.provider: "v8") để lấy insight. Includesrc/domain/**và composable auth/checkout. Exclude spec và code generate. Fail CI khi coverage nhánh tụt trong list include đó, không phải trênpages/hoặc chrome SFC Vue. Test snapshot phình coverage dòng mà không đi qua nhánh. - E2E critical path (8.3) là gate riêng. Coverage của suite mock hết mọi thứ là coverage cái mock. Hàm permission có nhánh (
canRefundcaptured khác pending khác expired); snapshot nút refund thì không. Mutation testing (Stryker) chỉ đáng trên module domain nhỏ, sau khi đã có số nhánh. - Trên Next, wrapper RSC và type generate sẽ phá gate cả repo. Cùng policy: instrument domain, không phải
app/. Analog Vue: đừng include.nuxt/hoặc stub auto-import. Câu phỏng vấn là bản đồ rủi ro, không phải phần trăm.
// Để lấy insight, không phải club 80%. Nhánh tụt ở domain fail CI; pages/ thì không.
export default defineConfig({
test: {
coverage: {
provider: "v8", // không thấy path Playwright
include: ["src/domain/**", "src/composables/useAuth.ts", "src/composables/useCheckout.ts"],
exclude: ["**/*.spec.ts", "src/generated/**"],
thresholds: {
"src/domain/**": { branches: 90, statements: 0 },
},
reporter: ["text", "html"],
},
},
})Điều gì xảy ra: v8 chỉ báo list include. Nhánh tụt ở src/domain fail CI. pages/ có thể ngồi ở 20% mà không chặn. Playwright vẫn phải click checkout. Config này không thấy click đó.
| Tín hiệu | Chứng minh | Nói dối khi |
|---|---|---|
| Coverage nhánh domain | Nhánh tiền/auth đã chạy | Suite đã mock collaborator |
| 80% dòng cả repo | Có thứ đã chạy | Snapshot và file generate đệm số |
| Path tiền Playwright | Browser đã click Pay | Bạn bỏ qua khi nó đỏ |
| Mutation testing | Assert sẽ bắt được bug | Bạn chạy nó trên SFC Vue |
Góc nhìn senior
- Cover rủi ro, không phải dòng. Tiền, auth, permission, PII, refund, mặc định feature-flag.
- v8 không thấy Playwright. Báo cáo unit 90% vẫn có thể nghĩa là chưa ai click checkout.
- Fail CI khi branch coverage tụt ở
src/domain, không phảipages/hay chrome SFC. - Coverage của suite mock hết là coverage cái mock. Hàm permission có nhánh; snapshot thì không.
- Mutation testing là dao mổ trên module domain nhỏ, không phải gate cho app Vue.
Lỗi hay gặp
- Triệu chứng: 90% xanh,
canRefundchưa test. Nguyên nhân: snapshot đệm dòng. Cách xử lý: ngưỡng nhánh trên domain.- Triệu chứng: gate Next/Nuxt luôn đỏ. Nguyên nhân: wrapper RSC /
.nuxttrong include. Cách xử lý: exclude phần generate.- Triệu chứng: diễn kịch. Nguyên nhân:
istanbul ignorevà test rỗng. Cách xử lý: review list include, không phải con số.- Triệu chứng: checkout chưa test trên browser. Nguyên nhân: tin v8. Cách xử lý: path tiền Playwright làm gate riêng.
Nhà tuyển dụng hay hỏi gì
“Bạn yêu cầu coverage bao nhiêu?” 80% làm gate cả repo, trong khi canRefund() chưa test, là câu junior. Họ muốn bản đồ rủi ro.
Trả lời khi phỏng vấn
Mình không gate cả repo ở 80%. Mình cover rủi ro: tiền, auth, permission, refund. v8 không thấy Playwright, nên báo cáo unit 90% vẫn có thể nghĩa là chưa ai click checkout trên browser. Mình fail CI khi branch coverage tụt ở src/domain, không phải pages. Coverage của suite mock hết là coverage cái mock. canRefund có nhánh; snapshot nút refund thì không. Ở admin nội bộ, bọn mình instrument domain và composable checkout, còn Playwright vẫn chịu trách nhiệm Pay. Mutation testing chỉ nằm ở module thuế.
Nhớ nhanh
- Cover rủi ro, không phải dòng. Gate 80% cả repo thường là diễn kịch.
- v8 không thấy Playwright. Checkout vẫn cần spec browser.
- Fail CI trên branch coverage của
src/domain, không phảipages/. - Suite mock cover cái mock. Snapshot đệm dòng, không phải nhánh.
Câu hỏi nối tiếp thường gặp
- Bạn có fail build khi coverage tụt? Có trên nhánh
src/domain. Không trênpages/hay file generate. - Khác gì trên Next? Wrapper RSC và type generate phá gate cả repo. Instrument domain.
- Khi nào mutation testing đáng? Module domain nhỏ đã đóng băng, sau khi đã có branch coverage.
- Vì sao coverage suite mock hết gây hiểu nhầm? Bạn đo cái mock, không phải collaborator bạn ship.
Tóm tắt: TDD (test-driven development) là đỏ → xanh → refactor trên một contract ổn định: giá, RBAC, parser, adapter trên fixture OpenAPI đã đóng băng. BDD (behaviour-driven development) là ngôn ngữ chung trong title test (Given / When / Then), không phải stack Cucumber. Đừng TDD padding của modal.
Vì sao quan trọng: “Bạn có TDD không?” Họ muốn cách cân nhắc, không phải tuyên ngôn, và họ ngửi ra diễn kịch TDD (test đề ngày hôm qua, viết sau) hoặc “we TDD” kèm 0% coverage permission. Nếu PM vẫn đang đổi bước checkout, test-first chỉ mã hóa phỏng đoán của hôm qua.
- Cách hoạt động: Đỏ: viết ví dụ fail của contract (
canRefundtrong 24h). Xanh: implementation nhỏ nhất. Refactor: tên và chỗ trùng, test vẫn xanh. Tên test là spec bạn review trong PR: “Given a captured card payment, When it is within 24h, Then the cashier may refund in full”. - Spike UI đang khám phá, rồi khóa hành vi bằng Testing Library (8.2). TDD adapter trên fixture đã đóng băng; khi spec vẫn còn đổi, bạn đang mã hóa một phỏng đoán. Cucumber /
Given40 tầng chỉ đáng nếu org đã chạy Gherkin và có người chịu trách nhiệm glue. Không thì title là đủ. - Contract test (8.6) hơn kata
Cart.addItemkhi nói “TDD trong codebase này”. Review tên test như spec. Từ chối test assert nội bộ. Thành thật “khi TDD làm mình chậm”: visual design system, hoặc spike một route Nuxt mới.
// Đỏ: fail đến khi canRefund tồn tại. Title là spec QA/PM đọc được.
describe("refund eligibility", () => {
it("Given a captured card payment, When it is within 24h, Then the cashier may refund in full", () => {
expect(canRefund({ status: "captured", method: "card", ageHours: 6 })).toEqual({
allowed: true,
maxCents: 6_00,
})
})
it("Given a pending payment, When the cashier opens refund, Then it is denied", () => {
expect(canRefund({ status: "pending", method: "card", ageHours: 1 }).allowed).toBe(false)
})
})
// Xanh: implement canRefund. Refactor: tách ageHours khác status mà không gãy title.Điều gì xảy ra: Title chính là spec product. Đỏ fail vì thiếu hàm. Xanh là canRefund nhỏ nhất. Refactor nội bộ sau đó vẫn giữ cả hai title xanh. QA đọc tên mà không cần mở Cucumber.
| Thực hành | Giúp | Hại |
|---|---|---|
| TDD | Parser, adapter, domain | CSS, UI đang khám phá, UX chưa rõ |
| Title BDD | Ngôn ngữ chung với QA/PM | Glue Cucumber không ai chịu trách nhiệm |
| Test sau | Spike nhanh | Dễ quên nhánh xấu |
| Contract test (8.6) | Biên HTTP vẫn thật | Không pipeline spec thì chúng mục |
Góc nhìn senior
- TDD cho contract đã đóng băng và domain thuần. Không cho UI đang khám phá hay padding modal.
- BDD là ngôn ngữ chung trong title test, không phải stack Cucumber trừ khi org đã chạy Gherkin.
- Review tên test như spec. Từ chối test assert nội bộ.
- “We TDD” kèm zero test permission là tín hiệu. Chỉ vào
canRefund, không phải kata. - Contract test ở 8.6 là TDD bảo vệ app Vue khỏi API lệch.
Lỗi hay gặp
- Triệu chứng: test ghi ngày hôm qua. Nguyên nhân: diễn kịch TDD. Cách xử lý: review ngày git so với feature; hỏi commit đỏ.
- Triệu chứng: flow nặng designer chậm 3×. Nguyên nhân: giáo điều TDD trên padding. Cách xử lý: spike UI, rồi khóa bằng Testing Library.
- Triệu chứng: QA phụ trách Cypress, dev phụ trách Vitest, không chung ngôn ngữ. Nguyên nhân: BDD chết ở hai toolchain. Cách xử lý: title cả hai phía.
- Triệu chứng: “we TDD” và
canRefundtrống. Nguyên nhân: văn hóa kata. Cách xử lý: TDD contract tiền.
Nhà tuyển dụng hay hỏi gì
“Bạn có TDD không?” Họ muốn chỗ nào có, chỗ nào không, và “we TDD” có đang che lỗ permission không.
Trả lời khi phỏng vấn
Mình TDD giá, RBAC và adapter trên fixture đã đóng băng. Mình không đỏ-xanh padding của modal: mình spike UI rồi khóa hành vi bằng Testing Library. Given / When / Then là cách mình nói với QA và PM; nó nằm ở title test. Mình không cần Cucumber trừ khi org đã chạy Gherkin. We TDD kèm zero test permission là tín hiệu. Contract test ở 8.6 mới là TDD bảo vệ app Vue này. Ở admin nội bộ canRefund được viết đỏ trước; bước checkout thì không, vì PM vẫn đang dời chúng.
Nhớ nhanh
- TDD cho contract đã đóng băng và domain thuần. Không cho UI đang khám phá.
- BDD là ngôn ngữ chung trong title test, không phải stack Cucumber.
- Review tên test như spec. Từ chối test assert nội bộ.
- Contract test (8.6) hơn kata khi nói “TDD trong codebase này”.
Câu hỏi nối tiếp thường gặp
- Lần TDD làm bạn chậm gần nhất? Visual design system, hoặc spike route Nuxt mới: contract chưa đóng băng.
- Review PR TDD thế nào? Đọc title như spec. Từ chối nội bộ. Hỏi commit đỏ.
- Vì sao contract test hơn kata Cart.addItem? Chúng bảo vệ biên HTTP bạn ship.
- QA phụ trách Cypress, dev phụ trách Vitest: BDD sống thế nào? Title chung cả hai phía, không phải hai stack Gherkin.
Tóm tắt: Contract test kiểm tra hai phía của một biên vẫn khớp, thường là HTTP: type generate và handler MSW từ cùng spec OpenAPI, để breaking change fail CI trước merge. Visual regression diff pixel trên theme đã khóa. Trên Vue legacy, characterize tiền/auth trước; đừng bắt đầu ở 80% coverage.
Vì sao quan trọng: API đổi mà UI vẫn lên. Hoặc: app Vue 2/3 không có test, ai đó muốn 80% coverage sau rewrite Composition API. Bạn bắt đầu từ đâu, và có làm visual regression không? Type TS viết tay bị lệch. Percy mọi chart dashboard thành 200 diff flake vì font.
- Cách hoạt động: Một spec → type OpenAPI + handler MSW generate từ đó. Test component đánh những handler đó, nên chúng test UI theo contract, không phải mock tưởng tượng. Pact (consumer-driven contracts) giúp khi nhiều consumer deploy tách khỏi API. Bỏ Pact trên monolith Nuxt với
server/apiđặt cạnh. - Visual: atom design system kèm vài màn marketing/checkout. Khóa theme, viewport, timezone,
animations: disabled. Diff được review, không auto-approve. Bỏ mọi chart dashboard và webfont chưa khóa. Storybook/Chromatic cho atom; screenshot Playwright cho path chrome checkout. - Vue legacy không có test: Playwright trên login và path doanh thu trước (characterization: khóa hành vi hiện tại, dù xấu). Tách hàm thuần rồi unit những hàm đó. Rồi MSW trên client Axios sẵn có. Đừng chờ rewrite, và đừng bắt đầu ở 80%. Giữ E2E xanh xuyên đổi compiler Vue 2 → 3 để còn bước được.
// Cùng spec OpenAPI → type + MSW. Field phá vỡ fail CI trước merge.
import { paths } from "./openapi" // generated
import { HttpResponse, http } from "msw"
type Catalog = paths["/api/catalog"]["get"]["responses"]["200"]["content"]["application/json"]
export const catalogHandler = http.get("/api/catalog", () =>
HttpResponse.json({ items: [{ sku: "A", name: "sku-9" }] } satisfies Catalog),
)
// Characterization legacy: khóa hôm nay, rồi tách. Đừng bắt đầu ở 80%.
test("cashier can refund a captured card from the order page", async ({ page }) => {
await page.goto("/orders/1001")
await page.getByRole("button", { name: "Refund" }).click()
await expect(page.getByRole("status")).toHaveText(/refund queued/i)
})Điều gì xảy ra: satisfies Catalog sẽ không compile nếu spec bỏ name. Spec Playwright khóa hành vi refund trên màn Vue 2 bạn đang có (lưới characterization) trước khi ai rewrite SFC.
| Nước đi | Dùng khi | Bỏ khi |
|---|---|---|
| Type OpenAPI + MSW generate | Một web app, spec trong repo | Không ai chịu trách nhiệm pipeline spec |
| Pact / test consumer | Nhiều consumer, API tách | Monolith Nuxt + /server/api cạnh |
| Visual (Playwright / Chromatic) | Atom design system + chrome checkout | Mọi chart, font chưa khóa |
| Characterization + Playwright trước | Vue legacy, không test | “Rewrite rồi test” / 80% trước |
Góc nhìn senior
- Một spec → type + MSW. Breaking change trên OpenAPI fail CI trước merge.
- Pact khi consumer deploy tách nhau. Bỏ trên BFF Nuxt nằm cùng app.
- Visual: design system + vài màn, font khóa, diff được review. Không phải mọi chart.
- Legacy: Playwright trên tiền/auth trước. Characterize app bạn đang có. Rồi tách hàm thuần.
- Đừng chờ rewrite. Đừng bắt đầu ở 80% coverage. Giữ E2E xanh xuyên Vue 2 → 3.
Lỗi hay gặp
- Triệu chứng: UI lên, API gãy. Nguyên nhân: type viết tay. Cách xử lý: generate type + MSW từ một spec.
- Triệu chứng: 200 visual diff flaky. Nguyên nhân: font chưa khóa / mọi chart. Cách xử lý: atom + checkout, khóa theme.
- Triệu chứng: rewrite kẹt, vẫn chưa có test. Nguyên nhân: 80% trước. Cách xử lý: Playwright tiền/auth, rồi tách.
- Triệu chứng: mock HAR lệch. Nguyên nhân: record một lần, không bao giờ generate lại. Cách xử lý: generate từ pipeline spec.
Nhà tuyển dụng hay hỏi gì
API đổi mà UI vẫn lên, hoặc app Vue 2 không có test và ai đó muốn 80% sau rewrite. Bạn bắt đầu từ đâu, và có visual-regress không?
Trả lời khi phỏng vấn
Mình coi ranh giới HTTP là contract: type generate và MSW từ cùng spec OpenAPI, nên breaking change fail CI trước merge. Visual chỉ nằm ở design system và chrome checkout, font khóa, diff được review, không phải mọi chart dashboard. Trên app Vue legacy, mình cho Playwright chạy login và path doanh thu trước, khóa hành vi hiện tại bằng characterization test, rồi tách hàm thuần. Mình không chờ rewrite, và không bắt đầu ở 80% coverage. Pact dành cho nhiều consumer; monolith Nuxt với server route nằm cùng app thì không cần.
Nhớ nhanh
- Một spec → type + MSW. Breaking change trên OpenAPI fail CI trước merge.
- Visual: design system + vài màn, font khóa, diff được review.
- Legacy: Playwright trên tiền/auth trước. Test app bạn đang có.
- Pact khi consumer deploy tách nhau. Bỏ trên BFF nằm cùng app.
Câu hỏi nối tiếp thường gặp
- Version contract thế nào? Cửa sổ back-compat, feature flag trên field mới, test consumer-driven khi nhiều app cùng dùng.
- Storybook khác Playwright cho visual? Atom ở Chromatic/Storybook. Chrome checkout ở Playwright.
- Vì sao giữ E2E xanh xuyên Vue 2 → 3? Để đổi compiler không âm thầm gãy Pay.
- Khi nào Pact thừa so với MSW generate? Một web app, spec trong repo: generate. Nhiều consumer: Pact.