NỘI DUNG BÀI HỌC

🎯 Chiến lược Assertion Thông minh

✂️ Loại bỏ Phản mẫu "Page Chaining"

💠 Kiến trúc Component Chuyên sâu

⚖️ Làm chủ Nguyên tắc SRP

💎 Xây dựng Framework Bền vững



 

🔧 Phần 1: Mở rộng Kiến trúc: Vai trò của Thư mục utils (Lớp Tiện ích)

 

Khi một bộ khung (framework) phát triển 📈, chúng ta sẽ gặp phải nhu cầu về các hàm logic có thể tái sử dụng nhưng lại không thuộc về bất kỳ Page Object cụ thể nào. Việc đặt logic này vào BasePage sẽ làm "phình to" 🐡 class Cha một cách không cần thiết, trong khi việc lặp lại chúng trong các file test vi phạm nguyên tắc DRY (🚫🔁).

Đây là lúc Lớp Tiện ích (utils) phát huy vai trò 🛠️.

 

📚 Định nghĩa Lớp Tiện ích (Utility Layer)

 

Lớp Tiện ích (thường được triển khai bằng thư mục utils/) là một tập hợp các hàm (functions) hoặc class độc lập, phi trạng thái (stateless), và có thể tái sử dụng trong toàn bộ dự án.

Nó tuân theo nguyên tắc Tách biệt Trách nhiệm ở cấp độ cao hơn:

  • 🏢 pages/ (Lớp POM): Quản lý trạng thái và hành vi của UI. Các class này được khởi tạo (new LoginPage(page)) và có liên kết trực tiếp với đối tượng page.

  • 🛠️ utils/ (Lớp Tiện ích): Cung cấp logic thuần túy (pure logic) hoặc các dịch vụ hỗ trợ. Các hàm này thường được import và gọi trực tiếp mà không cần khởi tạo class.

Cấu trúc dự án được cập nhật sẽ trông như sau:

my-playwright-project/
├── pages/
│   ├── base.page.ts
│   └── login.page.ts
├── tests/
│   └── login.spec.ts
├── utils/          # <-- ✨ LỚP MỚI
│   ├── dataFactory.ts  # (🏭 Tạo dữ liệu test)
│   ├── uiHelpers.ts    # (🧩 Hàm hỗ trợ UI phức tạp)
│   └── dateUtils.ts    # (📅 Định dạng ngày tháng)
└── ...

 

🏭 Triển khai 1: Tạo Dữ liệu Thử nghiệm Động (Dynamic Test Data)

 

Đây là một trong những ứng dụng quan trọng nhất của thư mục utils. Việc hard-code (viết cứng) dữ liệu test như test_user@gmail.com trong kịch bản test sẽ gây ra các vấn đề về khả năng bảo trì và xung đột dữ liệu 💥 khi chạy song song.

Giải pháp là tạo dữ liệu động, thực tế bằng cách sử dụng các thư viện như Faker.js.

 

Bước 1: Cài đặt Faker.js 📦

 
npm install --save-dev @faker-js/faker

Bước 2: Tạo utils/dataFactory.ts 🏭

File này sẽ đóng vai trò là "nhà máy" tạo ra dữ liệu test.

// utils/dataFactory.ts
import { faker } from '@faker-js/faker';

// Tạo một người dùng ngẫu nhiên với dữ liệu thực tế 👤
export const createRandomUser = () => {
  return {
    username: faker.internet.userName(),
    email: faker.internet.email(),
    password: faker.internet.password({ length: 12 }),
    firstName: faker.person.firstName(),
  };
};

// Tạo một chuỗi ký tự ngẫu nhiên đơn giản 🔤
export const createRandomString = (length: number): string => {
  let result = '';
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
};

Bước 3: Sử dụng trong Test Script 🧪

// tests/register.spec.ts
import { test } from '@playwright/test';
import { RegisterPage } from '../pages/register.page';
import { createRandomUser } from '../utils/dataFactory'; // <-- 📥 Import từ utils

test('should register successfully with a new user', async ({ page }) => {
  const registerPage = new RegisterPage(page);
  
  // Tạo dữ liệu động TRƯỚC khi thực thi 🎲
  const newUser = createRandomUser();
  
  await registerPage.goTo();
  await registerPage.register(
    newUser.username,
    newUser.email,
    newUser.password
  );
  
  // ... (assert kết quả) ✅
});

🧩  Triển khai 2: Hàm Hỗ trợ Tương tác (Interaction Helper Functions)

 

Ứng dụng của bạn có thể có các component UI phức tạp và tùy chỉnh (custom) được sử dụng lặp lại ở nhiều trang, ví dụ như một ô tìm kiếm "thông minh" (autocomplete search) 🔎 hoặc một component chọn ngày (date picker) 📅.

Thay vì viết lại logic phức tạp này trong mỗi Page Object, chúng ta có thể trừu tượng hóa nó thành một hàm utils.

Ví dụ: Xử lý một Dropdown tùy chỉnh (không phải thẻ <select>)

Giả sử ứng dụng có một dropdown được xây dựng bằng <div><li>, việc chọn một giá trị yêu cầu 2 bước: click vào nút trigger và sau đó click vào <li>data-value tương ứng.

 

Bước 1: Tạo utils/uiHelpers.ts 🛠️

// utils/uiHelpers.ts
import { type Page } from '@playwright/test';

/**
 * Xử lý một component dropdown 'material-ui' tùy chỉnh không phải là <select>
 * @param page Đối tượng Page
 * @param dropdownTriggerSelector Selector để mở dropdown (ví dụ: 'button[aria-label="Chọn quốc gia"]')
 * @param dataValue Giá trị của lựa chọn cần click (ví dụ: 'VN')
 */
export async function selectCustomDropdownValue(
  page: Page, 
  dropdownTriggerSelector: string, 
  dataValue: string
) {
  await page.click(dropdownTriggerSelector); // 👆 Click mở
  await page.click(`li[role="option"][data-value="${dataValue}"]`); // 👆 Click chọn
}​

Bước 2: Sử dụng trong Page Object (Không phải Test Script) 🤖

Các hàm hỗ trợ UI này nên được gọi từ bên trong Page Object, để giữ cho Test Script vẫn "sạch" và chỉ gọi API nghiệp vụ.

 
// pages/profile.page.ts
import { BasePage } from './base.page';
import { type Page, type Locator } from '@playwright/test';
import { selectCustomDropdownValue } from '../utils/uiHelpers'; // <-- 📥 Import từ utils

export class ProfilePage extends BasePage {
  readonly countryDropdownTrigger: Locator;
  
  constructor(page: Page) {
    super(page);
    this.countryDropdownTrigger = page.locator('button[aria-label="Chọn quốc gia"]');
  }
  
  // Phương thức nghiệp vụ
  async updateCountry(countryCode: string) {
    // Gọi hàm hỗ trợ thay vì viết logic phức tạp tại đây 💡
    await selectCustomDropdownValue(
      this.page, 
      'button[aria-label="Chọn quốc gia"]', // (Hoặc truyền this.countryDropdownTrigger)
      countryCode
    ); 
    // ... (click save, etc.) 💾
  }
}

⚖️  Phân biệt Kiến trúc: BasePage vs. utils

Một câu hỏi kiến trúc quan trọng là: "Khi nào nên đặt một hàm vào BasePage và khi nào vào utils?".

🏷️ Tiêu chí 🏛️ BasePage (Kế thừa) 🛠️ utils (Composition)
Bản chất Có trạng thái (Stateful). Liên kết chặt chẽ với các thành phần UI chung của ứng dụng (Header, Footer). Phi trạng thái (Stateless). Cung cấp logic thuần túy, không phụ thuộc vào trạng thái UI cụ thể.
Mối quan hệ Kế thừa (Inheritance) 🧬. ProfilePage là một BasePage. Composition 🧱. ProfilePage sử dụng một uiHelper.
Ví dụ async logout() (Hành động này phụ thuộc vào this.userMenuthis.logoutButton được định nghĩa trong BasePage). createRandomUser() (Không cần page, không cần UI).
Ví dụ async openUserMenu() (Một hành vi UI cụ thể, chung). formatDateToMMDDYYYY(date) (Logic thuần túy).
Ví dụ async searchGlobal(text) (Hành vi của thanh tìm kiếm chung trên Header). selectCustomDropdownValue(...) (Hàm này chung chung, nó có thể xử lý bất kỳ dropdown nào, không chỉ một cái trên Header).

🏁 Kết luận: Thư mục utils là một phần mở rộng thiết yếu cho kiến trúc POM, giúp framework trở nên gọn gàng, dễ bảo trì và tuân thủ chặt chẽ hơn nguyên tắc Tách biệt Trách nhiệm bằng cách cô lập logic hỗ trợ (như tạo dữ liệu và các hàm tiện ích) ra khỏi logic nghiệp vụ của các Page Objects.


🏗️ Phần 2: Nâng cao Kiến trúc: Component Object Model (CPOM)

 Vấn đề của POM "Truyền thống": Các Thành phần Tái sử dụng ♻️

Khi ứng dụng của bạn phát triển 📈, bạn sẽ nhận thấy các thành phần UI (components) được sử dụng lặp lại ở nhiều trang. Ví dụ:

  • 🎩 Header (Tiêu đề): Chứa logo, thanh tìm kiếm, menu người dùng. Xuất hiện trên mọi trang (trừ trang login).

  • 📊 Table (Bảng dữ liệu): Một bảng dữ liệu phức tạp với logic sắp xếp, phân trang. Có thể xuất hiện ở UsersPage, ProductsPage, OrdersPage.

  • 💬 Modal (Cửa sổ bật lên): Một modal xác nhận "Bạn có chắc chắn muốn xóa?" được dùng ở nhiều nơi.

Nếu tuân theo POM "truyền thống" 🏛️, chúng ta sẽ phải định nghĩa lại locators và các hàm tương tác (như search(), clickUserMenu(), sortByColumn()) trong mọi Page Object (ví dụ: UsersPage, ProductsPage...). Điều này vi phạm nghiêm trọng nguyên tắc DRY (🚫 Don't Repeat Yourself).


Giải pháp: Component Object Model (CPOM) 🧩

Component Object Model (CPOM) là một sự tiến hóa của POM, nơi chúng ta tạo ra các class riêng biệt cho các thành phần UI tái sử dụng, thay vì cho cả một trang.

Một "Component Object" về cơ bản là một Page Object cho một phần của trang.

 Cấu trúc Dự án Cập nhật với components/ 📂

Để triển khai CPOM, chúng ta tạo một thư mục mới là components/.

my-playwright-project/
├── components/      # <-- (MỚI ✨) Lớp Thành phần Tái sử dụng
│   ├── header.component.ts
│   ├── table.component.ts
│   └── modal.component.ts
├── pages/
│   ├── base.page.ts
│   └── login.page.ts
├── tests/
│   └── login.spec.ts
├── utils/
│   └── dataFactory.ts
└── ...

Phân tích Kỹ thuật: "Composition" (Khuyến nghị ✅) vs. "Inheritance"

Đây là lúc chúng ta trả lời câu hỏi mấu chốt: Các component này được quản lý ở đâu?

Cách 1: Kế thừa (Inheritance) - BasePage là Header (Không khuyến nghị ❌)

  • Cách làm: Đây là những gì chúng ta đã làm trong ví dụ BasePage ban đầu (Phần 2.3). Chúng ta đặt userMenu, logoutButton, và hàm logout() trực tiếp vào BasePage.

  • Ưu điểm: Đơn giản, dễ hiểu.

  • Nhược điểm: Kém linh hoạt, làm "phình to" 🐡 BasePage. Nếu một trang nào đó không có Header thì sao? Hoặc nếu Header có một biến thể nhỏ? Kế thừa trở nên cồng kềnh.

Cách 2: Composition (Khuyến nghị ✅) - BasePage có một HeaderComponent

  • Cách làm: Đây là mẫu thiết kế "Composition over Inheritance" (Ưu tiên Composition hơn Kế thừa).

  • Chúng ta tạo một class HeaderComponent độc lập.

  • Sau đó, BasePage (hoặc bất kỳ Page Object nào cần) sẽ khởi tạo một thực thể (instance) của HeaderComponent trong constructor của nó.

  • Ưu điểm: Cực kỳ linh hoạt 🤸, code sạch sẽ ✨, và tuân thủ triệt để Tách biệt Trách nhiệm. BasePage chỉ quản lý các component chung. ProfilePage có thể quản lý các component riêng của nó.

 Ví dụ Code Triển khai (Composition) 💻

Hãy tái cấu trúc (refactor) lại ví dụ của chúng ta để sử dụng CPOM.

File 1: components/header.component.ts 

(Chúng ta tách logic của Header ra khỏi BasePage)


// components/header.component.ts
import { type Page, type Locator } from '@playwright/test';

export class HeaderComponent {
  // Component này không kế thừa BasePage
  readonly page: Page;
  
  // Locators của riêng Header
  readonly userMenu: Locator;
  readonly logoutButton: Locator;
  readonly mainLogo: Locator;

  constructor(page: Page) {
    this.page = page;
    
    // Khởi tạo locators
    this.userMenu = page.locator('button[aria-label="User Menu"]');
    this.logoutButton = page.locator('a[href="/logout"]');
    this.mainLogo = page.locator('.header-logo');
  }

  // Hành động của riêng Header
  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }
}
​

File 2: pages/base.page.ts (Cập nhật để sử dụng Composition 🧱) 

(BasePage giờ đây gọn gàng hơn rất nhiều)

 
// pages/base.page.ts
import { type Page } from '@playwright/test';
import { HeaderComponent } from '../components/header.component'; // 1. 📥 Import component

export class BasePage {
  readonly page: Page;
  readonly header: HeaderComponent; // 2. "Có một" HeaderComponent (Composition)

  constructor(page: Page) {
    this.page = page;
    this.header = new HeaderComponent(page); // 3. 🆕 Khởi tạo component
  }

  // (BasePage giờ có thể chứa các hàm tiện ích chung như)
  async waitForSpinner() {
    // await this.page.locator('.loading-spinner').waitFor({ state: 'hidden' });
  }
}

 

File 3: pages/profile.page.ts (Không thay đổi nhiều 🤷‍♂️)

(Class này kế thừa BasePage, do đó nó tự động có quyền truy cập vào this.header)

 
// pages/profile.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class ProfilePage extends BasePage {
  readonly welcomeMessage: Locator;
  readonly editProfileButton: Locator;

  constructor(page: Page) {
    super(page); // ⬆️ Bắt buộc gọi 'super'

    this.welcomeMessage = page.locator('h1.welcome');
    this.editProfileButton = page.locator('button[aria-label="Edit Profile"]');
  }
  
  // (Không cần định nghĩa lại hàm logout() ở đây)
}

File 4: tests/login.spec.ts (Cập nhật cách gọi 📞)

(Test script giờ đây gọi hành vi thông qua component, làm rõ hơn ý định)

// tests/login.spec.ts
// (Imports...)

let loginPage: LoginPage;
let profilePage: ProfilePage;

test.beforeEach(async ({ page }) => {
  loginPage = new LoginPage(page);
  profilePage = new ProfilePage(page);
});

// ... (Test TC_LOGIN_01)

test('TC_PROFILE_03: Should logout successfully', async ({ page }) => {
  await loginPage.goTo();
  await loginPage.login('valid_user', 'correct_pass');

  // CÁCH GỌI MỚI: Rõ ràng hơn nhiều 💡
  // "Từ trang profile, truy cập component header, và thực hiện hành động logout"
  await profilePage.header.logout(); 
  
  await expect(page).toHaveURL('/login');
});

Cân nhắc Kiến trúc: Component Toàn cục 🌐 vs. Cục bộ 🏠 (Quan trọng)

Một câu hỏi kiến trúc quan trọng là: Nên khởi tạo Component ở đâu? Câu trả lời phụ thuộc vào phạm vi của component đó.

Cách 1: Component Toàn cục (Global) - Khởi tạo trong BasePage (Khuyến nghị ✅)

  • Kịch bản: Áp dụng cho các component xuất hiện trên hầu hết các trang, như HeaderComponent (Đầu trang), FooterComponent (Chân trang), hoặc SidebarComponent (Thanh bên chung).

  • Triển khai: Khởi tạo chúng một lần duy nhất trong constructor của BasePage, như chúng ta đã làm với HeaderComponent trong ví dụ trên.

  • Kết quả: Mọi Page Object kế thừa BasePage (như ProfilePage) sẽ tự động "có" (has-a) component đó. Lời gọi trong test sẽ là await profilePage.header.logout().

Cách 2: Component Cục bộ (Local/Shared) - Khởi tạo trong Page Object cụ thể 🏠

  • Kịch bản: Áp dụng cho các component được tái sử dụng, nhưng không xuất hiện ở mọi nơi. Ví dụ: một TableComponent (Bảng dữ liệu) phức tạp chỉ xuất hiện trên UsersPageProductsPage, nhưng không có trên SettingsPage.

  • Triển khai: Không đưa component này vào BasePage. Thay vào đó, UsersPageProductsPage sẽ tự import và khởi tạo nó trong constructor của riêng chúng.

// pages/users.page.ts
import { TableComponent } from '../components/table.component.ts';

export class UsersPage extends BasePage {
  readonly table: TableComponent;

  constructor(page: Page) {
    super(page);
    this.table = new TableComponent(page); // 👈 Khởi tạo cục bộ
  }
}


Kết quả: Chỉ những trang cần component đó mới khởi tạo nó. Điều này giữ cho BasePage gọn gàng và logic. SettingsPage (cũng kế thừa BasePage) sẽ không bị "gánh" thêm TableComponent mà nó không cần.

 

 

⚖️ Phần 3: Chiến lược Xác thực (Assertions): Cân bằng giữa Tính Linh hoạt và Dễ đọc

 

Một câu hỏi kiến trúc quan trọng khác là: Nên đặt các câu lệnh expect ở đâu?. Việc này ảnh hưởng trực tiếp đến tính dễ đọc của file test và tính linh hoạt của framework 🏗️.

 

🏹  Cách 1: expect trong File Test (Cách phổ biến)

Đây là cách tiếp cận đơn giản nhất, nơi file test trực tiếp gọi expect trên các locators public của Page Object.

  • Triển khai (tests/profile.spec.ts):

    test('Should show welcome message', async () => {
      await profilePage.goTo();
    
      // File test chịu trách nhiệm hoàn toàn về việc xác thực 🕵️
      await expect(profilePage.welcomeMessage).toContainText('Chào mừng');
    });
    
  • Ưu điểm 👍:

    • Linh hoạt Tối đa: File test có thể thực hiện bất kỳ loại xác thực nào (toContainText, toHaveText, toBeVisible, toHaveAttribute, v.v.) mà không cần sửa đổi Page Object.

  • Nhược điểm 👎:

    • "Ồn ào" (Noisy) 🔊: File test bị "lẫn lộn" giữa logic nghiệp vụ (gọi goTo()) và chi tiết kỹ thuật (gọi expect trên welcomeMessage).

    • Vi phạm nguyên tắc "One Assertion Per Test" (Một Xác thực mỗi Test) nếu lạm dụng.

📦 Cách 2: expect trong Page Object (Đóng gói Tối đa)

Cách tiếp cận này giấu hoàn toàn logic expect vào bên trong một phương thức helper của Page Object. Đây là một thực hành bị tranh cãi 🤔, vì nhiều chuyên gia cho rằng Page Object không nên chứa assertions.

  • Triển khai (pages/profile.page.ts):

    async expectWelcomeMessageToContain(text: string) {
      // 'expect' được giấu bên trong hàm 🙈
      await expect(this.welcomeMessage).toContainText(text);
    }
    
  • Triển khai (tests/profile.spec.ts):

     
    test('Should show welcome message', async () => {
      await profilePage.goTo();
    
      // File test đọc như một kịch bản, rất sạch sẽ ✨
      await profilePage.expectWelcomeMessageToContain('Chào mừng');
    });
    
  • Ưu điểm 👍:

    • Dễ đọc Tối đa: File test trở nên cực kỳ sạch sẽ, đọc giống như tài liệu nghiệp vụ 📖.

  • Nhược điểm 👎:

    • Kém Linh hoạt: Nếu một test khác muốn kiểm tra toHaveText (chính xác) hoặc toBeVisible, bạn sẽ phải tạo thêm các hàm helper mới (expectWelcomeMessageToHaveText, expectWelcomeMessageToBeVisible). Điều này làm "phình to" 🐡 Page Object.

    • Vi phạm SRP 🚫: Page Object giờ đây gánh thêm trách nhiệm "xác thực", vốn nên thuộc về file test.

 

🤝  Cách 3: Phương pháp Kết hợp (Khuyến nghị ✅)

Đây là cách tiếp cận cân bằng, tận dụng ưu điểm của cả hai cách trên.

Nguyên tắc:

  1. Luôn cung cấp (expose) các locators quan trọng dưới dạng readonly public để file test có thể truy cập khi cần sự linh hoạt.

  2. Cung cấp các hàm "expect helper" cho các trường hợp xác thực phổ biến nhất (chiếm 90% các test case) để giữ cho file test sạch sẽ trong hầu hết thời gian.

Ví dụ Code "Kết hợp"

File 1: pages/profile.page.ts (Cập nhật)

import { type Page, type Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class ProfilePage extends BasePage {
  
  // 1. LUÔN CUNG CẤP LOCATOR DƯỚI DẠNG PUBLIC 🔓
  readonly welcomeMessage: Locator;
  readonly editButton: Locator;

  constructor(page: Page) {
    super(page);
    this.welcomeMessage = page.locator('h1.welcome');
    this.editButton = page.locator('.edit-btn');
  }

  // 2. CUNG CẤP HELPER CHO CÁC CHECK PHỔ BIẾN 🎁
  async expectWelcomeMessageToContain(text: string) {
    await expect(this.welcomeMessage).toContainText(text);
  }
}

File 2: tests/profile.spec.ts (Minh họa sự linh hoạt)


// ... (khởi tạo profilePage trong beforeEach)

// Test 1: Dùng helper vì nó phổ biến 🌟
test('TC_01: Should contain welcome message', async () => {
  await profilePage.goTo();
  
  // 👍 Rất sạch sẽ và dễ đọc
  await profilePage.expectWelcomeMessageToContain('Chào mừng');
});

// Test 2: Dùng locator public vì có nhu cầu 'expect' đặc biệt 🔧
test('TC_02: Should check exact attribute of welcome message', async () => {
  await profilePage.goTo();
  
  // 👍 Vẫn linh hoạt khi cần thiết
  await expect(profilePage.welcomeMessage)
   .toHaveAttribute('data-testid', 'welcome-header');
});

🏁 Kết luận: Bằng cách cung cấp cả hai, bạn đạt được trạng thái lý tưởng: các file test của bạn sạch sẽ và dễ đọc trong 90% trường hợp, nhưng vẫn giữ được toàn bộ sự linh hoạt của expect cho 10% các trường hợp đặc biệt mà không cần phải làm "phình to" Page Object.

 

🚫 Phần 4: Phản Mẫu (Anti-Pattern) Cần Tránh: "Page Chaining" (Nối chuỗi Trang)

Một kỹ thuật thường được thảo luận trong POM là "Page Chaining" (còn gọi là Fluent API), nơi một phương thức hành động (như login()) trả về một thực thể của Page Object tiếp theo. Mặc dù điều này có vẻ hấp dẫn vì nó làm cho code trong file test trông "mượt mà", nhưng nó được coi là một phản mẫu (anti-pattern) ☠️ vì những lý do kiến trúc nghiêm trọng.

⛓️. Ví dụ về Phản mẫu Page Chaining

 

Đây là cách triển khai "Page Chaining" (Không khuyến nghị ❌):

// PHẢN MẪU: KHÔNG NÊN LÀM ❌
// trong pages/login.page.ts
import { HomePage } from './home.page.ts';

public async login(user, pass): Promise<HomePage> { // <-- Trả về HomePage
  await this.usernameInput.fill(user);
  await this.passwordInput.fill(pass);
  await this.loginButton.click();
  
  // Page Object này "biết" về Page Object tiếp theo
  return new HomePage(this.page); 
}

// trong tests/login.spec.ts
test('test', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  // Lời gọi test "fluent" 🌊
  const homePage = await loginPage.login('user', 'pass');
  await homePage.expectWelcomeMessage();
});

🔍 Phân tích Vấn đề Kiến trúc

Cách tiếp cận này vi phạm các nguyên tắc cốt lõi của thiết kế phần mềm tốt:

  1. Vi phạm Nguyên tắc Đơn lẻ (Single Responsibility Principle - SRP) 🚫:

    • Trách nhiệm của LoginPage lẽ ra chỉ nên là: "Mô hình hóa các phần tử và hành vi trên trang đăng nhập".

    • Khi LoginPage trả về new HomePage(), nó đã gánh thêm một trách nhiệm thứ hai: "Quản lý và điều phối luồng điều hướng của ứng dụng".

    • Trách nhiệm điều phối (orchestration) này phải thuộc về Test Script (file *.spec.ts). File test mới là "nhạc trưởng" 🎼 quyết định luồng đi của nghiệp vụ.

  2. Tạo ra Phụ thuộc Cứng (Tight Coupling) 🔗:

    • LoginPage.ts bây giờ phải import HomePage.ts. Điều này tạo ra một phụ thuộc cứng.

    • Nếu logic nghiệp vụ thay đổi (ví dụ: đăng nhập thành công giờ đây chuyển đến DashboardPage thay vì HomePage), bạn không chỉ phải sửa file test mà còn phải sửa cả class LoginPage - một class lẽ ra không liên quan gì đến DashboardPage.

  3. Không thể Xử lý Điều hướng có Điều kiện 🔀:

    • Đây là vấn đề lớn nhất. Điều gì xảy ra nếu việc đăng nhập thất bại? Lẽ ra người dùng vẫn ở LoginPage.

    • Điều gì xảy ra nếu có 3 kết quả khả dĩ (ví dụ: trang chủ, trang đổi mật khẩu, trang đăng nhập thất bại)? Logic trả về trở nên cực kỳ phức tạp và không thể bảo trì.

🎻 . Giải pháp Khuyến nghị: "Test Orchestration" (Test điều phối)

 

Giải pháp "sạch" ✨ và tuân thủ đúng nguyên tắc Tách biệt Trách nhiệm là phương pháp đã được trình bày trong suốt bài nghiên cứu này:

  • Page Object Methods trả về void: Các phương thức hành động (như login()) chỉ nên thực hiện hành động của chúng và trả về Promise<void>. Chúng "câm" 🤐 về việc điều hướng.

  • Test Script là "Nhạc trưởng" (Orchestrator) 🎼: File test chịu trách nhiệm khởi tạo tất cả các Page Object mà nó cần (thường trong test.beforeEach) và quyết định khi nào sử dụng cái nào.

Ví dụ về Kiến trúc Tốt

// KIẾN TRÚC TỐT (Khuyến nghị ✅)
// trong pages/login.page.ts
public async login(user, pass): Promise<void> { // <-- Trả về void
  await this.usernameInput.fill(user);
  await this.passwordInput.fill(pass);
  await this.loginButton.click();
}

// trong tests/login.spec.ts
let loginPage: LoginPage;
let homePage: HomePage; // (hoặc ProfilePage, v.v.)

test.beforeEach(({ page }) => {
  loginPage = new LoginPage(page);   // <-- Test chịu trách nhiệm khởi tạo
  homePage = new HomePage(page);     // <-- Test chịu trách nhiệm khởi tạo
});

test('test', async () => {
  // 1. Hành động trên LoginPage
  await loginPage.login('user', 'pass');
  
  // 2. Test điều phối: "OK, hành động 1 đã xong."
  // "Bây giờ, tôi (file test) quyết định dùng HomePage để xác thực kết quả."
  await homePage.expectWelcomeMessage();
});

Cách tiếp cận này giữ cho các Page Object hoàn toàn độc lập, có thể tái sử dụng ♻️, tuân thủ SRP, và giúp cho việc bảo trì framework dễ dàng hơn rất nhiều trong dài hạn.

🧱 Phần 5: Phá vỡ Quy tắc? Phân tích "Vi phạm SRP" vì Lợi ích của Composition

 Chúng ta đã thảo luận về hai cách khởi tạo Component:

  1. Toàn cục (Global) 🌐: Khởi tạo trong BasePage (ví dụ: HeaderComponent).

  2. Cục bộ (Local) 🏠: Khởi tạo trong một Page Object cụ thể (ví dụ: TableComponent).

Một câu hỏi kiến trúc nâng cao được đặt ra: "Việc một Page Object tự khởi tạo component (Cách 2) có vi phạm Nguyên tắc Đơn lẻ (SRP) không?" 🤔

Bề ngoài, có vẻ là "Có". CRMDashboardPage dường như có hai trách nhiệm:

  1. Định nghĩa locators/hành vi của trang CRM.

  2. Khởi tạo SidebarMenuComponent.

Tuy nhiên, đây là một sự đánh đổi kiến trúc có chủ đích và là một thực hành tốt, tuân theo nguyên tắc "Ưu tiên Composition hơn Kế thừa" (Composition over Inheritance).

🧠 Phân tích Triết lý: Đâu mới là Trách nhiệm Thực sự?

Trách nhiệm thực sự của CRMDashboardPage không chỉ là "định nghĩa locator", mà là "Cung cấp một API hoàn chỉnh cho trang CRM Dashboard" 🎁.

Nếu trang CRM Dashboard thực sự có một Sidebar Menu, thì việc CRMDashboardPage chịu trách nhiệm cung cấp quyền truy cập vào SidebarMenuComponent là hoàn toàn nằm trong phạm vi trách nhiệm đơn lẻ của nó.

⚖️ So sánh các Lựa chọn Kiến trúc

 

Hãy xem xét một CRMDashboardPageSalesDashboardPage đều cần SidebarMenuComponent, nhưng LoginPage thì không.

Lựa chọn 1 (Tồi 👎): Đưa SidebarMenuComponent vào BasePage

  • BasePage khởi tạo HeaderComponentSidebarMenuComponent.

  • LoginPage, CRMDashboardPage, SalesDashboardPage đều kế thừa BasePage.

  • Vấn đề: LoginPage giờ đây cũng "thừa hưởng" một sidebarMenu mà nó không hề có trên UI. Điều này gây nhầm lẫn, phi logic và làm "phình to" (bloat) 🐡 mọi Page Object. Đây là một vi phạm kiến trúc nặng nề hơn.

Lựa chọn 2 (Tốt nhất 🏆): Composition Cục bộ

  • BasePage chỉ khởi tạo các component thực sự toàn cục (ví dụ: HeaderComponent).

  • Các Page Object khác kế thừa BasePage.

  • Chỉ những trang cần SidebarMenuComponent mới tự khởi tạo nó.

Ví dụ Triển khai (Khuyến nghị ✅):

// pages/crmDashboard.page.ts
import { BasePage } from './base.page';
import { SidebarMenuComponent } from '../components/sidebarMenu.component';
import { type Page } from '@playwright/test';

export class CRMDashboardPage extends BasePage {
  // Trách nhiệm 1: Định nghĩa locators/hàm của riêng trang này
  readonly crmChart: Locator;
  
  // Trách nhiệm 2: Quản lý component cục bộ của nó
  readonly sidebarMenu: SidebarMenuComponent; // 👈 "Có một" SidebarMenu (Composition)

  constructor(page: Page) {
    super(page); // Kế thừa các thành phần toàn cục (như 'header') từ BasePage

    // Tự khởi tạo các component mà chỉ trang này (hoặc một nhóm) cần
    this.sidebarMenu = new SidebarMenuComponent(page);
    
    // Khởi tạo các locators của riêng trang này
    this.crmChart = page.locator('#crm-chart');
  }
  
  async getCRMChartData() {
    // ...
  }
}

 

🏁  Kết luận

Việc một Page Object cụ thể tự khởi tạo các Component Object (CPOM) mà nó chứa không phải là một vi phạm SRP. Đây là một ứng dụng trưởng thành của mẫu hình Composition, giúp:

  • Giữ cho BasePage gọn gàng và chỉ chứa các thành phần thực sự toàn cục 🌐.

  • Giữ cho các Page Object (như LoginPage) không bị "ô nhiễm" 😷 bởi các component mà chúng không có.

  • Trao đúng trách nhiệm cho Page Object: "Mô hình hóa chính xác trang UI mà nó đại diện", bao gồm cả các component con của nó 👪.

Teacher

Teacher

Nguyên Hoàng

Automation Engineer

With 7+ years of hands-on experience across multiple languages and frameworks. I'm here to share knowledge, helping you turn complex processes into simple and effective solutions.

Cộng đồng Automation Testing Việt Nam:

🌱 Telegram Automation Testing:   Cộng đồng Automation Testing
🌱 
Facebook Group Automation: Cộng đồng Automation Testing Việt Nam
🌱 
Facebook Fanpage: Cộng đồng Automation Testing Việt Nam - Selenium
🌱 Telegram
Manual Testing:   Cộng đồng Manual Testing
🌱 
Facebook Group Manual: Cộng đồng Manual Testing Việt Nam

Chia sẻ khóa học lên trang

Bạn có thể đăng khóa học của chính bạn lên trang Anh Tester để kiếm tiền

Danh sách bài học