Học Playwright tiếng Việt, Cộng đồng Playwright cho người Việt

Khóa học

[Vọc Playwright] Authentication

https://playwright.dev/docs/auth

Giới thiệu

  • Playwright chạy các test ở các context riêng biệt, không dùng chung cookie, localStorage. Việc này giúp các test độc lập với nhau, không bị phụ thuộc.
  • Mô hình này giúp Playwright chạy test trong các điều kiện giống nhau, dễ tái hiện khi có bug.
  • Tuy nhiên nếu bạn muốn dùng lại thì cũng có cách luôn.

Core concepts

  • Dù là xác thực kiểu gì đi nữa, thì bạn cũng sẽ lưu dữ liệu xác thực ở cookie hoặc localStorage.
  • Bạn nên tạo thư mục playwright/.auth để quản lý authentication state của trình duyệt và thêm thư mục đó và .gitignore để đẩy lên git.
  • Khi chạy test đầu tiên, authentication state của trình duyệt sẽ được lưu vào playwright/.auth, giúp tiết kiệm thời gian xác thực cho mỗi lần kiểm thử.
  • Đây là cách tạo folder và thêm vào gitignore dùng command line:
mkdir -p playwright/.auth
echo "\nplaywright/.auth" >> .gitignore

Cách đơn giản: share account dùng chung cho tất cả các test

  • Nếu các test của bạn là độc lập với nhau, bạn có thể chạy setup project lần đầu, sau đó lưu lại authentication state để dùng lại trong các test sau.
  • Bạn hiểu đơn giản là test của bạn không làm thay đổi dữ liệu làm ảnh hưởng đến test khác thì dùng cách này được.
  • Một số trường hợp không nên dùng:
    • Ví dụ một test đổi theme, một test kiểm tra UI ~> conflict lẫn nhau.
    • Test trên các trình duyệt khác nhau.
  • Cách code như sau:
    • Tạo file tests/auth.setup.ts:
import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto('https://github.com/login');
  await page.getByLabel('Username or email address').fill('username');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL('https://github.com/');
  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

  // End of authentication steps.

  await page.context().storageState({ path: authFile });
});
  • Tại phần projects file playwright.config.ts, tạo project setup và khai báo nó là dependency của tất cả các projects khác. Project setup sẽ luôn được chạy và thực hiện việc xác thực trước các test:
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    // Setup project
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Use prepared auth state.
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        // Use prepared auth state.
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
  • Làm như trên thì các test đã được login sẵn rồi, bạn không cần login lại nữa.
import { test } from '@playwright/test';

test('test', async ({ page }) => {
  // page is authenticated
});

Xác thực với UI mode

  • Mặc định thì UI mode sẽ không chạy setup project.
  • Vì vậy nếu có thì bạn cần lưu ý chạy tay trước.

Cách khó hơn chút: mỗi tài khoản trên một worker

  • Cách này giải quyết vấn đề của cách đơn giản bằng cách:
    • Cho mấy test liên quan đến nhau chạy cùng 1 worker
    • Mỗi worker config dùng một account.
  • Để implement điều này, bạn làm như sau:
    • Tạo file playwright/fixtures.ts sẽ ghi đè storageState fixture để thực hiện authenticate cho mỗi worker.
    • Sử dụng testInfo.parallelIndex cho các worker khác nhau.
import { test as baseTest, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';

export * from '@playwright/test';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
  // Use the same storage state for all tests in this worker.
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  // Authenticate once per worker with a worker-scoped fixture.
  workerStorageState: [async ({ browser }, use) => {
    // Use parallelIndex as a unique identifier for each worker.
    const id = test.info().parallelIndex;
    const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

    if (fs.existsSync(fileName)) {
      // Reuse existing authentication state if any.
      await use(fileName);
      return;
    }

    // Important: make sure we authenticate in a clean environment by unsetting storage state.
    const page = await browser.newPage({ storageState: undefined });

    // Acquire a unique account, for example create a new one.
    // Alternatively, you can have a list of precreated accounts for testing.
    // Make sure that accounts are unique, so that multiple team members
    // can run tests at the same time without interference.
    const account = await acquireAccount(id);

    // Perform authentication steps. Replace these actions with your own.
    await page.goto('https://github.com/login');
    await page.getByLabel('Username or email address').fill(account.username);
    await page.getByLabel('Password').fill(account.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    // Wait until the page receives the cookies.
    //
    // Sometimes login flow sets cookies in the process of several redirects.
    // Wait for the final URL to ensure that the cookies are actually set.
    await page.waitForURL('https://github.com/');
    // Alternatively, you can wait until the page reaches a state where all cookies are set.
    await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

    // End of authentication steps.

    await page.context().storageState({ path: fileName });
    await page.close();
    await use(fileName);
  }, { scope: 'worker' }],
});
  • Từ giờ, chúng ta có thể sử dụng fixture test thay cho @playwright/test để sử dụng authentication state có sẵn:
// Important: import our fixtures.
import { test, expect } from '../playwright/fixtures';

test('test', async ({ page }) => {
  // page is authenticated
});

Một vài cách nâng cao hơn

Xác thực với API

  • Nếu dùng API làm nhanh hơn là click ở UI thì nên dùng API.
  • Có mấy cách để xử lý: dùng setup project, dùng fixture.
  • Trường hợp sử dụng setup project:
import { test as setup } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ request }) => {
  // Send authentication request. Replace with your own.
  await request.post('https://github.com/login', {
    form: {
      'user': 'user',
      'password': 'password'
    }
  });
  await request.storageState({ path: authFile });
});
  • Trường hợp sử dụng worker fixture
import { test as baseTest, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';

export * from '@playwright/test';
export const test = baseTest.extend<{}, { workerStorageState: string }>({
  // Use the same storage state for all tests in this worker.
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  // Authenticate once per worker with a worker-scoped fixture.
  workerStorageState: [async ({}, use) => {
    // Use parallelIndex as a unique identifier for each worker.
    const id = test.info().parallelIndex;
    const fileName = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);

    if (fs.existsSync(fileName)) {
      // Reuse existing authentication state if any.
      await use(fileName);
      return;
    }

    // Important: make sure we authenticate in a clean environment by unsetting storage state.
    const context = await request.newContext({ storageState: undefined });

    // Acquire a unique account, for example create a new one.
    // Alternatively, you can have a list of precreated accounts for testing.
    // Make sure that accounts are unique, so that multiple team members
    // can run tests at the same time without interference.
    const account = await acquireAccount(id);

    // Send authentication request. Replace with your own.
    await context.post('https://github.com/login', {
      form: {
        'user': 'user',
        'password': 'password'
      }
    });

    await context.storageState({ path: fileName });
    await context.dispose();
    await use(fileName);
  }, { scope: 'worker' }],
});

Authen với nhiều roles khác nhau

  • Đại khái là sẽ lưu thành 2 file state khác nhau. Trong test dùng file nào thì define vào.
  • Đầu tiên thì trong file setup định nghĩa nhiều setup khác nhau, mỗi setup lưu vào 1 file authentication state khác nhau.
import { test as setup, expect } from '@playwright/test';

const adminFile = 'playwright/.auth/admin.json';

setup('authenticate as admin', async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto('https://github.com/login');
  await page.getByLabel('Username or email address').fill('admin');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL('https://github.com/');
  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

  // End of authentication steps.

  await page.context().storageState({ path: adminFile });
});

const userFile = 'playwright/.auth/user.json';

setup('authenticate as user', async ({ page }) => {
  // Perform authentication steps. Replace these actions with your own.
  await page.goto('https://github.com/login');
  await page.getByLabel('Username or email address').fill('user');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait until the page receives the cookies.
  //
  // Sometimes login flow sets cookies in the process of several redirects.
  // Wait for the final URL to ensure that the cookies are actually set.
  await page.waitForURL('https://github.com/');
  // Alternatively, you can wait until the page reaches a state where all cookies are set.
  await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

  // End of authentication steps.

  await page.context().storageState({ path: userFile });
});
  • Bây giờ storageState không định nghĩa trong file playwright.config.ts nữa, mà dùng file nào thì trong test define ra (dùng test.use):
import { test } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/admin.json' });

test('admin test', async ({ page }) => {
  // page is authenticated as admin
});

test.describe(() => {
  test.use({ storageState: 'playwright/.auth/user.json' });

  test('user test', async ({ page }) => {
    // page is authenticated as a user
  });
});
  • Trường hợp bạn muốn authen nhiều role trong cùng một test, sử dụng browserContext:
import { test } from '@playwright/test';

test('admin and user', async ({ browser }) => {
  // adminContext and all pages inside, including adminPage, are signed in as "admin".
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const adminPage = await adminContext.newPage();

  // userContext and all pages inside, including userPage, are signed in as "user".
  const userContext = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
  const userPage = await userContext.newPage();

  // ... interact with both adminPage and userPage ...

  await adminContext.close();
  await userContext.close();
});

Test multiple roles với POM fixtures

  • Cũng khá đơn giản: sử dụng POM kết hợp với fixture để tổ chức code cho gọn gàng
import { test as base, type Page, type Locator } from '@playwright/test';

class AdminPage {
  // Page signed in as "admin".
  page: Page;
  greeting: Locator;

  constructor(page: Page) {
    this.page = page;
    this.greeting = page.locator('#greeting');
  }
}

class UserPage {
  // Page signed in as "user".
  page: Page;
  greeting: Locator;

  constructor(page: Page) {
    this.page = page;
    this.greeting = page.locator('#greeting');
  }
}

// Declare the types of your fixtures.
type MyFixtures = {
  adminPage: AdminPage;
  userPage: UserPage;
};

export * from '@playwright/test';
export const test = base.extend<MyFixtures>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
    const adminPage = new AdminPage(await context.newPage());
    await use(adminPage);
    await context.close();
  },
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
    const userPage = new UserPage(await context.newPage());
    await use(userPage);
    await context.close();
  },
});
  • Sau khi tạo fixture, chúng ta có thể sử dụng fixture để khởi tạo thông tin xác thực cho hai người dùng với vai role khác nhau:
// Import test with our new fixtures.
import { test, expect } from '../playwright/fixtures';

// Use adminPage and userPage fixtures in the test.
test('admin and user', async ({ adminPage, userPage }) => {
  // ... interact with both adminPage and userPage ...
  await expect(adminPage.greeting).toHaveText('Welcome, Admin');
  await expect(userPage.greeting).toHaveText('Welcome, User');
});

Session storage

  • Thường thì authentication state sẽ lưu ở cookie và localStorage.
  • Tuy nhiên có vài trường hợp cá biệt lưu vào sessionStorage.
  • Playwright không cung cấp API để tương tác với sessionStorage, tuy nhiên có thể workaround như sau:
// Get session storage and store as env variable
const sessionStorage = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('playwright/.auth/session.json', sessionStorage, 'utf-8');

// Set session storage in a new context
const sessionStorage = JSON.parse(fs.readFileSync('playwright/.auth/session.json', 'utf-8'));
await context.addInitScript(storage => {
  if (window.location.hostname === 'example.com') {
    for (const [key, value] of Object.entries(storage))
      window.sessionStorage.setItem(key, value);
  }
}, sessionStorage);

Ignore authentication trong 1 số test

  • Giả sử bây giờ bạn cần bỏ authentication trong một số test đi, thì hãy define storageState rỗng:
import { test } from '@playwright/test';

// Reset storage state for this file to avoid being authenticated
test.use({ storageState: { cookies: [], origins: [] } });

test('not signed in test', async ({ page }) => {
  // ...
});

Trả lời