Hub

Kiểm thử

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.

  1. 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ên ref nội bộ. Khôi phục fake trong afterEach: vi.useRealTimers(), vi.unstubAllGlobals().
  2. MSW (setupServer + http.get) là seam HTTP: composable vẫn gọi fetch. 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ên flushPromises sau việc async của Vue là false-red số 1.
  3. Tách lõi thuần khi composable kéo cả thế giới: invoiceTotalCents(lines, taxBps) là unit; useCatalogSearch vớ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ĐượcMất
Unit thuần + integration MSWNhanh; test contractCần seam (hàm domain / module API)
vi.mock tất cảDễ viếtDính vào internals; tự tin giả
Testing trophyBắt “user không thanh toán được”Chậm hơn sum()
Snapshot HTML lớnRẻ để thêmFail 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.mock axios 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 ref nội bộ.
  • Fake timer và flushPromises là một phần contract. Khôi phục chúng trong afterEach.
  • 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.mock cả 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ý: cleanup afterEach.
  • Triệu chứng: PR cháy một ngày vì -u. Nguyên nhân: snapshot cả markup. Cách xử lý: assert items.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 ref nội bộ.
  • Khôi phục fake timer. flushPromises sau việc async của Vue.

Câu hỏi nối tiếp thường gặp

  • Test composable dùng onMounted thế nào? Mount bằng Testing Library, hoặc tách hàm onMounted gọ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.

  1. Cách hoạt động: Render bằng VTL render (hoặc VTU mount). Tìm getByRole("button", { name: /pay/i }). Click bằng userEvent. Chờ bằng waitFor / 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ải vm.loading.
  2. shallowMount stub child. Bạn không còn test slot, provide/inject, Teleport, hoặc PayButton lồ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ên document.body, không trên wrapper.
  3. 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 Enzyme shallow. 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 khiBỏ qua khi
Vue Testing LibraryForm, dialog, tableBạn cần wrapper.emitted tầng thấp
VTU mountEmit, slot, vmBạn chỉ quan tâm thứ user thấy
shallowMountChild là canvas/mapMặc định: nó giấu bug bạn đưa lên
Playwright componentLayout / focus thậtCase thường: E2E path đó

Góc nhìn senior

  • Query theo role, label và text. Đừng .btn-primary kèm snapshot cả trang.
  • mount bắt bug integration (slot, Teleport, Pay lồng nhau). shallowMount thì 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-if gỡ khỏi cây (exists() false). v-show vẫ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: shallowMount stub PayButton. Cách xử lý: mount.
  • Triệu chứng: query dialog ra null. Nguyên nhân: Teleport ra body. Cách xử lý: query document.body.
  • Triệu chứng: flake lúc click. Nguyên nhân: không waitFor sau 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.
  • mount bắt bug integration. shallowMount thì giấu mất.
  • Async: flushPromises / waitFor. Pinia thật, memory router.
  • Query Teleport trên document.body. Assert state accessible, không phải vm.

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ặc screen), không bao giờ wrapper component.
  • v-if khác v-show: exists() khác state accessible? v-if thì mất. v-show thì ẩn. Ưu tiên assert accessible.
  • Khi nào VTU mount vẫn hơn Testing Library? Khi bạn phải assert emitted() 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.

  1. Cách hoạt động: Playwright auto-wait: action trên locator retry đến khi element actionable; expect(locator).toBeVisible() retry đến timeout. waitForTimeout là sleep: máy chậm thì flake, máy nhanh thì nói dối. Ưu tiên assert locator hơn networkidle. Websocket và analytics không bao giờ idle.
  2. 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.
  3. Fixture (test.extend) hơn Page Object bọ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ọnGiá nếu chọnƯu tiên
Thêm E2EChậm, flake, ngốn envMột nắm: tiền + auth
Login UI mọi testPhá runtimestorageState + một spec login
networkidleTreo trên SPA websocketAssert locator
Email staging dùng chungWorker đụng nhauUser 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.
  • storageState kè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: networkidle trên SPA websocket. Cách xử lý: toBeVisible trê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.
  • networkidle treo 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.
  • networkidle làm gì trên app websocket? Nó không bao giờ fire. Dùng assert locator.
  • Khi nào Page Object vẫ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.

  1. Cách hoạt động: Instrument Vitest (coverage.provider: "v8") để lấy insight. Include src/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ên pages/ hoặc chrome SFC Vue. Test snapshot phình coverage dòng mà không đi qua nhánh.
  2. 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 (canRefund captured 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.
  3. 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ệuChứng minhNói dối khi
Coverage nhánh domainNhánh tiền/auth đã chạySuite đã mock collaborator
80% dòng cả repoCó thứ đã chạySnapshot và file generate đệm số
Path tiền PlaywrightBrowser đã click PayBạn bỏ qua khi nó đỏ
Mutation testingAssert sẽ bắt được bugBạ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ải pages/ 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, canRefund chư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 / .nuxt trong include. Cách xử lý: exclude phần generate.
  • Triệu chứng: diễn kịch. Nguyên nhân: istanbul ignore và 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ải pages/.
  • 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ên pages/ 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.

  1. Cách hoạt động: Đỏ: viết ví dụ fail của contract (canRefund trong 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”.
  2. 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 / Given 40 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à đủ.
  3. Contract test (8.6) hơn kata Cart.addItem khi 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ànhGiúpHại
TDDParser, adapter, domainCSS, UI đang khám phá, UX chưa rõ
Title BDDNgôn ngữ chung với QA/PMGlue Cucumber không ai chịu trách nhiệm
Test sauSpike nhanhDễ quên nhánh xấu
Contract test (8.6)Biên HTTP vẫn thậtKhô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à canRefund trố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.

  1. 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.
  2. 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.
  3. 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 điDùng khiBỏ khi
Type OpenAPI + MSW generateMột web app, spec trong repoKhông ai chịu trách nhiệm pipeline spec
Pact / test consumerNhiều consumer, API táchMonolith Nuxt + /server/api cạnh
Visual (Playwright / Chromatic)Atom design system + chrome checkoutMọi chart, font chưa khóa
Characterization + Playwright trướcVue 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.