jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Frontend Testing Strategy at Scale: Confidence, Speed, and Trust in Large Codebases

A principal-level playbook for unit, integration, and e2e testing with Vitest, Testing Library, and Playwright—without a slow, flaky suite.

Why testing strategy matters more than tooling

Ở quy mô lớn, câu hỏi không phải “nên dùng test runner nào?” mà là “làm sao đổi được nhiều confidence nhất cho mỗi đồng thời gian CI và sự chú ý của engineer?”

Một suite chạy 45 phút và flake hai lần mỗi tuần còn tệ hơn suite nhỏ hơn chạy tám phút và chỉ fail khi hành vi thực sự regress.

Senior và principal engineer được đánh giá qua hệ thống: cách phân tầng test, seed data, cách cách ly flake, và feedback PR dưới mười phút.

Bài viết này là playbook thực dụng—không phải tuyên ngôn 100% coverage hay “không bao giờ mock”.

The trophy vs the pyramid: what actually wins

Kent C. Dodds phổ biến testing trophy: integration test nặng, e2e vừa phải, unit nhẹ, và một lát mỏng static analysis.

Testing pyramid cổ điển đảo ngược mô hình đó: nhiều unit test nhanh ở đáy, ít integration hơn, e2e ít nhất ở đỉnh.

Cả hai sơ đồ chỉ là cách dạy ngắn gọn; không phải định luật vật lý.

Điều quan trọng là confidence trên chi phí theo ba trục: tốc độ, tính xác định, và chất lượng tín hiệu (fail có chỉ đúng chỗ hỏng không?).

LayerTypical runtimeBest signal forMain failure mode
Static (TypeScript, ESLint, tsc)secondstype/API misuse, a11y lint rulesfalse sense of security on runtime behavior
Unitmilliseconds–secondspure logic, edge cases, parsersover-mocking; testing implementation
Component / integrationsecondsUI behavior + local state + mocked networkjsdom gaps vs real browser
E2Eminutescritical user journeys, auth, routingflakiness, slow feedback, env drift
Visual regressionminutes + reviewlayout/CSS regressionssnapshot noise, baseline maintenance

Kết luận principal: Tối ưu hình dạng suite theo risk profile sản phẩm—không theo slide. Dashboard B2B với tính toán phức tạp có thể cần nhiều unit hơn; checkout với ba payment provider cần e2e và contract test chặt.

Unit tests: pure logic and the mocking trap

Unit test giỏi ở chỗ input/output rõ ràng và side effect có giới hạn.

Worth unit-testing:

  • pure function: formatter, validator, reducer, selector, pricing engine
  • thuật toán có edge case: pagination, retry backoff, diff
  • map lỗi: HTTP status → copy cho user, mã lỗi domain

Usually not worth deep unit coverage:

  • wrapper React mỏng chỉ gom hook và render JSX
  • layout CSS (dùng visual hoặc e2e)
  • code chỉ “gọi API rồi hiển thị”—thuộc integration/e2e

Vitest at scale

Vitest dùng chung pipeline transform của Vite nên khởi động test nhanh trong monorepo đã dùng Vite.

// src/lib/format-currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format-currency';

describe('formatCurrency', () => {
  it('formats zero without fractional noise', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });

  it('rounds half-up for display', () => {
    expect(formatCurrency(10.005, 'USD')).toBe('$10.01');
  });
});

Dùng describe.concurrent và song song theo file cẩn thận: global state dùng chung (singleton module, Date.now không fake) gây fail phụ thuộc thứ tự.

import { vi, beforeEach, afterEach } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-05-29T12:00:00Z'));
});

afterEach(() => {
  vi.useRealTimers();
});

Mocking pitfalls that erode trust

Mock là khoản vay trước refactor—lãi trả khi module thật đổi mà test vẫn pass.

Anti-patternWhy it hurts
Mocking the module under testYou prove nothing about production behavior
Asserting call order on internal helpersBreaks on harmless refactors
Snapshotting entire error objects with stack tracesNoise on every Node/V8 bump
vi.mock of deep dependency treesTests document the mock graph, not the app

Ưu tiên dependency injection ở biên (truyền fetch, clock, storage) thay vì vi.mock tràn lan.

Nguyên tắc: Nếu xóa implementation, thay bằng return 42 mà test vẫn pass, bạn không test hành vi.

Component tests: Testing Library and the user-centric model

Component test nằm giữa unit và integration: cây render thật, dispatch event thật (phần lớn), thường mock network.

Triết lý Testing Library: query như user tìm, tương tác như user, assert kết quả—không phải state nội bộ hay data-testid trừ khi role/text không dùng được.

Thứ tự ưu tiên (rút gọn): getByRolegetByLabelTextgetByPlaceholderTextgetByText → cuối cùng getByTestId.

// CartSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartSummary } from './CartSummary';

it('applies coupon and updates total', async () => {
  const user = userEvent.setup();
  render(<CartSummary subtotal={100} />);

  await user.type(screen.getByLabelText(/coupon code/i), 'SAVE10');
  await user.click(screen.getByRole('button', { name: /apply/i }));

  expect(screen.getByRole('status')).toHaveTextContent('$90.00');
});

Dùng @testing-library/user-event thay fireEvent cho chuỗi pointer/keyboard thực tế—giảm false pass khi fireEvent.click bỏ qua disabled hoặc thiếu focus.

jsdom limits vs real-browser component testing

jsdom không phải browser: không layout, CSS không đủ, không IntersectionObserver trừ khi polyfill, semantics fetch/cookie dễ lệch.

Chấp nhận được cho test hành vi (form, ARIA, render có điều kiện). Không đủ cho hành vi phụ thuộc layout (sticky header, scroll snap, container query).

Two escape hatches:

  1. Vitest Browser Mode với @vitest/browser và Playwright provider—cùng file test, Chromium thật, vẫn cạnh source.
  2. Playwright component testing (@playwright/experimental-ct-react) khi component cần layout hoặc Web API thật.

Đổi lại: component test browser chậm hơn, debug local khó hơn; dành cho component mà false confidence jsdom tốn kém (map, rich text, drag-and-drop).

Integration tests: features across boundaries

Integration test chứng minh nhiều unit phối hợp đúng—thường route, data hook, UI cùng lúc—network được kiểm soát nhưng không mock mọi hàm lá.

Ví dụ phạm vi: “User mở /settings/billing, thấy plan từ API, upgrade, thấy toast thành công và plan cập nhật.”

MSW (Mock Service Worker) for network

MSW chặn request ở biên network—cùng code path với fetch/XHR production—khác mock axios.get trong module.

// tests/msw/handlers/billing.ts
import { http, HttpResponse } from 'msw';

export const billingHandlers = [
  http.get('/api/billing/plan', () => {
    return HttpResponse.json({ plan: 'pro', seats: 5 });
  }),
  http.post('/api/billing/upgrade', async ({ request }) => {
    const body = await request.json();
    if (body.plan !== 'enterprise') {
      return HttpResponse.json({ error: 'invalid_plan' }, { status: 400 });
    }
    return HttpResponse.json({ plan: 'enterprise', seats: 5 });
  }),
];
// BillingPage.integration.test.tsx
import { setupServer } from 'msw/node';
import { billingHandlers } from '../msw/handlers/billing';

const server = setupServer(...billingHandlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

onUnhandledRequest: 'error' không thương lượng ở scale: passthrough im lặng tới API thật tạo flake và tải staging không chủ ý.

Với GraphQL dùng helper graphql của MSW; streaming/SSE ưu tiên e2e hoặc contract test—mock stream dễ nói sai về backpressure và cancel.

End-to-end testing with Playwright

E2E là lưới an toàn giống production: browser, router, cookie, timing thật.

Cũng là nơi flake, CI chậm, và “máy tôi chạy được” va nhau—kỷ luật quan trọng hơn cú pháp.

Auto-waiting, locators, and web-first assertions

Playwright tự đợi element actionable (visible, stable, enabled) trước click và fill.

Prefer user-facing locators:

await page.getByRole('link', { name: 'Billing' }).click();
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();

Tránh chuỗi CSS/XPath gắn implementation (div.container > span:nth-child(3)).

Use web-first assertions that retry until timeout:

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('seat-count')).toHaveText('5');

Không bọc bằng waitForTimeout thủ công—che race condition và tăng thời gian suite tuyến tính.

Fixtures, parallelization, and trace viewer

Fixture Playwright gom setup ( page đã auth, handle DB seed, feature flag) không copy-paste beforeEach.

// tests/fixtures/auth.ts
import { test as base } from '@playwright/test';

export const test = base.extend<{ authedPage: Page }>({
  authedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(process.env.E2E_USER!);
    await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page).toHaveURL(/dashboard/);
    await use(page);
  },
});

Chạy worker song song (fullyParallel: true) nhưng cô lập data mỗi test—test song song không dùng chung account mutable trừ khi rollback transaction hoặc prefix tenant riêng.

Khi fail, trace viewer (trace: 'on-first-retry' hoặc 'retain-on-failure') ghi DOM, network, console—cần cho debug fail chỉ trên CI.

Avoiding flakiness: a checklist

DoDon’t
Seed deterministic data via API or SQL fixturesRely on “whatever is in staging today”
Use expect with auto-retrypage.waitForTimeout(3000)
Stub third-party widgets you do not ownLoad live Stripe/Maps in every test
One logical journey per testTen assertions after unrelated navigation
Quarantine flaky tests immediatelyRetry forever without ownership

Network idle: waitForLoadState('networkidle') không còn là chiến lược mặc định—SPA có websocket và analytics không bao giờ idle. Assert kết quả user thấy thay vì vậy.

Visual regression testing

Visual test bắt những gì behavioral test bỏ sót: lệch grid, font weight sai, regress token dark mode, overflow ở breakpoint cụ thể.

Three common approaches:

  1. Snapshot ảnh Jest/Vitest—feedback nhanh, nhiễu kinh khủng do anti-aliasing và font khác OS.
  2. Playwright toHaveScreenshot()—diff sẵn, chỉnh threshold, lưu theo project/browser.
  3. Dịch vụ hosted (Chromatic, Percy, Argos)—render cloud, UI review, workflow duyệt baseline; chi phí theo số snapshot.
await expect(page).toHaveScreenshot('billing-page.png', {
  maxDiffPixelRatio: 0.01,
  mask: [page.getByTestId('live-clock')],
});

Chi phí bảo trì thật: Đổi tên design token có thể fail hàng trăm baseline. Giảm bằng test component/page ổn định, mask vùng động (quảng cáo, timestamp, avatar CDN), chạy visual trên một image Linux CI—không phải mọi laptop dev.

Quan điểm principal: visual test là hợp đồng product/design, không thay coverage unit hay e2e.

Contract testing when services multiply

Khi frontend nói với năm microservice của bốn team, e2e một mình không báo deploy làm hỏng shape API trước khi user vào production.

Consumer-driven contract testing (Pact là tooling phổ biến) ghi expectation từ frontend (consumer) và provider verify trong CI của họ.

Brief flow:

  1. Test frontend hoặc pact test phát schema request/response mong đợi.
  2. Pact broker lưu contract.
  3. CI provider chạy verify—fail nếu response lệch mà consumer chưa duyệt.

Không thay e2e—loại bỏ cả lớp lỗi “backend đổi userId thành user_id” khỏi tầng chậm nhất.

Bỏ qua contract test nếu có một BFF với OpenAPI enforce hai phía CI; dùng schema làm contract.

Accessibility testing in CI

A11y tự động bắt ~30–50% issue—nhưng gồm thiếu label, ARIA sai, contrast—rẻ để ngăn.

axe-core via @axe-core/playwright or jest-axe in component tests:

import AxeBuilder from '@axe-core/playwright';

test('dashboard has no critical a11y violations', async ({ page }) => {
  await page.goto('/dashboard');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();
  expect(results.violations.filter(v => v.impact === 'critical')).toEqual([]);
});

Chạy axe trên route và state component đại diện (empty, loading, error)—không chỉ happy path Storybook.

Scan tự động không thay walkthrough bàn phím hay screen reader cho widget phức tạp (combobox, date picker).

Test data, factories, seeding, and environments

E2E flake thường là vấn đề data, không phải Playwright.

Patterns that scale:

  • Factory (vd. @faker-js/faker với seed cố định trên CI) tạo entity default hợp lý; test chỉ override field cần assert.
  • Seed qua API trước bước browser: tạo user + subscription qua request.newContext() nhanh hơn click admin UI.
  • Cleanup idempotent hoặc tenant riêng (e2e-${testInfo.workerIndex}-${Date.now()}) tránh test làm bẩn nhau.
import { test } from '@playwright/test';

test('invite flow', async ({ page, request }) => {
  const email = `invite-${Date.now()}@example.test`;
  await request.post('/api/test/seed-user', { data: { email, role: 'admin' } });
  await page.goto('/team/invite');
  // ...
});

Environment matrix:

EnvPurposeData
Localfast feedback, MSW-heavysynthetic
CI ephemeralPR gates, parallel shardsseeded per run
Stagingnightly e2e, contract publishshared, refreshed nightly
Productionsynthetic monitoring onlyread-only probes

Không trỏ e2e PR vào staging dùng chung không cô lập—merge queue sẽ đạp lên nhau.

CI strategy: fast PRs, thorough nights

Mục tiêu thực thi phân tầng: check rẻ mỗi push, check đắt theo lịch hoặc trước release.

What runs on PR

  • lint, typecheck, unit + integration (Vitest), detect project bị ảnh hưởng trong monorepo
  • smoke e2e: 5–15 path critical, shard qua worker
  • mục tiêu < 10 phút p95 cho flow dev

Sharding and caching

Playwright hỗ trợ --shard=1/4 chia spec file qua nhiều máy.

Cache:

  • store npm/pnpm, binary browser Playwright
  • cache transform Vite/Vitest khi an toàn
  • Không cache kết quả test qua commit nếu không có key theo lockfile + hash source

Nightly and pre-release

  • e2e đầy đủ (browser bạn thực sự support—không phải mọi bản WebKit)
  • job cập nhật visual baseline (có duyệt người)
  • budget performance, bundle size, chaos tùy chọn trên staging

Quarantining flaky tests

Test flake là sự cố production đang xếp hàng.

Process:

  1. tag @flaky hoặc project quarantine riêng trên CI
  2. ticket có owner và SLA sửa hoặc xóa trong N ngày
  3. chặn merge mới nếu tăng số quarantine

Retry Playwright (retries: 2 trên CI) là băng keo cho infra—không cho race condition. Sửa race.

Coverage: signal, not goal

Coverage Istanbul/c8 trong Vitest hữu ích tìm module critical chưa test—không phải gate 90%.

Theo dõi xu hướng coverage trên payment, auth, permission; bỏ qua file generate và trang marketing tĩnh.

A pragmatic strategy a principal would set

Dưới đây là template policy cụ thể cho frontend lớn (50+ engineer, monorepo, deploy hàng ngày).

1. Define risk tiers

TierExamplesRequired tests
P0Login, checkout, permissionse2e smoke on every PR + full nightly + contract
P1Settings, integrationsintegration + selective e2e
P2Marketing, internal toolsunit + manual QA cadence

2. Colocation and ownership

Test cạnh source; team sở hữu sửa fail trong 24h khi main gãy.

Không gate bởi “team QA” tập trung—platform cung cấp harness (helper MSW, fixture auth, seed CLI).

3. The default stack (example)

  • Vitest + Testing Library + MSW cho unit/integration
  • Playwright cho e2e và debug ưu tiên trace
  • axe trên CI cho route P0/P1
  • Pact (hoặc diff OpenAPI) cho biên service không sở hữu
  • Chromatic hoặc screenshot Playwright chỉ primitive design-system—không phải mọi page

4. Metrics that matter

  • thời lượng check PR p95
  • tỷ lệ main xanh
  • tỷ lệ flake trên 1.000 lần chạy test
  • thời gian trung bình sửa main gãy
  • defect lọt mỗi release trên flow P0

Không phải: phần trăm coverage thô trên dashboard.

5. Cultural rules

  • CI đỏ chặn merge—không “retry đến khi xanh” không điều tra.
  • Xóa test flake phải xóa hoặc thay coverage hành vi tương ứng.
  • Tính năng mới ship kèm test ở tầng thấp nhất cho confidence thật—leo lên e2e chỉ khi tầng dưới nói dối.

Closing: trust is the product

Test suite là hạ tầng user không thấy—nhưng họ cảm mỗi flake là fix trễ và mỗi test thiếu là outage.

Tối ưu tín hiệu đáng tin, chi phí phân tầng, và phục hồi nhanh khi main gãy. Trophy, pyramid, và runner mới nhất chỉ quan trọng ở chỗ phục vụ ba điều đó.

Bắt đầu đo thời lượng PR và flake rate tuần này; chọn một flow P0 và trace từ unit → integration MSW → e2e Playwright; xóa một test mock cho ra xanh giả. Đó mới là strategy ở scale—không phải slide deck thêm.