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ượngpage. -
🛠️
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 đượcimportvà 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> và <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> có 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.userMenu và this.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àmlogout()trực tiếp vàoBasePage. -
Ư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ủaHeaderComponenttrong 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.
BasePagechỉ quản lý các component chung.ProfilePagecó 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ặcSidebarComponent(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ớiHeaderComponenttrong 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ênUsersPagevàProductsPage, nhưng không có trênSettingsPage. -
Triển khai: Không đưa component này vào
BasePage. Thay vào đó,UsersPagevàProductsPagesẽ 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ọiexpecttrênwelcomeMessage). -
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ặctoBeVisible, 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:
-
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. -
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:
-
Vi phạm Nguyên tắc Đơn lẻ (Single Responsibility Principle - SRP) 🚫:
-
Trách nhiệm của
LoginPagelẽ 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
LoginPagetrả 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ụ.
-
-
Tạo ra Phụ thuộc Cứng (Tight Coupling) 🔗:
-
LoginPage.tsbây giờ phảiimport 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
DashboardPagethay vìHomePage), bạn không chỉ phải sửa file test mà còn phải sửa cả classLoginPage- một class lẽ ra không liên quan gì đếnDashboardPage.
-
-
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:
-
Toàn cục (Global) 🌐: Khởi tạo trong
BasePage(ví dụ:HeaderComponent). -
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:
-
Định nghĩa locators/hành vi của trang CRM.
-
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 CRMDashboardPage và SalesDashboardPage đều cần SidebarMenuComponent, nhưng LoginPage thì không.
Lựa chọn 1 (Tồi 👎): Đưa SidebarMenuComponent vào BasePage
-
BasePagekhởi tạoHeaderComponentVÀSidebarMenuComponent. -
LoginPage,CRMDashboardPage,SalesDashboardPageđều kế thừaBasePage. -
Vấn đề:
LoginPagegiờ đây cũng "thừa hưởng" mộtsidebarMenumà 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ộ
-
BasePagechỉ 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
SidebarMenuComponentmớ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
BasePagegọ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ó 👪.
