Danh sách bài viết

Bài 20: Fixture Scope test (Default)

Mỗi custom fixture đều có scope — quyết định khi nào instance được tạo và khi nào bị huỷ. Scope test là mặc định: fixture được khởi tạo lại cho từng test và cleanup ngay sau khi test kết thúc. Bài này đào sâu cơ chế này, so sánh function form với tuple form, các use case điển hình, dependency rules giữa các scope, và những pitfall hay gặp.

27/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ẽ:

  • Giải thích được tại sao scope test là default và khi nào nó hoạt động đúng với function form.
  • Phân biệt function form và tuple form — biết khi nào cần tuple.
  • Mô tả chính xác thứ tự setup → test body → teardown của một test-scope fixture.
  • Nhận biết 4 use case chuẩn cho test-scope: counter, DB transaction, mock route, temp file.
  • Áp dụng đúng dependency rules: fixture nào được phép depend vào fixture nào.
  • Tránh được 4 pitfall điển hình với test-scope fixture.

Cú pháp base.extend() và cấu trúc fixture function đã được trình bày ở bài 19. Bài này không lặp lại phần đó — focus vào scope mechanics.

2

Scope Là Gì Trong Custom Fixture

Scope của fixture quyết định đơn vị sống của instance đó — tức là Playwright Test sẽ tạo instance mới khi nào và giữ nguyên instance cũ đến khi nào.

Playwright Test hỗ trợ hai scope cho custom fixture:

Scope Tạo instance khi Huỷ instance khi Số instance tối đa
test Trước mỗi test Sau mỗi test 1 per test (trong 1 worker)
worker Lần đầu fixture được dùng trong worker Khi worker kết thúc toàn bộ công việc 1 per worker process

Bài này tập trung vào scope test. Worker scope được trình bày ở bài 21.

3

Default Behavior — Function Form

Khi khai báo fixture bằng function form — tức là truyền trực tiếp async function — Playwright Test mặc định hiểu scope là test:

import { test as base } from '@playwright/test';

class Counter {
  value = 0;
  increment() { this.value++; }
  reset() { this.value = 0; }
}

export const test = base.extend<{ counter: Counter }>({
  // Function form — scope mặc định là 'test'
  counter: async ({}, use) => {
    const c = new Counter();
    await use(c);       // test body chạy ở đây
    c.reset();          // cleanup sau test
  },
});

Không có gì để khai báo thêm — function form đủ dùng cho mọi trường hợp scope test thông thường. Đây là cú pháp gọn nhất và phổ biến nhất.

4

Explicit Declaration — Tuple Form

Tuple form là cú pháp khai báo fixture dưới dạng mảng 2 phần tử: [async function, options object]. Nó cần thiết khi muốn đặt bất kỳ option nào ngoài scope mặc định — ví dụ auto, timeout, title, hoặc khai báo fixture là option.

export const test = base.extend<{ counter: Counter }>({
  counter: [
    async ({}, use) => {
      const c = new Counter();
      await use(c);
      c.reset();
    },
    { scope: 'test' },  // explicit — nhưng giống behavior khi dùng function form
  ],
});

Khai báo scope: 'test' trong tuple khi fixture đã default là test scope không thay đổi gì về runtime. Lý do dùng explicit trong trường hợp này:

  • Tăng tính tường minh cho codebase lớn, nhiều fixture với scope khác nhau.
  • Khi fixture cần thêm option khác (ví dụ timeout), tuple form là bắt buộc, và viết rõ scope cùng lúc là good practice.

Options object trong tuple form nhận các key:

Key Kiểu Mô tả ngắn
scope 'test' | 'worker' Scope của fixture
auto boolean Tự động khởi tạo dù test không destructure (bài 22)
option boolean Đánh dấu là configurable option (bài 23)
timeout number Timeout riêng cho setup + teardown của fixture này
title string Tên fixture hiển thị trong trace / reporter
box boolean Ẩn fixture internals khỏi trace viewer [v1.46+]

Bài này chỉ đề cập scope. Các key còn lại được trình bày ở bài 22 (auto) và bài 23 (option).

5

Vòng Đời Chi Tiết

Với test-scope fixture, lifecycle trong một test diễn ra như sau:

1. [Worker] worker-scope fixtures khởi tạo (nếu có) — chỉ 1 lần
   ─────────────────────────────────────────────────────────────
2. [Test N] test-scope fixture setup chạy → trước await use(c)
3. [Test N] await use(c) — yield: test body bắt đầu chạy
4. [Test N] test body hoàn thành (pass hoặc fail)
5. [Test N] use(c) return — code sau use(c) chạy → teardown
   ─────────────────────────────────────────────────────────────
6. [Test N+1] test-scope fixture setup chạy lại — instance mới
7. [Test N+1] await use(c) — yield: test body chạy
8. [Test N+1] teardown
   ─────────────────────────────────────────────────────────────
9. [Worker] worker-scope fixtures teardown — khi worker xong

Điểm quan trọng cần nhớ:

  • Teardown luôn chạy — ngay cả khi test fail. Code sau await use() được Playwright Test đảm bảo chạy.
  • Instance mới — mỗi test nhận object hoàn toàn mới từ setup, không kế thừa state từ test trước.
  • Thứ tự teardown ngược với setup — nếu fixture A depend vào fixture B, B setup trước, A teardown trước.
// Fixture với log để quan sát lifecycle
export const test = base.extend<{ counter: Counter }>({
  counter: async ({}, use) => {
    console.log('[fixture] setup counter');
    const c = new Counter();

    await use(c);           // test body chạy đây

    console.log('[fixture] teardown counter, final value:', c.value);
    c.reset();
  },
});

test('test A', async ({ counter }) => {
  console.log('[test A] counter.value =', counter.value); // 0
  counter.increment();
  console.log('[test A] after increment =', counter.value); // 1
});

test('test B', async ({ counter }) => {
  console.log('[test B] counter.value =', counter.value); // 0 — fresh
});

// Output thứ tự (trong 1 worker, sequential):
// [fixture] setup counter
// [test A] counter.value = 0
// [test A] after increment = 1
// [fixture] teardown counter, final value: 1
// [fixture] setup counter
// [test B] counter.value = 0
// [fixture] teardown counter, final value: 0
6

Isolation Giữa Các Test

Test-scope fixture đảm bảo state không lan sang test khác. Ví dụ minh hoạ rõ nhất:

test('test 1', async ({ counter }) => {
  expect(counter.value).toBe(0);  // fresh instance
  counter.increment();
  counter.increment();
  expect(counter.value).toBe(2);
});

test('test 2', async ({ counter }) => {
  // Nhận instance mới — không phải instance từ test 1
  expect(counter.value).toBe(0);  // fresh again, không phải 2
});

test('test 3', async ({ counter }) => {
  expect(counter.value).toBe(0);  // luôn 0, bất kể test nào chạy trước
});

Behaviour trên đúng bất kể thứ tự chạy test (parallel hay sequential). Mỗi worker nhận fixture instance riêng của mình cho mỗi test — không có shared mutable state giữa test trong cùng worker qua test-scope fixture.

7

Use Cases Điển Hình

1. Counter / State object per test

Use case đơn giản nhất: bất kỳ object nào cần trạng thái khởi tạo sạch cho từng test.

export const test = base.extend<{ counter: Counter }>({
  counter: async ({}, use) => {
    await use(new Counter());
    // Không cần cleanup vì Counter không giữ external resource
  },
});

2. Mock API server per test

Khi mỗi test cần mock server riêng với endpoint khác nhau:

import { createServer, Server } from 'http';

type MockServer = { url: string; server: Server };

export const test = base.extend<{ mockServer: MockServer }>({
  mockServer: async ({}, use) => {
    const server = createServer((req, res) => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ status: 'ok' }));
    });

    await new Promise<void>(resolve => server.listen(0, resolve));
    const port = (server.address() as any).port;

    await use({ url: `http://localhost:${port}`, server });

    await new Promise<void>(resolve => server.close(() => resolve()));
  },
});

3. Database transaction wrap

Pattern phổ biến trong integration test: mở transaction trước test, rollback sau — đảm bảo DB luôn sạch giữa các test mà không cần truncate bảng.

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export const test = base.extend<{ db: PoolClient }>({
  db: async ({}, use) => {
    const client = await pool.connect();
    await client.query('BEGIN');

    await use(client);          // test thực hiện queries trên client này

    await client.query('ROLLBACK');   // huỷ mọi thay đổi sau test
    client.release();
  },
});

Pattern DB transaction sẽ được đào sâu hơn ở chương về Database Testing. Ở đây chỉ nêu cấu trúc cơ bản để minh hoạ use case test-scope.

4. Temporary file per test

import { mkdtemp, rm } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';

export const test = base.extend<{ tmpDir: string }>({
  tmpDir: async ({}, use) => {
    const dir = await mkdtemp(join(tmpdir(), 'pw-test-'));

    await use(dir);   // test dùng thư mục tạm này

    await rm(dir, { recursive: true, force: true });  // dọn dẹp
  },
});

5. Mock route per test

Khi cần intercept request chỉ trong phạm vi 1 test (page fixture có scope test — có thể depend trực tiếp):

export const test = base.extend<{ mockApi: void }>({
  mockApi: async ({ page }, use) => {
    await page.route('/api/user', route => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ name: 'Test User', role: 'admin' }),
      });
    });

    await use();    // void fixture — không có value trả về

    await page.unroute('/api/user');
  },
});

page.unroute() trong teardown đảm bảo route không bị để lại — quan trọng khi fixture page được reuse (ví dụ trong test.beforeEach hay fixture chain).

8

testInfo Trong Fixture

Fixture function nhận tham số thứ ba: testInfo — object chứa metadata về test đang chạy. Chỉ có trong test-scope fixture (không có trong worker-scope).

export const test = base.extend<{ counter: Counter }>({
  counter: async ({}, use, testInfo) => {
    console.log('Setting up for:', testInfo.title);
    // testInfo.title = tên test đang chạy
    // testInfo.file  = đường dẫn file spec
    // testInfo.line  = dòng khai báo test

    const c = new Counter();
    await use(c);

    // Sau test — có thể đọc kết quả
    if (testInfo.status !== testInfo.expectedStatus) {
      console.log(`[counter] Test "${testInfo.title}" failed with counter=${c.value}`);
    }
  },
});

Các property hữu ích nhất của testInfo trong teardown:

  • testInfo.status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted' — trạng thái thực tế sau khi test chạy.
  • testInfo.expectedStatus: thường là 'passed', trừ khi test được annotate với test.fail().
  • testInfo.outputDir: thư mục output dành riêng cho test này — hữu ích để lưu file debug.
  • testInfo.attach(): đính kèm file hoặc buffer vào test report — có thể gọi trong teardown để attach debug artifact khi fail.
// Attach debug info khi test fail
export const test = base.extend<{ db: PoolClient }>({
  db: async ({}, use, testInfo) => {
    const client = await pool.connect();
    await client.query('BEGIN');

    await use(client);

    if (testInfo.status === 'failed') {
      // Lưu SQL query log để debug
      const log = await client.query('SELECT * FROM pg_stat_activity');
      await testInfo.attach('db-state-on-fail.json', {
        body: JSON.stringify(log.rows, null, 2),
        contentType: 'application/json',
      });
    }

    await client.query('ROLLBACK');
    client.release();
  },
});
9

Dependency Rules Giữa Scope

Fixture có thể depend vào fixture khác qua destructuring trong tham số đầu tiên. Nhưng scope tạo ra ràng buộc:

Fixture A scope Depend vào fixture B scope Hợp lệ? Lý do
test test OK Cùng vòng đời
test worker OK Worker tồn tại lâu hơn — dùng chung được
worker test ERROR Worker không thể depend thứ thay đổi mỗi test
worker worker OK Cùng vòng đời
// OK — test depend worker
export const test = base.extend<TestFixtures, WorkerFixtures>({
  // dbPool là worker-scope (kết nối pool mở 1 lần per worker)
  dbPool: [
    async ({}, use) => {
      const pool = new Pool({ connectionString: process.env.DATABASE_URL });
      await use(pool);
      await pool.end();
    },
    { scope: 'worker' },
  ],

  // db là test-scope — depend vào dbPool (worker-scope) → OK
  db: async ({ dbPool }, use) => {
    const client = await dbPool.connect();
    await client.query('BEGIN');
    await use(client);
    await client.query('ROLLBACK');
    client.release();
  },
});
// ERROR — worker depend test
export const test = base.extend<TestFixtures, WorkerFixtures>({
  counter: async ({}, use) => {   // test-scope
    await use(new Counter());
  },

  // Lỗi TypeScript + runtime error khi chạy
  workerCounter: [
    async ({ counter }, use) => {  // worker depend test-scope counter → INVALID
      await use(counter);
    },
    { scope: 'worker' },
  ],
});
// TypeError: "workerCounter" fixture with a 'worker' scope
//            cannot depend on "counter" fixture with a 'test' scope

Playwright Test phát hiện lỗi này tại runtime với thông báo rõ ràng — không phải silent fail.

10

Performance Và Khi Nào Không Dùng test-scope

Test-scope fixture được tạo lại cho mỗi test — đây là overhead có thể đo được nếu setup tốn thời gian.

Setup fixture: 200ms
Tests: 50 test
Tổng overhead: 50 × 200ms = 10 giây thuần fixture setup
(chưa tính teardown)

Mốc tham khảo thực tế:

Loại setup Thời gian điển hình Dùng test-scope?
Khởi tạo object đơn giản (Counter, DTO) < 1ms Tốt
Đọc file nhỏ, parse JSON 1–5ms Tốt
HTTP request (mock server local) 5–20ms Chấp nhận được
DB connection mới (không pool) 50–200ms Nên dùng pool + worker-scope cho pool
Khởi động process ngoài (browser, server) 500ms – 5s Cần worker-scope hoặc beforeAll

Nguyên tắc: nếu setup fixture mất hơn 50ms và test suite có nhiều hơn 20 test dùng fixture đó, cân nhắc tách phần heavy setup sang worker-scope, chỉ giữ phần per-test (transaction, route) ở test-scope.

11

Common Pitfalls

1. Setup nặng trong test-scope — suite chậm ngầm

// SAI — mở DB connection mới mỗi test (không pool)
db: async ({}, use) => {
  const client = new Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();  // ~100–200ms per test
  await use(client);
  await client.end();
},

// TỐT HƠN — pool ở worker-scope, chỉ checkout client ở test-scope
dbPool: [async ({}, use) => {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  await use(pool);
  await pool.end();
}, { scope: 'worker' }],

db: async ({ dbPool }, use) => {
  const client = await dbPool.connect();  // ~1–5ms (pool reuse)
  await use(client);
  client.release();
},

2. Cleanup throw error — che khuất lỗi gốc từ test

// SAI — nếu server.close() throw, lỗi gốc từ test bị mất
mockServer: async ({}, use) => {
  const server = startServer();
  await use(server);
  server.close();  // nếu throw ở đây → override error gốc
},

// ĐÚNG — wrap teardown để preserve original error
mockServer: async ({}, use) => {
  const server = startServer();
  await use(server);
  try {
    await new Promise<void>(resolve => server.close(() => resolve()));
  } catch (e) {
    console.error('[fixture] server close error (suppressed):', e);
  }
},

3. Sai giá trị scope trong tuple — capitalize hoặc typo

// SAI — 'Test' (viết hoa) không phải scope hợp lệ
counter: [
  async ({}, use) => { await use(new Counter()); },
  { scope: 'Test' },  // TypeError at runtime — Playwright không chấp nhận
],

// SAI — 'tests' (số nhiều) không hợp lệ
counter: [
  async ({}, use) => { await use(new Counter()); },
  { scope: 'tests' },  // tương tự
],

// ĐÚNG — lowercase chính xác
counter: [
  async ({}, use) => { await use(new Counter()); },
  { scope: 'test' },
],

TypeScript sẽ bắt lỗi này nếu type được khai báo đúng — scope có kiểu 'test' | 'worker'. Khi không có TypeScript, lỗi chỉ xuất hiện tại runtime.

4. Worker-scope fixture depend test-scope — TypeScript + runtime error

// SAI — worker không thể depend test-scope
sharedPool: [
  async ({ db }, use) => {  // db là test-scope
    await use(db);
  },
  { scope: 'worker' },
],
// Lỗi:
// "sharedPool" fixture with a 'worker' scope cannot depend on
// "db" fixture with a 'test' scope

Gặp lỗi này: kiểm tra type parameter thứ hai của base.extend<TestFixtures, WorkerFixtures> — fixture được đặt nhầm nhóm sẽ không enforce đúng scope.

12

Tổng Kết

  • Scope test là mặc định — function form không cần khai báo gì thêm.
  • Tuple form [async fn, options] cần khi muốn thêm auto, timeout, title, box, hoặc option.
  • Mỗi test nhận instance fixture mới — state không lan từ test này sang test khác.
  • Teardown (code sau await use()) luôn chạy kể cả khi test fail.
  • Test-scope fixture có thể depend vào worker-scope fixture, nhưng không được chiều ngược lại.
  • Tham số testInfo chỉ có trong test-scope fixture — dùng để log, attach artifact dựa trên kết quả test.
  • Setup tốn hơn 50ms × nhiều test: xem xét tách heavy part sang worker-scope.
  • Cleanup phải xử lý exception riêng — để tránh che khuất lỗi gốc từ test.
13

Bài Tập Củng Cố

Câu 1

Cho đoạn code sau:

export const test = base.extend<{ list: string[] }>({
  list: async ({}, use) => {
    const items: string[] = [];
    await use(items);
    // không có cleanup
  },
});

test('A', async ({ list }) => {
  list.push('hello');
  expect(list).toHaveLength(1);
});

test('B', async ({ list }) => {
  expect(list).toHaveLength(0);
});

Test B có pass không? Tại sao?

Đáp án

Pass. Mỗi test nhận instance items mới từ const items: string[] = [] — test A push vào instance của test A, test B nhận array rỗng độc lập. Test-scope đảm bảo không có shared state giữa hai test.

Câu 2

Viết fixture tmpFile tạo file tạm thời với nội dung 'initial', sau test xoá file đó. Nếu test fail, attach path file vào report thay vì xoá.

Đáp án
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';

export const test = base.extend<{ tmpFile: string }>({
  tmpFile: async ({}, use, testInfo) => {
    const filePath = join(tmpdir(), `pw-${Date.now()}.txt`);
    await writeFile(filePath, 'initial', 'utf-8');

    await use(filePath);

    if (testInfo.status === 'failed') {
      await testInfo.attach('tmp-file-on-fail', {
        path: filePath,
        contentType: 'text/plain',
      });
    } else {
      await unlink(filePath).catch(() => {});
    }
  },
});

Câu 3

Fixture A có scope worker, Fixture B có scope test. Fixture A muốn dùng Fixture B — điều này có được không? Lỗi gì xảy ra?

Đáp án

Không được. Worker-scope fixture không thể depend vào test-scope fixture vì vòng đời mâu thuẫn: worker tồn tại suốt nhiều test, trong khi fixture B thay đổi mỗi test. Playwright Test sẽ throw runtime error: "A" fixture with a 'worker' scope cannot depend on "B" fixture with a 'test' scope. TypeScript cũng báo lỗi nếu type parameter của base.extend được khai báo đúng.

Câu 4

Đoạn code teardown sau có vấn đề gì?

server: async ({}, use) => {
  const srv = await startHeavyServer();
  await use(srv);
  await srv.shutdown();  // có thể throw nếu server đã crash
},
Đáp án

Nếu srv.shutdown() throw — ví dụ server đã crash trong quá trình test — exception này sẽ override lỗi gốc từ test, làm mất thông tin debug quan trọng. Teardown nên wrap bằng try/catch và log lỗi riêng thay vì để nó propagate:

server: async ({}, use) => {
  const srv = await startHeavyServer();
  await use(srv);
  try {
    await srv.shutdown();
  } catch (e) {
    console.error('[fixture] server shutdown error:', e);
  }
},

Câu 5

Khi nào nên dùng tuple form thay vì function form cho test-scope fixture?

Đáp án

Dùng tuple form khi cần ít nhất một trong các option: auto: true (tự khởi tạo dù test không destructure), timeout (đặt timeout riêng cho fixture), title (tên hiển thị trong trace/reporter), box: true (ẩn internals khỏi trace viewer), hoặc option: true (đánh dấu là configurable option). Nếu chỉ cần test-scope đơn giản, function form gọn hơn và đủ dùng.

14

Bài Tiếp Theo

Bài 21 đào sâu scope còn lại: worker — fixture tạo 1 lần per worker process, dùng chung cho mọi test trong worker đó. Đây là scope phù hợp cho heavy resources như database pool, browser instance, hoặc server nặng.

Bài 21: Fixture Scope worker