Danh sách bài viết

Bài 120: Slow API Response Simulation

Delay API response trong test bằng page.route() và setTimeout để kiểm tra loading spinner, skeleton UI, timeout handling, optimistic UI, và race condition. Bài này cover pattern từ delay đơn giản đến variable delay, chaos testing, kết hợp delay với modify response, và 4 pitfall phổ biến nhất.

28/05/2026
13 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau khi hoàn thành bài này, bạn sẽ:

  • Hiểu tại sao cần simulate slow API để test loading state đúng cách.
  • Dùng page.route() + setTimeout để delay response theo từng route.
  • Viết test cho loading spinner, skeleton UI, timeout handling, optimistic UI.
  • Kết hợp delay với modify response trong cùng một route handler.
  • Dùng variable delay và random delay cho chaos testing.
  • Biết khi nào nên dùng Clock API thay vì real delay.
  • Tránh 4 pitfall làm test fail hoặc bị flaky.
2

Vấn Đề: API Nhanh Che Giấu Lỗi UI

Trong môi trường test, API thường respond trong vài milliseconds — nhanh hơn nhiều so với production. Điều này dẫn đến một số vấn đề:

  • Loading spinner không bao giờ visible vì API xong trước khi test assert.
  • Skeleton UI flash quá nhanh — test không thể capture.
  • Timeout error path chưa bao giờ được test vì API không bao giờ chậm.
  • Race condition ẩn chỉ xuất hiện khi API chậm hơn một ngưỡng nhất định.

Giải pháp là inject delay nhân tạo vào route handler, giữ nguyên response thật nhưng trì hoãn thời điểm nó về đến browser.

3

Pattern Cơ Bản — Delay Với setTimeout

Route handler là async function — bạn có thể await bất cứ Promise nào trước khi gọi route.continue() hoặc route.fulfill(). Delay đơn giản nhất dùng setTimeout bọc trong Promise:

await page.route('**/api/data', async (route) => {
  // Đợi 3 giây trước khi forward request
  await new Promise(resolve => setTimeout(resolve, 3_000));
  await route.continue();  // Forward đến server thật
});

Hoặc nếu muốn trả response mock thay vì forward:

await page.route('**/api/data', async (route) => {
  await new Promise(resolve => setTimeout(resolve, 3_000));
  await route.fulfill({
    status: 200,
    json: { items: [] },
  });
});

Cách delay hoạt động: Route handler block đến khi Promise resolve, rồi mới gọi continue()/fulfill(). Browser đang chờ response — đây là thời điểm app render loading state. Test assert trong khoảng thời gian này.

4

Test Loading Spinner

Pattern chuẩn để test spinner: assert spinner visible ngay sau khi trigger request, rồi assert nó ẩn sau khi response về.

test('shows spinner during API load', async ({ page }) => {
  await page.route('**/api/data', async (route) => {
    await new Promise(r => setTimeout(r, 2_000));  // delay 2s
    await route.continue();
  });

  await page.goto('/dashboard');

  // Spinner phải visible ngay sau khi page load trigger API call
  await expect(page.getByTestId('spinner')).toBeVisible();

  // Spinner ẩn sau khi response về (timeout cover delay + margin)
  await expect(page.getByTestId('spinner')).toBeHidden({ timeout: 5_000 });
});

Lưu ý timing: Khoảng thời gian giữa hai assert là lúc test chờ spinner ẩn đi. Nếu delay là 2s, đặt timeout: 5_000 để có buffer đủ lớn. Default timeout 5s của Playwright vừa khớp — nhưng tốt hơn nên explicit để tránh phụ thuộc vào config toàn cục.

Trường hợp spinner dùng CSS class thay vì display:

// App dùng class "loading" thay vì show/hide element
await expect(page.getByTestId('dashboard')).toHaveClass(/loading/);
await expect(page.getByTestId('dashboard')).not.toHaveClass(/loading/, { timeout: 5_000 });
5

Test Skeleton UI

Skeleton UI thường là các placeholder element có class riêng (ví dụ skeleton, placeholder, shimmer). Pattern test tương tự spinner:

test('shows skeleton placeholders while loading', async ({ page }) => {
  await page.route('**/api/posts', async (route) => {
    await new Promise(r => setTimeout(r, 2_000));
    await route.continue();
  });

  await page.goto('/blog');

  // Skeleton visible trong khi API chờ
  const skeletons = page.getByTestId('post-skeleton');
  await expect(skeletons.first()).toBeVisible();

  // Sau khi load: skeleton biến mất, nội dung thật xuất hiện
  await expect(skeletons.first()).toBeHidden({ timeout: 5_000 });
  await expect(page.getByTestId('post-item').first()).toBeVisible({ timeout: 5_000 });
});

Bài test này verify cả hai chiều: skeleton có visible trong lúc chờ (không bị skip do API quá nhanh) và biến mất sau khi data về.

6

Test Timeout Handling

Khi API quá chậm (hoặc không bao giờ respond), app phải hiển thị error message. Test pattern: delay rất lớn + abort request:

test('shows timeout error when API is too slow', async ({ page }) => {
  await page.route('**/api/data', async (route) => {
    await new Promise(r => setTimeout(r, 30_000));  // 30s — quá timeout của app
    await route.abort();  // Abort thay vì continue
  });

  await page.goto('/');

  // App phải hiện error sau khi timeout
  await expect(page.getByText('Request timed out')).toBeVisible({ timeout: 35_000 });
});

Tại sao route.abort(): Nếu dùng continue() sau 30s thì server thật sẽ nhận request — không phải ý muốn. abort() mô phỏng network failure sau delay, giả lập scenario API timeout mà không hit real server.

Test timeout vs. app timeout: Test timeout (35_000 trong ví dụ) phải lớn hơn delay trong route handler. Nếu test timeout nhỏ hơn delay, test sẽ fail vì Playwright abort test trước khi app kịp hiện error.

Cấu hình test timeout cho test cụ thể này:

test('shows timeout error', async ({ page }) => {
  test.setTimeout(40_000);  // Override timeout chỉ cho test này

  await page.route('**/api/data', async (route) => {
    await new Promise(r => setTimeout(r, 30_000));
    await route.abort();
  });
  // ...
});
7

Test Race Condition

Race condition điển hình: user trigger request thứ nhất, sau đó trigger request thứ hai trước khi thứ nhất về. App phải hiển thị kết quả của request thứ hai, không phải thứ nhất.

test('last search result wins (no stale data)', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/search*', async (route) => {
    callCount++;
    const currentCall = callCount;

    if (currentCall === 1) {
      // Request đầu chậm hơn
      await new Promise(r => setTimeout(r, 2_000));
    } else {
      // Request thứ hai về nhanh
      await new Promise(r => setTimeout(r, 200));
    }
    await route.continue();
  });

  await page.goto('/search');

  // User gõ nhanh — trigger 2 request
  await page.getByRole('searchbox').fill('react');
  await page.getByRole('searchbox').fill('vue');

  // Kết quả phải là "vue", không phải "react" (dù "react" trigger trước)
  await expect(page.getByTestId('results-label')).toHaveText(/vue/i, { timeout: 5_000 });
  await expect(page.getByTestId('results-label')).not.toHaveText(/react/i);
});

Test này phát hiện bug "stale closure" hay thiếu cancel logic khi component bị unmount hoặc query thay đổi.

8

Test Optimistic UI

Optimistic UI update giao diện ngay lập tức trước khi nhận xác nhận từ server. Test pattern: delay server confirm, assert UI đã update trước khi response về.

test('optimistic update — like count increments before server confirms', async ({ page }) => {
  await page.route('**/api/like', async (route) => {
    await new Promise(r => setTimeout(r, 3_000));  // server confirm chậm
    await route.fulfill({ json: { success: true } });
  });

  await page.goto('/post/123');
  const initialCount = await page.getByTestId('like-count').textContent();

  await page.getByRole('button', { name: 'Like' }).click();

  // UI update ngay (optimistic) — trước khi 3s trôi qua
  await expect(page.getByTestId('like-count')).not.toHaveText(initialCount!);

  // Sau khi server confirm, count vẫn đúng (không rollback không cần thiết)
  await expect(page.getByTestId('like-count')).not.toHaveText(initialCount!, { timeout: 5_000 });
});

Nếu muốn test rollback khi server trả lỗi:

test('rollback on server error', async ({ page }) => {
  await page.route('**/api/like', async (route) => {
    await new Promise(r => setTimeout(r, 1_000));
    await route.fulfill({ status: 500, json: { error: 'Internal error' } });
  });

  await page.goto('/post/123');
  const initialCount = await page.getByTestId('like-count').textContent();

  await page.getByRole('button', { name: 'Like' }).click();

  // Optimistic: count tăng ngay
  await expect(page.getByTestId('like-count')).not.toHaveText(initialCount!);

  // Rollback sau khi server error
  await expect(page.getByTestId('like-count')).toHaveText(initialCount!, { timeout: 4_000 });
});
9

Delay + Modify Response Kết Hợp

Có thể vừa delay vừa sửa response trong cùng một handler. Pattern dùng route.fetch() để lấy response thật, rồi route.fulfill() với response đã sửa:

await page.route('**/api/products', async (route) => {
  // Delay trước
  await new Promise(r => setTimeout(r, 2_000));

  // Fetch response thật từ server
  const response = await route.fetch();
  const body = await response.json();

  // Sửa response: inject thêm field
  body.promo = 'SALE_50';

  await route.fulfill({
    response,          // Giữ headers, status từ response thật
    json: body,        // Override body với phiên bản đã sửa
  });
});

Thứ tự quan trọng: delay phải nằm trước route.fetch() — nếu delay sau fetch, request đã hit server và response đã về, delay không còn tác dụng mô phỏng slow API nữa.

10

Variable Delay

Thay vì hardcode một giá trị, dùng object map để switch nhanh giữa các scenario:

const delays = {
  fast:   100,      // Gần như instant
  normal: 1_000,    // Kết nối bình thường
  slow:   5_000,    // 3G / poor connection
  frozen: 30_000,   // API không respond
} as const;

type DelayProfile = keyof typeof delays;

async function withDelay(page: Page, pattern: string, profile: DelayProfile) {
  await page.route(pattern, async (route) => {
    await new Promise(r => setTimeout(r, delays[profile]));
    await route.continue();
  });
}

// Dùng trong test
test('slow network: dashboard shows skeleton', async ({ page }) => {
  await withDelay(page, '**/api/**', 'slow');
  await page.goto('/dashboard');
  await expect(page.getByTestId('skeleton')).toBeVisible();
});

Reusable helper như thế này giúp test file ngắn hơn và dễ đổi profile khi cần debug.

11

Test Debounce Với Slow API

Debounce logic thường được test cùng slow API để verify rằng chỉ một request duy nhất được gửi sau một chuỗi input nhanh:

test('debounce: only one request after rapid typing', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/search*', async (route) => {
    requestCount++;
    await new Promise(r => setTimeout(r, 500));  // API cần thời gian respond
    await route.continue();
  });

  await page.goto('/search');
  const input = page.getByRole('searchbox');

  // Gõ nhanh từng ký tự
  await input.pressSequentially('react', { delay: 50 });

  // Chờ debounce kết thúc và response về
  await page.waitForTimeout(1_500);

  // Debounce hoạt động đúng: chỉ 1 request dù gõ 5 ký tự
  expect(requestCount).toBe(1);
});

Nếu requestCount > 1, debounce bị thiếu hoặc sai timing.

12

Chaos Testing Với Random Delay

Chaos testing dùng random delay để phát hiện bug latency-sensitive không xuất hiện với delay cố định:

test('resilient to variable network latency', async ({ page }) => {
  await page.route('**/api/**', async (route) => {
    // Random delay 0–3000ms
    const randomDelay = Math.random() * 3_000;
    await new Promise(r => setTimeout(r, randomDelay));
    await route.continue();
  });

  await page.goto('/dashboard');

  // Dù latency ngẫu nhiên, dashboard phải load được
  await expect(page.getByTestId('dashboard-content')).toBeVisible({ timeout: 10_000 });
});

Khi nào dùng: Chaos test thường không chạy trong main CI pipeline (kết quả không deterministic). Nên tách thành test suite riêng, chạy định kỳ hoặc khi cần stress test.

Seed random để reproduce:

// Dùng seed cố định để reproduce khi debug
const seed = Number(process.env.CHAOS_SEED ?? Date.now());
const pseudoRandom = (seed * 9301 + 49297) % 233280 / 233280;  // LCG simple
const delay = pseudoRandom * 3_000;
console.log(`Chaos seed: ${seed}, delay: ${delay.toFixed(0)}ms`);
13

So Sánh Với CDP Throttling

Bài 121 sẽ cover CDP network throttling — đây là điểm khác biệt để chọn đúng approach:

Đặc điểm setTimeout delay (bài này) CDP throttling (bài 121)
Phạm vi áp dụng Per-route, chọn lọc Toàn bộ network của page
Loại mô phỏng Chỉ delay time Bandwidth + latency thật
Độ phức tạp Đơn giản, vài dòng Cần CDP session, phức tạp hơn
Dùng khi Test loading state của API cụ thể Test toàn bộ app trên mạng chậm
Ảnh hưởng static assets Không (chỉ route match) Có (JS, CSS, images cũng chậm)

Với trường hợp cần test loading state của một API endpoint cụ thể, setTimeout approach đơn giản hơn và đủ dùng.

14

Clock API Thay Thế Real Delay

Nhược điểm lớn nhất của setTimeout delay là test chạy chậm thật — delay 3s tức test mất ít nhất 3s. Khi có nhiều test delay, suite có thể mất vài phút.

Playwright v1.45 bổ sung page.clock API cho phép mock thời gian trong browser context. Thay vì dùng real setTimeout trong route handler, app code dùng setTimeout bên trong browser — Clock API có thể advance time ngay lập tức mà không cần đợi:

// Thay vì delay trong route handler (real wait):
// await new Promise(r => setTimeout(r, 5_000));

// Dùng Clock API để fast-forward time trong browser:
await page.clock.install();
await page.goto('/dashboard');

// App gọi setTimeout(showSpinner, 0) rồi fetch API
// Advance time 5 giây ngay lập tức
await page.clock.fastForward('5s');

// Test không tốn thời gian thật

Clock API phù hợp khi app dùng setTimeout / setInterval nội bộ (polling, auto-refresh, debounce timer). Nếu muốn test loading state phụ thuộc vào thời gian response thật từ server, setTimeout delay trong route handler vẫn cần thiết. Bài này không đi sâu Clock API — sẽ có bài riêng trong chương H.

15

Common Pitfalls

Pitfall 1: Delay lớn hơn test timeout

Nếu delay trong route handler lớn hơn test timeout mặc định (thường 30s), Playwright abort test trước khi route handler hoàn thành.

// BAD: delay 30s + test timeout mặc định 30s = test có thể fail
await page.route('**/api/data', async (route) => {
  await new Promise(r => setTimeout(r, 30_000));
  await route.abort();
});

// GOOD: tăng test timeout lên đủ lớn
test('timeout error handling', async ({ page }) => {
  test.setTimeout(40_000);  // Tăng trước khi set route
  await page.route('**/api/data', async (route) => {
    await new Promise(r => setTimeout(r, 30_000));
    await route.abort();
  });
  // ...
});

Pitfall 2: Quên gọi route.continue() / route.fulfill() sau delay

Nếu route handler return mà không gọi continue(), fulfill(), hay abort(), request sẽ hang mãi mãi cho đến khi test timeout.

// BAD: handler return mà không giải quyết route
await page.route('**/api/data', async (route) => {
  await new Promise(r => setTimeout(r, 2_000));
  // Quên gọi route.continue() hoặc route.fulfill()
});

// GOOD
await page.route('**/api/data', async (route) => {
  await new Promise(r => setTimeout(r, 2_000));
  await route.continue();  // Bắt buộc
});

Pitfall 3: Real delay làm suite chậm

Delay 2s × 20 test = 40s chỉ cho delay. Với suite lớn, tổng delay có thể chiếm phần lớn thời gian chạy test.

Cách giảm thiểu: dùng delay nhỏ nhất đủ để capture loading state (thường 500ms–1s là đủ cho spinner test), chỉ dùng delay lớn khi test timeout handling thật sự.

Pitfall 4: Assert spinner sau khi API đã load (timing issue)

Nếu page load trigger API call ngay lập tức và delay quá ngắn, spinner có thể đã biến mất trước khi test kịp assert.

// FLAKY: navigate rồi mới assert — có thể đã quá muộn
await page.goto('/dashboard');
await page.waitForTimeout(100);  // Bad: arbitrary wait
await expect(spinner).toBeVisible();  // Có thể đã hidden rồi

// BETTER: setup route trước goto, không có arbitrary wait
await page.route('**/api/data', async (route) => {
  await new Promise(r => setTimeout(r, 2_000));  // Đủ để assert
  await route.continue();
});
await page.goto('/dashboard');
// Assert ngay sau goto — spinner chắc chắn visible trong 2s window
await expect(spinner).toBeVisible();
16

Tổng Kết

  • page.route() handler là async — await new Promise(r => setTimeout(r, ms)) trong handler delay response mà không cần thêm dependency nào.
  • Luôn gọi route.continue(), route.fulfill(), hoặc route.abort() sau delay — thiếu sẽ làm request hang.
  • Đặt test.setTimeout() đủ lớn khi delay lớn (timeout testing).
  • Per-route delay phù hợp để test loading state của endpoint cụ thể; CDP throttling (bài 121) phù hợp hơn cho toàn bộ page.
  • Real delay làm suite chậm — xem xét Clock API cho app code dùng setTimeout nội bộ.
17

Bài Tập Củng Cố

Câu 1

Route handler sau có vấn đề gì? Request sẽ kết thúc ra sao?

await page.route('**/api/user', async (route) => {
  await new Promise(r => setTimeout(r, 3_000));
  console.log('delay done');
  // handler kết thúc ở đây
});
Đáp án

Handler kết thúc mà không gọi route.continue(), route.fulfill(), hay route.abort(). Playwright sẽ cảnh báo "route was not handled" và request sẽ hang (không được forward cũng không được abort). App chờ response mãi mãi cho đến khi test timeout. Sửa bằng cách thêm await route.continue(); hoặc await route.fulfill({...}); sau console.log.

Câu 2

Test sau chạy đúng không? Nếu sai, lý do và cách sửa?

test('timeout handling', async ({ page }) => {
  await page.route('**/api/data', async (route) => {
    await new Promise(r => setTimeout(r, 35_000));
    await route.abort();
  });
  await page.goto('/');
  await expect(page.getByText('Error: timeout')).toBeVisible({ timeout: 40_000 });
});
Đáp án

Test có khả năng fail nếu default test timeout của project nhỏ hơn tổng thời gian cần (35s delay + page load + assert time). Default test timeout của Playwright là 30s. Dù expect timeout: 40_000 được set, test timeout tổng thể vẫn có thể abort sớm hơn. Sửa bằng cách thêm test.setTimeout(50_000); ở đầu test.

Câu 3

Bạn muốn test rằng spinner visible trong lúc chờ API, nhưng spinner không bao giờ visible dù test pass. Nguyên nhân phổ biến nhất là gì?

Đáp án

API respond quá nhanh (vài millisecond) nên spinner mount và unmount trước khi test kịp assert. Cần setup page.route() với delay đủ lớn (ít nhất 500ms–1s) trước page.goto(). Nếu đã có delay mà vẫn không visible, kiểm tra lại selector (getByTestId) có đúng không — spinner có thể chưa render ngay mà render sau một tick.

Câu 4

Khác biệt giữa delay route trước route.fetch() và sau route.fetch() là gì khi kết hợp delay + modify response?

Đáp án

Delay trước route.fetch(): request chưa gửi đến server, app ở trạng thái chờ response — đây là trạng thái loading state cần test. Delay sau route.fetch(): server đã nhận request và đã trả response, response chỉ bị giữ lại trong test process. App vẫn thấy request đang pending từ phía browser, nhưng server đã xử lý xong. Với mục tiêu test loading state do slow API, delay phải đặt trước route.fetch().

Câu 5

Team có 50 test dùng slow API simulation với delay 2s mỗi test. Suite mất khoảng 100+ giây chỉ cho delay. Có cách nào giảm thời gian mà không phải bỏ các test này không?

Đáp án

Vài hướng: (1) Giảm delay xuống mức tối thiểu (500ms thường đủ để spinner visible và assert kịp), từ 2s xuống 500ms tiết kiệm 75s cho 50 test. (2) Dùng Clock API (page.clock) nếu app loading state phụ thuộc vào setTimeout nội bộ — fast-forward time mà không cần real wait. (3) Chạy test parallel (workers) để các test delay chạy song song thay vì tuần tự — wall time giảm, không phải test time. (4) Chỉ giữ delay lớn (2s+) ở test timeout handling, còn spinner/skeleton test dùng delay nhỏ.

18

Bài Tiếp Theo

Bài 121 chuyển sang CDP network throttling — simulate băng thông và latency thật cho toàn bộ page thay vì per-route.

Bài 121: Network Throttling Với CDP