Danh sách bài viết

Bài 53: project.use — Override Options Per Project

Mỗi project trong playwright.config.ts có thể khai báo block use riêng để override fixture options so với top-level use. Bài này đi vào cơ chế merge precedence đầy đủ (5 tầng), hành vi shallow merge, các pattern thực tế như multi-role auth, per-env baseURL, per-project locale và trace mode nặng hơn cho nightly, spread device và order của spread, cùng 4 pitfall điển hình.

28/05/2026
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 project.use override top-level use như thế nào và ở đâu trong chuỗi precedence.
  • Nắm hành vi shallow merge — tại sao nested object bị replace thay vì merge.
  • Áp dụng pattern multi-role auth, per-env baseURL, per-project locale và per-project trace mode.
  • Tránh 4 pitfall thực tế khi dùng spread device và override fixture options.
2

project.use Là Gì

Mỗi entry trong mảng projects của defineConfig có thể chứa một block use riêng. Block này nhận cùng kiểu dữ liệu (PlaywrightTestOptions & LaunchOptions) như top-level use, và Playwright merge hai block lại theo cơ chế shallow merge — project-level key thắng top-level key.

Điều này cho phép một config file duy nhất phục vụ nhiều project với browser, auth state, locale, headers, trace level khác nhau mà không cần file config riêng cho từng project.

Top-level use đóng vai trò là "default" cho toàn bộ suite. Project use là "exception" chỉ áp dụng cho project đó. Test code không cần biết mình đang chạy trong project nào để thay đổi fixture — Playwright tự inject đúng options.

3

Cú Pháp Cơ Bản

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: {
    // Top-level: áp dụng cho mọi project
    baseURL: 'https://staging.app.com',
    headless: true,
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // viewport, userAgent, isMobile, hasTouch inherited từ device
        // baseURL, headless, trace lấy từ top-level
      },
    },
    {
      name: 'mobile-prod',
      use: {
        ...devices['iPhone 15 Pro'],
        baseURL: 'https://m.app.com',  // override top-level baseURL
        locale: 'vi-VN',              // thêm option không có ở top-level
      },
    },
  ],
});

Project chromium không khai báo baseURL nên dùng giá trị top-level 'https://staging.app.com'. Project mobile-prod khai báo baseURL riêng nên giá trị top-level bị bỏ qua hoàn toàn cho project đó.

4

Merge Precedence — 5 Tầng

Playwright resolve fixture options theo thứ tự ưu tiên từ thấp đến cao:

  1. Built-in defaults — giá trị mặc định của Playwright (ví dụ: headless: true, actionTimeout: 0).
  2. Top-level usedefineConfig({ use: { ... } }), áp dụng cho mọi project.
  3. project.useprojects[i].use, chỉ áp dụng cho project đó, override top-level.
  4. test.use() — khai báo ở đầu file hoặc trong describe block, override project config cho scope đó.
  5. Per-call options — ví dụ page.click('#btn', { timeout: 5000 }), chỉ áp dụng cho lời gọi đó.

Ví dụ minh họa toàn bộ 5 tầng:

// playwright.config.ts
export default defineConfig({
  use: {
    actionTimeout: 30_000,  // tầng 2: top-level default
  },
  projects: [
    {
      name: 'slow-env',
      use: {
        actionTimeout: 60_000,  // tầng 3: project override, thắng top-level
      },
    },
  ],
});
// tests/heavy.spec.ts
import { test } from '@playwright/test';

test.use({ actionTimeout: 90_000 });  // tầng 4: file-level, thắng project

test('slow operation', async ({ page }) => {
  // tầng 5: per-call, chỉ cho lệnh này
  await page.click('#submit', { timeout: 120_000 });
});

Trong ví dụ trên, khi test chạy trong project slow-env:

  • actionTimeout effective cho hầu hết actions trong file là 90_000 (tầng 4 thắng tầng 3).
  • page.click('#submit', { timeout: 120_000 }) dùng 120_000 (tầng 5 thắng tầng 4).
5

Hành Vi Shallow Merge

Playwright merge top-level useproject.use ở mức key của object cấp đầu. Nếu cùng key tồn tại ở cả hai, project value thắng và replace hoàn toàn — không merge đệ quy vào bên trong.

Hành vi này ảnh hưởng đến các option là nested object: proxy, httpCredentials, viewport, geolocation.

export default defineConfig({
  use: {
    proxy: {
      server: 'http://proxy.internal:8080',
      bypass: 'localhost,127.0.0.1',
    },
  },
  projects: [
    {
      name: 'prod',
      use: {
        // Muốn chỉ override username/password nhưng giữ server và bypass
        // Cách SAI — proxy object bị REPLACE toàn bộ, mất server và bypass
        proxy: {
          username: 'user',
          password: 'secret',
        },
      },
    },
  ],
});

Kết quả của project prod: proxy.serverproxy.bypass bị mất vì project proxy object replace hoàn toàn top-level proxy. Nếu cần giữ cả top-level lẫn override một số field, phải spread:

import { defineConfig } from '@playwright/test';

const baseProxy = {
  server: 'http://proxy.internal:8080',
  bypass: 'localhost,127.0.0.1',
};

export default defineConfig({
  use: {
    proxy: baseProxy,
  },
  projects: [
    {
      name: 'prod',
      use: {
        proxy: {
          ...baseProxy,             // giữ nguyên server + bypass
          username: 'user',
          password: 'secret',
        },
      },
    },
  ],
});

Primitive values (string, number, boolean) không bị ảnh hưởng bởi shallow merge — chúng luôn được replace trực tiếp, đó là hành vi mong muốn.

6

Spread Device Và Order

devices['iPhone 15 Pro'] là một object chứa nhiều fixture options: viewport, userAgent, deviceScaleFactor, isMobile, hasTouch. Khi spread vào project.use, thứ tự của spread quyết định option nào thắng:

// ❌ SAI — viewport custom bị ghi đè bởi iPhone 15 Pro viewport
use: {
  viewport: { width: 400, height: 800 },  // khai báo trước
  ...devices['iPhone 15 Pro'],            // spread sau — override viewport ở trên
}
// ✅ ĐÚNG — spread device trước, override sau
use: {
  ...devices['iPhone 15 Pro'],            // spread trước: base device
  viewport: { width: 400, height: 800 }, // override sau: thắng device viewport
  // userAgent, deviceScaleFactor, isMobile, hasTouch giữ từ device
}

Quy tắc nhớ: device là "base", custom option là "exception" — base trước, exception sau. Đây là JavaScript spread thông thường, không có logic đặc biệt nào của Playwright.

Ví dụ giữ DPR và userAgent của device nhưng dùng viewport tùy chỉnh:

projects: [
  {
    name: 'iphone-landscape',
    use: {
      ...devices['iPhone 15 Pro'],         // isMobile: true, touch: true, DPR: 3
      viewport: { width: 844, height: 390 }, // landscape mode
    },
  },
],
7

Pattern Multi-role Auth

Dùng project.use.storageState để inject auth state khác nhau cho từng project. Playwright load file JSON chứa cookies và localStorage trước mỗi test — không cần login lại trong từng test.

projects: [
  {
    name: 'admin-tests',
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'auth/admin.json',
    },
    testMatch: /admin\..*\.spec\.ts/,
  },
  {
    name: 'user-tests',
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'auth/user.json',
    },
    testMatch: /user\..*\.spec\.ts/,
  },
  {
    name: 'guest-tests',
    use: {
      ...devices['Desktop Chrome'],
      // Không có storageState — chạy như anonymous user
    },
    testMatch: /guest\..*\.spec\.ts/,
  },
],

File auth/admin.jsonauth/user.json thường được tạo bởi global setup script — một lần trước toàn bộ suite — và lưu session sau khi login thành công:

// global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();

  // Tạo admin session
  const adminContext = await browser.newContext();
  const adminPage = await adminContext.newPage();
  await adminPage.goto('https://staging.app.com/login');
  await adminPage.fill('#email', process.env.ADMIN_EMAIL!);
  await adminPage.fill('#password', process.env.ADMIN_PASSWORD!);
  await adminPage.click('[type=submit]');
  await adminContext.storageState({ path: 'auth/admin.json' });
  await adminContext.close();

  // Tạo user session tương tự
  const userContext = await browser.newContext();
  // ... tương tự
  await userContext.storageState({ path: 'auth/user.json' });
  await userContext.close();

  await browser.close();
}

Pattern này giữ code test tập trung vào test logic, không lặp login ở mỗi test.

8

Pattern Environment Per Project

Dùng project.use.baseURL để trỏ từng project vào environment khác nhau — dev, staging, prod — mà không cần nhiều config file:

projects: [
  {
    name: 'dev',
    use: {
      baseURL: 'http://localhost:3000',
      ignoreHTTPSErrors: true,  // self-signed cert trong local
    },
  },
  {
    name: 'staging',
    use: {
      baseURL: 'https://staging.app.com',
      extraHTTPHeaders: {
        'X-Bypass-Auth': process.env.STAGING_TOKEN!,
      },
    },
  },
  {
    name: 'prod',
    use: {
      baseURL: 'https://app.com',
      // Không có bypass header — dùng real auth flow
    },
  },
],

Chạy theo environment bằng cách chọn project:

# Chỉ chạy project dev
npx playwright test --project=dev

# Chỉ chạy project staging
npx playwright test --project=staging

# Chạy tất cả environments (mặc định)
npx playwright test

Lưu ý: process.env.STAGING_TOKEN được đọc lúc Playwright load config — nghĩa là biến môi trường phải có sẵn khi chạy lệnh, không phải lúc test được execute. Điều này đúng cho mọi giá trị trong config file.

9

Per-project Locale Và Timezone

Dùng project.use.localeproject.use.timezoneId để kiểm tra ứng dụng với người dùng ở vùng địa lý khác nhau:

projects: [
  {
    name: 'vietnam',
    use: {
      ...devices['Desktop Chrome'],
      locale: 'vi-VN',
      timezoneId: 'Asia/Ho_Chi_Minh',
    },
  },
  {
    name: 'japan',
    use: {
      ...devices['Desktop Chrome'],
      locale: 'ja-JP',
      timezoneId: 'Asia/Tokyo',
    },
  },
  {
    name: 'us-east',
    use: {
      ...devices['Desktop Chrome'],
      locale: 'en-US',
      timezoneId: 'America/New_York',
    },
  },
],

Playwright inject locale và timezoneId vào browser context — ảnh hưởng đến Intl.DateTimeFormat, Date.toLocaleString(), navigator.language. Hữu ích khi test:

  • Hiển thị ngày/giờ theo vùng (DD/MM/YYYY vs MM/DD/YYYY).
  • Format tiền tệ và số (1.234,56 vs 1,234.56).
  • Content ẩn/hiện theo ngôn ngữ qua JS navigator.language.
  • Scheduled content trigger theo timezone.
10

Per-project Trace Mode

Trace tốn I/O và storage. Dùng project.use.trace để áp dụng trace nặng hơn chỉ cho một số project — ví dụ project nightly hoặc project flaky:

export default defineConfig({
  use: {
    trace: 'on-first-retry',  // default nhẹ cho mọi project
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      // trace giữ nguyên 'on-first-retry' từ top-level
    },
    {
      name: 'nightly-full-trace',
      use: {
        ...devices['Desktop Chrome'],
        trace: 'on',        // ghi trace cho mọi test, kể cả pass
        video: 'on',        // video cũng bật cho nightly
        screenshot: 'on',
      },
      testMatch: /nightly\..*\.spec\.ts/,
    },
    {
      name: 'webkit',
      use: {
        ...devices['Desktop Safari'],
        trace: 'retain-on-failure',  // giữ trace khi fail, bỏ khi pass
      },
    },
  ],
});

Các giá trị hợp lệ cho trace:

  • 'off' — không ghi trace.
  • 'on' — ghi trace cho mọi test.
  • 'retain-on-failure' — ghi trace, xoá khi test pass.
  • 'on-first-retry' — chỉ ghi khi retry lần đầu.
  • 'on-all-retries' — ghi trace cho mọi lần retry.
11

Per-project actionTimeout

Một số project chạy trên môi trường chậm hơn — ví dụ project staging ở region xa, hoặc project mobile trên emulator. Tăng actionTimeout per project thay vì tăng toàn suite:

export default defineConfig({
  use: {
    actionTimeout: 15_000,  // default 15 giây
  },
  projects: [
    {
      name: 'fast-desktop',
      use: { ...devices['Desktop Chrome'] },
      // giữ actionTimeout 15s từ top-level
    },
    {
      name: 'slow-network',
      use: {
        ...devices['Desktop Chrome'],
        actionTimeout: 45_000,  // slow network environment
        navigationTimeout: 60_000,
      },
    },
    {
      name: 'mobile-emulator',
      use: {
        ...devices['Pixel 7'],
        actionTimeout: 30_000,  // emulator thường chậm hơn real device
      },
    },
  ],
});

actionTimeout áp dụng cho tất cả actions như click, fill, waitForSelector. navigationTimeout áp dụng riêng cho goto, waitForNavigation, waitForLoadState.

12

project.use vs test.use()

Tiêu chí project.use test.use()
Vị trí khai báo playwright.config.ts File test hoặc describe block
Scope áp dụng Toàn bộ tests trong project File hoặc describe block hiện tại
Precedence Tầng 3 — thắng top-level use Tầng 4 — thắng project.use
Khi nào dùng Khi option cần nhất quán cho cả project (auth, baseURL, locale) Khi một số test trong project cần exception
Cần sửa config file không Không — chỉ sửa test file

Ví dụ test.use() override project.use:

// playwright.config.ts
projects: [
  {
    name: 'vi-tests',
    use: { locale: 'vi-VN', timezoneId: 'Asia/Ho_Chi_Minh' },
  },
],

// tests/date-edge.spec.ts
// File này cần test date trong timezone UTC để kiểm tra server-side logic
test.use({ timezoneId: 'UTC' });  // override project timezoneId cho file này

test('server timestamp comparison', async ({ page }) => {
  // chạy với locale=vi-VN (từ project) nhưng timezoneId=UTC (từ test.use)
});
13

Pitfall Thường Gặp

Pitfall 1 — Spread device sau override, device thắng

// ❌ SAI — device spread sau, viewport custom bị ghi đè
use: {
  viewport: { width: 1920, height: 1080 },
  ...devices['iPhone 15'],
  // devices['iPhone 15'] chứa viewport: { width: 390, height: 844 }
  // → viewport cuối cùng là 390x844, không phải 1920x1080
}

// ✅ ĐÚNG — device spread trước, custom override sau
use: {
  ...devices['iPhone 15'],
  viewport: { width: 1920, height: 1080 },
  // viewport cuối cùng là 1920x1080, userAgent và isMobile giữ từ device
}

Pitfall 2 — Top-level và project conflict khó debug

Khi project có một option và top-level cũng có option cùng tên, giá trị cuối cùng dùng bởi test không rõ ràng nếu không nhớ precedence. Nên comment rõ trong config:

// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: 'https://staging.app.com', // Default cho tất cả project
    trace: 'on-first-retry',            // Default trace — project có thể override
  },
  projects: [
    {
      name: 'prod-smoke',
      use: {
        baseURL: 'https://app.com', // Override staging URL cho prod smoke
        // trace: giữ nguyên 'on-first-retry' từ top-level (không khai báo lại)
      },
    },
  ],
});

Pitfall 3 — Nested object (proxy, httpCredentials) bị replace toàn bộ

Như đã trình bày ở mục 5: nếu top-level khai báo proxy với nhiều field, và project chỉ override 1 field mà không spread top-level object vào — toàn bộ top-level proxy bị mất cho project đó. Luôn spread nested object base trước khi override field.

// ❌ SAI — mất proxy.server và proxy.bypass
projects: [
  {
    name: 'prod',
    use: {
      proxy: { username: 'user', password: 'pass' },
    },
  },
],

// ✅ ĐÚNG — giữ nguyên server và bypass
const baseProxy = { server: 'http://proxy:8080', bypass: 'localhost' };

projects: [
  {
    name: 'prod',
    use: {
      proxy: { ...baseProxy, username: 'user', password: 'pass' },
    },
  },
],

Pitfall 4 — Không set extraHTTPHeaders khi override httpCredentials, header stale

Khi top-level có cả httpCredentialsextraHTTPHeaders, và project chỉ override httpCredentials: extraHTTPHeaders vẫn tồn tại (vì khác key, không bị override). Tuy nhiên nếu project khai báo extraHTTPHeaders riêng mà quên giữ header cần thiết từ top-level, header sẽ mất:

export default defineConfig({
  use: {
    extraHTTPHeaders: {
      'X-Request-Id': 'test-suite',  // tracking header
      'Accept-Language': 'en',
    },
  },
  projects: [
    {
      name: 'staging',
      use: {
        extraHTTPHeaders: {
          'X-Bypass-Auth': process.env.STAGING_TOKEN!,
          // Quên giữ 'X-Request-Id' và 'Accept-Language' từ top-level
          // → top-level headers bị replace hoàn toàn
        },
      },
    },
  ],
});
// ✅ ĐÚNG — spread top-level headers trước
const baseHeaders = {
  'X-Request-Id': 'test-suite',
  'Accept-Language': 'en',
};

export default defineConfig({
  use: {
    extraHTTPHeaders: baseHeaders,
  },
  projects: [
    {
      name: 'staging',
      use: {
        extraHTTPHeaders: {
          ...baseHeaders,
          'X-Bypass-Auth': process.env.STAGING_TOKEN!,
        },
      },
    },
  ],
});
14

Quiz

Câu 1. Config sau, khi chạy test trong project mobile, giá trị baseURLlocale là gì?

export default defineConfig({
  use: {
    baseURL: 'https://staging.app.com',
    locale: 'en-US',
  },
  projects: [
    {
      name: 'mobile',
      use: {
        ...devices['iPhone 15 Pro'],
        locale: 'vi-VN',
      },
    },
  ],
});
Đáp án

baseURL = 'https://staging.app.com' (lấy từ top-level, project không khai báo). locale = 'vi-VN' (project override top-level 'en-US').

Câu 2. Tại sao đoạn code sau không cho kết quả như mong muốn?

use: {
  viewport: { width: 375, height: 812 },
  ...devices['iPhone 14'],
}
Đáp án

devices['iPhone 14'] spread sau viewport — object spread trong JavaScript ghi đè key trùng theo thứ tự cuối cùng. devices['iPhone 14'] chứa viewport của iPhone 14, nên viewport custom 375x812 bị mất. Phải đặt spread trước: { ...devices['iPhone 14'], viewport: { width: 375, height: 812 } }.

Câu 3. Top-level useproxy: { server: 'http://proxy:8080', bypass: 'localhost' }. Project override proxy: { username: 'user', password: 'pass' }. Giá trị proxy.server trong project là gì?

Đáp án

proxy.serverundefined. Shallow merge của Playwright replace toàn bộ proxy object — project proxy không chứa server nên field đó bị mất. Phải dùng spread để giữ lại: proxy: { ...baseProxy, username: 'user', password: 'pass' }.

Câu 4. Test file dùng test.use({ actionTimeout: 60_000 }) trong project có project.use.actionTimeout: 30_000. Timeout effective cho test trong file là bao nhiêu?

Đáp án

60_000ms. test.use() ở tầng 4 — cao hơn project.use ở tầng 3 trong merge precedence. File-level override thắng project-level.

Câu 5. Bạn cần project staging dùng header X-Bypass-Auth nhưng vẫn giữ header X-Request-Id từ top-level. Cách nào đúng?

Đáp án

Phải spread top-level extraHTTPHeaders vào project. Cách đơn giản nhất: extract top-level headers thành biến, rồi spread trong cả top-level lẫn project. Không thể chỉ khai báo X-Bypass-Auth trong project vì shallow merge sẽ replace toàn bộ extraHTTPHeaders object, làm mất X-Request-Id.