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
🛠️ Các "Viên gạch" Nền tảng (Utility Types)
🔑 Phép biến hình dữ liệu (Type Operations)
🏹 Giải mã Cú pháp Hàm (Function Types)
🧠 Tư duy Logic & Trừu tượng (Core Concepts)
🚀 THỰC CHIẾN - Combo "Hủy Diệt" (The Killer Pattern)
🎯 Tư duy "Anti-Fragil💠 Thuật toán "Smart Map" & Normalization
🔧 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ó 👪.
Utility Types là gì?
Trong TypeScript, chúng ta thường định nghĩa các interface hoặc type cố định. Tuy nhiên, trong thực tế code, dữ liệu thường biến đổi linh hoạt.
- Vấn đề: Đôi khi chúng ta cần một type giống hệt User, nhưng tất cả các trường đều không bắt buộc. Hoặc chúng ta cần một object map từ ID sang Data mà không muốn viết thủ công.
- Giải pháp: TypeScript cung cấp Utility Types (các kiểu tiện ích) để biến đổi các type có sẵn thành type mới một cách tự động.
Partial<T> - Biến tất cả thành "Tùy chọn"
Khái niệm
Partial<T> tạo ra một type mới bằng cách sao chép type T nhưng biến tất cả các property (thuộc tính) của nó thành optional (có dấu ?).
Cú pháp:
Partial<TypeCanBienDoi>
Tại sao cần dùng?
Dùng nhiều nhất trong tính năng Update (Cập nhật). Khi bạn sửa thông tin user, bạn không cần gửi lên toàn bộ thông tin (như email, password, ngày tạo...), mà chỉ gửi những trường cần sửa.
Ví dụ minh họa
Bước 1: Định nghĩa type gốc
interface Product {
id: number;
name: string;
price: number;
description: string;
}
Bước 2: Vấn đề gặp phải (Nếu không dùng Partial)
// Hàm update này bắt buộc phải truyền đủ cả name, price, description
// Dù ta chỉ muốn sửa giá tiền.
function updateProduct(id: number, newData: Product) {
// logic update
}
// Lỗi: Thiếu name, description...
updateProduct(1, { price: 20000 });
Bước 3: Giải pháp với Partial
// newData bây giờ có kiểu là { id?: number; name?: string; ... }
function updateProduct(id: number, newData: Partial<Product>) {
console.log(`Updating product ${id} with data:`, newData);
// Gọi API update...
}
// Hợp lệ: Chỉ update giá
updateProduct(1, { price: 20000 });
// Hợp lệ: Chỉ update tên và mô tả
updateProduct(1, { name: "Iphone 15", description: "Mới tinh" });
Record<K, T> - Tạo Object Key-Value an toàn
Khái niệm
Record<K, T> dùng để tạo ra một object type mà:
- K (Key): Là danh sách các key hợp lệ (thường là string, number, hoặc union type).
- T (Type): Là kiểu dữ liệu của value tương ứng với key đó.
Cú pháp:
Record<Keys, Type>
Tại sao cần dùng?
- Thay thế cho cú pháp lỏng lẻo index signature (ví dụ: {[key: string]: any}).
- Bắt buộc lập trình viên phải khai báo đầy đủ các trường hợp (khi Key là Union Type).
Ví dụ 1: Object Dictionary đơn giản (Map ID -> Data)
Giả sử bạn có một danh sách user, nhưng muốn truy xuất nhanh theo ID thay vì dùng mảng .find().
interface UserInfo {
name: string;
age: number;
}
// Key là string (ID), Value là UserInfo
const userMap: Record<string, UserInfo> = {
"user_01": { name: "Tùng", age: 25 },
"user_02": { name: "Hoa", age: 22 },
};
// Truy xuất cực nhanh
console.log(userMap["user_01"]);
Ví dụ 2: Ràng buộc Key chặt chẽ (Quyền lực của Record)
Đây là ví dụ "ăn tiền" của Record. Giả sử hệ thống có 3 vai trò (Role), bạn muốn định nghĩa cấu hình cho 3 vai trò này. Nếu thiếu 1 vai trò, code phải báo lỗi.
Kết hợp Record và Partial: Combo thực tế
Trong Automaton, thay vì hardcode dữ liệu rải rác trong từng file test, chúng ta sẽ tổ chức dữ liệu tập trung.
Record: Giúp quản lý các bộ dữ liệu theo từng kịch bản (Scenario) cụ thể, tránh việc đặt tên biến lung tung.
Partial: Giúp tạo ra các bộ dữ liệu "biến thể" (chỉ chứa các trường cần thay đổi) mà không cần copy-paste lại toàn bộ object gốc.
Ví dụ: Test tính năng "Cập nhật hồ sơ người dùng" (Update Profile)
Giả sử form Profile có 4 trường: username, email, bio, avatarUrl.
Bước 1: Định nghĩa Interface gốc
Đây là khuôn mẫu đầy đủ của một User.
interface UserProfile {
username: string;
email: string;
bio: string;
avatarUrl: string;
}
Bước 2: Định nghĩa các kịch bản test (Keys)
Chúng ta dùng Union Type để liệt kê các trường hợp cần test. Việc này giúp code không bị "magic string" (gõ nhầm tên case).
type TestScenario =
| "standard_update" // Case cập nhật thông thường
| "email_change_only" // Case chỉ đổi email
| "long_bio" // Case bio quá dài
| "remove_avatar"; // Case xóa avatar
Bước 3: Tạo kho dữ liệu dùng Record và Partial
Đây là phần quan trọng nhất. Chúng ta khai báo biến testDataMap.
Key phải là TestScenario.
Value là Partial<UserProfile> (Nghĩa là: Dữ liệu test không bắt buộc phải đủ 4 trường, chỉ cần chứa trường muốn test).
const testDataMap: Record<TestScenario, Partial<UserProfile>> = {
// Case 1: Cập nhật đầy đủ (Dùng gần hết các trường)
standard_update: {
username: "user_moi_123",
email: "new_email@example.com",
bio: "Đây là bio mới",
},
// Case 2: Chỉ muốn test đổi email, các cái khác giữ nguyên (Partial phát huy tác dụng)
email_change_only: {
email: "only_change_this@test.com"
},
// Case 3: Test boundary (giới hạn) của Bio
long_bio: {
bio: "A".repeat(1000) // Chuỗi dài 1000 ký tự
},
// Case 4: Xóa avatar (truyền chuỗi rỗng)
remove_avatar: {
avatarUrl: ""
}
};
Bước 4: Viết Helper Function trong Playwright
Chúng ta cần một hàm updateProfile thông minh. Hàm này sẽ nhận vào Partial data, sau đó merge (gộp) với dữ liệu mặc định để điền vào form.
import { Page, expect } from '@playwright/test';
// Hàm này nhận vào "dataOverrides" - tức là chỉ những gì cần sửa
async function fillProfileForm(page: Page, dataOverrides: Partial<UserProfile>) {
// 1. Dữ liệu mặc định (để đảm bảo form luôn được điền đủ nếu cần)
const defaultData: UserProfile = {
username: "default_user",
email: "default@test.com",
bio: "Default Bio",
avatarUrl: "default.png"
};
// 2. Merge dữ liệu: Lấy default đè partial lên
// Những trường nào trong dataOverrides có thì sẽ thay thế default
const finalData = { ...defaultData, ...dataOverrides }
// 3. Thực hiện thao tác với Playwright
if (dataOverrides.username) {
await page.fill('#username', finalData.username);
}
if (dataOverrides.email) {
await page.fill('#email', finalData.email);
}
if (dataOverrides.bio) {
await page.fill('#bio', finalData.bio);
}
await page.click('#save-btn');
}
Bước 5: Sử dụng trong Test Case
Bây giờ file test của bạn sẽ cực kỳ gọn gàng và dễ đọc.
import { test } from '@playwright/test';
test('Should update email successfully', async ({ page }) => {
// Chỉ lấy đúng cục data dành cho case email
const data = testDataMap.email_change_only;
// Gọi hàm fill, hàm sẽ tự hiểu chỉ cần quan tâm email
await fillProfileForm(page, data);
// Assertion
await expect(page.locator('#success-msg')).toBeVisible();
});
test('Should handle long bio validation', async ({ page }) => {
const data = testDataMap.long_bio;
await fillProfileForm(page, data);
await expect(page.locator('#error-bio')).toHaveText('Bio too long');
});
typeof (Trong ngữ cảnh TypeScript)
Khác với JavaScript (trả về chuỗi "string", "number"), typeof trong TypeScript dùng để "chụp ảnh" kiểu dữ liệu của một biến đang tồn tại.
Tác dụng: Bạn lười viết interface? Bạn có sẵn một object mẫu? Dùng typeof để tự động sinh ra Type từ object đó.
const product = {
id: 1,
title: "Iphone 15",
isStock: true
};
// Thay vì ngồi hì hục viết interface Product {...}
// Ta dùng typeof để trích xuất type từ biến product
type ProductType = typeof product;
// Kết quả của ProductType sẽ tương đương:
// { id: number; title: string; isStock: boolean; }
keyof
keyof là toán tử lấy ra danh sách các Keys (tên thuộc tính) của một Type (Lưu ý: Chỉ hoạt động trên Type, không hoạt động trên biến).
interface Staff {
name: string;
salary: number;
}
type StaffKeys = keyof Staff;
// Kết quả: "name" | "salary"
Tại sao phải kết hợp keyof typeof?
Đây là câu hỏi học viên hay thắc mắc nhất: "Tại sao không dùng keyof biencuatoi luôn cho nhanh?"
Vấn đề:
keyof chỉ ăn được Type (Interface, Type Alias).
Biến (Object const, let) là Value.
=> keyof không thể đứng trước một biến.
Giải pháp: Cần một "cây cầu" bắc ngang.
Dùng typeof để biến Value -> Type.
Dùng keyof để lấy key từ Type đó.
Công thức:
Biến Object (Value) ---> typeof ---> Type ---> keyof ---> Union Keys
Ví dụ thực tế trong Automation
Trong Automation, chúng ta hay có một file chứa danh sách URL hoặc Locators. Chúng ta muốn viết một hàm gotoPage mà chỉ chấp nhận các tên page có trong danh sách đó.
Bước 1: Khai báo dữ liệu cố định (Config)
Lưu ý: Trong thực tế nên dùng thêm as const để khóa chặt giá trị.
// Đây là một Object (Value), không phải Type
const APP_URLS = {
home: '/dashboard',
profile: '/user/profile',
settings: '/settings/general',
login: '/auth/login'
} as const; // as const giúp biến các value thành literal type (chính xác từng chữ)
Bước 2: Tạo Type động từ dữ liệu trên
// 1. Lấy type của object APP_URLS
type AppUrlsType = typeof APP_URLS;
// 2. Lấy danh sách key (home | profile | settings | login)
type PageName = keyof AppUrlsType;
// Viết tắt (Combo):
type PageNameShort = keyof typeof APP_URLS;
// Kết quả: "home" | "profile" | "settings" | "login"
Bước 3: Ứng dụng vào hàm Playwright
Bây giờ hàm của bạn cực kỳ thông minh. Nếu bạn thêm 1 url mới vào APP_URLS, hàm này tự động update type, không cần sửa interface nào cả.
async function navigateTo(pageName: PageName) {
const url = APP_URLS[pageName];
console.log(`Navigating to: ${url}`);
// await page.goto(url);
}
// ✅ Hợp lệ - Code gợi ý (Intellisense) tự hiện ra list cho chọn
navigateTo('home');
navigateTo('settings');
// ❌ Lỗi ngay lập tức (Compile error)
navigateTo('admin'); // Lỗi: Argument of type '"admin"' is not assignable...
Kỹ thuật nâng cao: keyof typeof đi kèm as const
Nếu không có as const:
const LOCATORS = {
btnSubmit: "#submit",
inputUser: "#user"
};
// Type suy luận là: { btnSubmit: string; inputUser: string; }
// Key vẫn là "btnSubmit" | "inputUser" -> OK
Nhưng nếu bạn muốn lấy Value (ví dụ muốn lấy danh sách các chuỗi locator #submit | #user) thì bắt buộc cần as const.
Ví dụ lấy cả Key và Value:
const ERROR_MESSAGES = {
REQUIRED: "Trường này là bắt buộc",
INVALID_EMAIL: "Email không đúng định dạng",
MIN_LENGTH: "Phải tối thiểu 6 ký tự"
} as const; // <--- QUAN TRỌNG: Khóa cứng giá trị, không cho thành string chung chung
// 1. Lấy Keys: "REQUIRED" | "INVALID_EMAIL" | "MIN_LENGTH"
type ErrorKeys = keyof typeof ERROR_MESSAGES;
// 2. Lấy Values: "Trường này là bắt buộc" | ... (Lấy các value thực tế)
type ErrorValues = (typeof ERROR_MESSAGES)[ErrorKeys];
// Ứng dụng: Hàm assert thông báo lỗi
function expectErrorMessage(msg: ErrorValues) {
// Hàm này chỉ chấp nhận đúng 3 câu thông báo lỗi chuẩn đã quy định
}
expectErrorMessage("Trường này là bắt buộc"); // ✅ OK
expectErrorMessage("Lỗi linh tinh"); // ❌ Error
Closure là gì? (Giải thích bình dân)
Hãy tưởng tượng một hàm (Function) giống như một căn phòng.
Khi hàm chạy: Đèn bật sáng, biến được tạo ra.
Khi hàm chạy xong (return): Đèn tắt, phòng dọn sạch, tất cả biến bị xóa khỏi bộ nhớ. -> Đây là cơ chế bình thường.
Nhưng Closure thì khác:
Khi một hàm con được sinh ra trong hàm cha và được mang ra ngoài sử dụng, nó mang theo một "chiếc balo". Trong chiếc balo đó chứa tất cả các biến của hàm cha mà nó cần dùng. Dù hàm cha đã chạy xong, đã "tắt đèn", nhưng những biến đó vẫn sống mãi trong chiếc balo của hàm con.
Định nghĩa kỹ thuật:
Closure là sự kết hợp giữa một hàm và môi trường nơi nó được khai báo (Lexical Environment). Nó cho phép hàm truy cập vào scope của hàm cha ngay cả khi hàm cha đã thực thi xong.
Ví dụ kinh điển: Bộ đếm (Counter)
Đây là ví dụ dễ hiểu nhất để thấy Closure hoạt động.
function createCounter() {
let count = 0; // Biến này nằm trong hàm cha (Local scope)
// Hàm con được return ra ngoài
return function() {
count++; // Hàm con dùng biến count của cha
console.log("Current count:", count);
};
}
// 1. Chạy hàm cha. Lúc này createCounter chạy xong,
// Lẽ ra biến `count` phải bị hủy.
const myCounter = createCounter();
// 2. NHƯNG KHÔNG! myCounter vẫn nhớ `count` nhờ Closure.
myCounter(); // Output: Current count: 1
myCounter(); // Output: Current count: 2
myCounter(); // Output: Current count: 3
// 3. Tạo một counter mới -> Nó có một môi trường (balo) mới riêng biệt
const anotherCounter = createCounter();
anotherCounter(); // Output: Current count: 1 (Không liên quan đến cái trên)
Tại sao quan trọng?
Nếu bạn khai báo let count = 0 ở biến toàn cục (Global), bất kỳ ai cũng có thể sửa nó thành count = 1000. Nhưng với Closure, biến count được bảo vệ tuyệt đối bên trong hàm, chỉ có hàm con mới sửa được nó.
Function Factory (Nhà máy tạo hàm)
Closure giúp bạn viết code tổng quát (Generic) và dùng nó để tạo ra các hàm cụ thể. Kỹ thuật này gọi là Currying hoặc Partial Application.
Ví dụ: Tạo bộ log thông minh cho các môi trường khác nhau.
function createLogger(prefix: string) {
return function(message: string) {
console.log(`[${prefix}] ${message}`);
};
}
// Tạo ra các hàm log chuyên biệt
const infoLog = createLogger("INFO");
const errorLog = createLogger("ERROR");
const playWrightLog = createLogger("PLAYWRIGHT");
// Sử dụng
infoLog("Server started"); // Output: [INFO] Server started
errorLog("Database failed"); // Output: [ERROR] Database failed
playWrightLog("Clicking button");// Output: [PLAYWRIGHT] Clicking button
Các mảnh ghép cú pháp (Syntax Blocks)
Chúng ta sẽ xây dựng một ví dụ đời thường: "Hệ thống lấy thông báo (Notification System)". Hệ thống này vừa chứa các câu thông báo tĩnh (cố định), vừa chứa các thông báo động (cần tham số).
Mảnh ghép 1: Record kết hợp Union Type (Cấu trúc dữ liệu hỗn hợp)
Bình thường, Record chỉ chứa 1 kiểu dữ liệu. Nhưng thực tế ta cần lưu trữ lẫn lộn: vừa là chuỗi (String), vừa là hàm (Function).
Cú pháp:
// Key luôn là string
// Value là: HOẶC là string, HOẶC là một Function nhận vào string trả về string
type SmartMessage = Record<string, string | ((name: string) => string)>;
Ví dụ thực tế:
const database: SmartMessage = {
// Dạng 1: Giá trị tĩnh (String)
success: "Thao tác thành công!",
// Dạng 2: Giá trị động (Function)
welcome: (name) => `Xin chào ${name}, chúc ngày mới tốt lành!`
};
Mảnh ghép 2: typeof (Cảnh sát giao thông tại Runtime)
Vì biến database ở trên chứa lẫn lộn string và function, nên khi lấy ra dùng, code sẽ không biết phải xử lý thế nào. Ta cần typeof để phân luồng.
Cú pháp:
const value = database['welcome']; // TypeScript chưa biết đây là String hay Function
if (typeof value === 'function') {
// A ha! Đây là hàm, mình phải gọi nó ()
console.log(value("Tùng"));
} else {
// Ồ, đây chỉ là chuỗi bình thường, in ra thôi
console.log(value);
}
Mảnh ghép 3: Generic <T> kết hợp keyof T (Siêu liên kết)
Đây là phần khó nhất nhưng quan trọng nhất. Làm sao để viết một hàm mà khi người dùng gõ get(...), nó tự động hiện ra danh sách key có trong object dữ liệu?
Công thức: Input là T --> Output chỉ nhận keyof T.
Cú pháp:
// <T> là cái khuôn, nó sẽ co giãn tùy theo dữ liệu data truyền vào
function createGetter<T>(data: T) {
// Hàm trả về chỉ chấp nhận tham số 'key' nằm trong danh sách key của T
return (key: keyof T) => {
return data[key];
};
}
Tại sao cần cái này?
Nếu không có nó, bạn có thể gõ get('cai_gi_do_sai_bet') mà code không báo lỗi. Có nó, gõ sai là đỏ lòm ngay.
Mảnh ghép 4: Closure (Chiếc balo ký ức)
Làm sao để hàm get bên trong truy cập được dữ liệu data đã truyền vào hàm cha createGetter từ đời nào rồi? Đó là nhờ Closure.
Cú pháp:
const makeWallet = (money: number) => {
// Hàm con này được mang ra ngoài dùng, nhưng nó vẫn "nhớ" biến money của hàm cha
return (cost: number) => {
money = money - cost;
console.log(`Còn lại: ${money}`);
}
}
TỔNG HỢP: Ghép 4 mảnh thành "Combo Hủy Diệt"
Bây giờ, hãy ghép 4 mảnh trên thành một hàm xử lý thông báo hoàn chỉnh
Bước 1: Chuẩn bị dữ liệu (Data Source)
Dùng as const để "đóng băng" dữ liệu, giúp TypeScript hiểu chính xác từng key.
const MESSAGES = {
error404: "Không tìm thấy trang này",
hello: (name: string) => `Hi ${name}, welcome back!`,
diskSpace: (percent: number) => `Cảnh báo! Ổ cứng đã đầy ${percent}%`
} as const;
Bước 2: Viết hàm Factory (Kết hợp Generic + Closure + typeof)
// 1. GENERIC <T>: Chấp nhận object đầu vào bất kỳ (miễn là đúng format Record hỗn hợp)
function createMessageReader<T extends Record<string, string | ((...args: any[]) => string)>>(
source: T // Mảnh ghép 1: Input
) {
// 2. CLOSURE: Trả về hàm con, hàm này "nhớ" biến source
// 3. KEYOF: Hàm con bắt buộc nhập đúng key có trong source
return (key: keyof T, ...args: any[]) => {
const value = source[key];
// 4. TYPEOF: Kiểm tra xem value lấy ra là String hay Function
if (typeof value === 'function') {
// @ts-ignore: Bỏ qua check type chi tiết cho ví dụ đơn giản
return value(...args);
}
return value; // Nếu là string thì trả về luôn
};
}
Bước 3: Hưởng thụ thành quả
// Khởi tạo máy đọc tin nhắn
const read = createMessageReader(MESSAGES);
// ✅ Case 1: Gọi key tĩnh (String) -> Output: "Không tìm thấy trang này"
console.log(read('error404'));
// ✅ Case 2: Gọi key động (Function) -> Output: "Hi Huy, welcome back!"
console.log(read('hello', 'Huy'));
// ❌ Case 3: Gõ sai key -> Lỗi ngay lập tức (nhờ keyof T)
// read('goodbye'); // Error: Argument of type '"goodbye"' is not assignable...
Kết luận phần Syntax Blocks:
Logic của đoạn code Playwright phức tạp kia thực chất chỉ là sự kết hợp của việc:
Định nghĩa Data hỗn hợp (Record).
Viết hàm nhận Data (Generic).
Trả về hàm con (Closure) chỉ nhận đúng Key (keyof).
Bên trong hàm con thì check kiểu (typeof) để xử lý.
The Ultimate Locator Factory Pattern
Phân tích "Vũ khí" (Thành phần Code)
Trước khi đi vào cách dùng, hãy "mổ xẻ" đoạn code. Mỗi từ khóa đều có một sứ mệnh quan trọng:
// 1. GENERIC <T>: Giúp hàm chấp nhận bất kỳ Object Locator nào, không cố định.
// 2. RECORD: Ràng buộc cấu trúc đầu vào (Key là string, Value là string HOẶC function).
protected createLocatorGetter<T extends Record<string, string | ((page: Page) => Locator)>>(
locatorMap: T // 3. INPUT: Cái bản đồ chứa locator
): (locatorName: keyof T) => Locator { // 4. RETURN TYPE: Trả về 1 hàm, hàm này chỉ nhận key của T
// 5. CLOSURE: Hàm con được return, "nhớ" biến locatorMap và this.page
return (locatorName: keyof T): Locator => {
const locatorDef = locatorMap[locatorName];
// 6. TYPE GUARD (typeof runtime): Kiểm tra xem value là String selector hay Function
if (typeof locatorDef === 'function') {
return locatorDef(this.page); // Nếu là function thì gọi nó
}
return this.page.locator(locatorDef); // Nếu là string thì dùng page.locator()
};
}
Tại sao đây là "Combo hủy diệt"?
Tính linh hoạt (Record & Union Type): Bạn có thể định nghĩa locator dạng chuỗi tĩnh (#id) hoặc dạng hàm động (page.getByRole...).
Gợi ý code tuyệt đối (Generic & keyof): Khi bạn gõ hàm trả về, IDE sẽ gợi ý chính xác từng key trong locatorMap. Gõ sai 1 chữ là lỗi ngay.
Gọn gàng (Closure): Logic xử lý typeof (string hay function) được giấu kín bên trong. Người dùng ở ngoài chỉ cần gọi get('key') là xong.
Triển khai thực tế: Xây dựng BasePage
Để combo này hoạt động, chúng ta cần đặt nó vào một class cha (BasePage) để các trang khác kế thừa.
Setup Class Cha (BasePage)
import { Page, Locator } from '@playwright/test';
// Định nghĩa kiểu dữ liệu cho Map locator (để code gọn hơn)
type LocatorMap = Record<string, string | ((page: Page) => Locator)>;
export class BasePage {
constructor(protected page: Page) {}
// "Hàm thần thánh" của bạn nằm ở đây
protected createLocatorGetter<T extends LocatorMap>(locatorMap: T) {
return (name: keyof T): Locator => {
const def = locatorMap[name];
if (typeof def === 'function') {
return def(this.page);
}
return this.page.locator(def);
};
}
}
Ứng dụng: Xây dựng LoginPage (Nơi phép màu xảy ra)
Ở đây chúng ta sẽ dùng as const để khóa chặt object locator, giúp TypeScript suy luận chính xác từng key (Literal Types).
export class LoginPage extends BasePage {
// 1. Định nghĩa MAP các locator
// Dùng as const để biến nó thành Readonly và giữ nguyên giá trị chuỗi
private static readonly LOCATORS = {
usernameInput: '#username', // Dạng String đơn giản
passwordInput: "input[name='password']", // Dạng String
submitButton: (page: Page) => page.getByRole('button', { name: 'Login' }), // Dạng Function (Complex locator)
errorMessage: (page: Page) => page.locator('.error-msg').first() // Dạng Function
} as const;
// 2. Khởi tạo "Getter" bằng Closure
// this.get bây giờ là một hàm, nó "nhớ" cái LOCATORS ở trên
private get = this.createLocatorGetter(LoginPage.LOCATORS);
constructor(page: Page) {
super(page);
}
// 3. Viết các hành động (Actions)
async login(user: string, pass: string) {
// KHI GÕ this.get('...') -> IDE SẼ TỰ ĐỘNG GỢI Ý:
// "usernameInput" | "passwordInput" | "submitButton" | "errorMessage"
await this.get('usernameInput').fill(user);
await this.get('passwordInput').fill(pass);
await this.get('submitButton').click();
}
async getErrorText() {
return await this.get('errorMessage').textContent();
}
}
Giải thích luồng chạy (Flow)
Điều gì thực sự diễn ra khi bạn gọi this.get('submitButton')?
Gọi hàm: this.get('submitButton') được kích hoạt.
Closure kích hoạt: Hàm get (là hàm con trong createLocatorGetter) lục lại "ký ức" để tìm biến locatorMap (chính là LoginPage.LOCATORS).
Truy xuất: Nó lấy giá trị của key submitButton.
Giá trị là: (page: Page) => page.getByRole(...).
Kiểm tra (Type Guard): typeof value === 'function'. -> ĐÚNG.
Thực thi: Nó gọi hàm đó, truyền this.page hiện tại vào.
Kết quả: Trả về một Locator chuẩn của Playwright để bạn .click().
Tại sao cách này tốt hơn cách truyền thống?
Cách truyền thống (Khai báo mỏi tay):
class OldLoginPage {
// Phải khai báo từng property, lặp đi lặp lại chữ 'Locator'
readonly username: Locator;
readonly submitBtn: Locator;
constructor(page: Page) {
// Phải khởi tạo từng cái một
this.username = page.locator('#user');
this.submitBtn = page.getByRole('button');
}
}
Cách dùng Combo Hủy Diệt:
Tập trung dữ liệu: Toàn bộ selector nằm gọn trong 1 object LOCATORS. Dễ sửa, dễ quản lý.
Không lặp code: Không cần khai báo readonly xyz: Locator hàng chục lần.
Intellisense cực mạnh: Nhờ keyof T và as const, bạn không bao giờ gọi sai tên locator.
Support Dynamic Locator: Hỗ trợ cả getByRole, getByText... chứ không chỉ mỗi string selector.
Khái niệm: "Bản thiết kế" của một hàm
Trong TypeScript, biến không chỉ lưu số (number) hay chuỗi (string), mà nó còn có thể lưu cả một hàm (function).
Khi chúng ta viết:
type MyFunction = () => Locator;
Đây KHÔNG PHẢI là code chạy (implementation).
Đây là BẢN THIẾT KẾ (Type Definition) quy định hình dáng của hàm.
Công thức đọc:
( Danh sách tham số đầu vào ) => Kiểu dữ liệu trả về (Output)
Các cấp độ ví dụ từ dễ đến khó
Cấp độ 1: Hàm không cần tham số (Basic)
Đây là cú pháp: () => string.
Ý nghĩa: "Tôi là một cái biến, tôi chứa một hàm. Hàm này không cần đầu vào, nhưng khi chạy xong chắc chắn sẽ nhả ra một string."
// 1. Định nghĩa Type (Bản thiết kế)
// Hàm này không nhận gì cả, và bắt buộc trả về string
type GreetingFunc = () => string;
// 2. Áp dụng vào biến thực tế
const getHello: GreetingFunc = () => {
return "Hello World"; // ✅ Đúng, trả về string
};
const getNumber: GreetingFunc = () => {
return 100; // ❌ LỖI: Type quy định trả về string, ông lại trả về number
};
Cấp độ 2: Hàm có tham số đầu vào
Ví dụ: (a: number, b: number) => number.
Ý nghĩa: "Hàm này cần nạp vào 2 số, và sẽ trả ra 1 số."
// Định nghĩa kiểu cho một hàm tính toán
type MathFunc = (a: number, b: number) => number;
// Gán hàm cộng
const add: MathFunc = (x, y) => x + y;
// Gán hàm nhân
const multiply: MathFunc = (x, y) => x * y;
Áp dụng vào bài toán Playwright
Quay lại đoạn code "hủy diệt" của chúng ta:
Record<string, string | ((page: Page) => Locator)>
Hãy tách phần (page: Page) => Locator ra để giải thích.
Vị trí: Nó nằm ở phần Value của Record.
Ý nghĩa: Giá trị này là một hàm.
Input: Bắt buộc phải nhận vào một biến page (kiểu Page của Playwright).
Output: Bắt buộc phải trả về một Locator.
Tại sao lại cần cú pháp này?
Bởi vì có những Locator rất phức tạp, không thể viết bằng chuỗi String đơn giản (#id). Chúng ta cần quyền năng của biến page để tìm kiếm nâng cao (như page.getByRole, page.getByText).
Ví dụ so sánh trực quan:
// Định nghĩa Type
type LocatorFinder = (page: Page) => Locator;
// 1. Hàm tìm nút Submit (Phức tạp, cần dùng page)
const findSubmitBtn: LocatorFinder = (p) => {
// p chính là 'page' được truyền vào
return p.getByRole('button', { name: 'Submit' });
};
// 2. Hàm tìm nút Login
const findLoginBtn: LocatorFinder = (page) => {
return page.locator('#login-btn');
};
Tóm tắt nhanh
Khi nhìn thấy dấu => trong TypeScript:
Nếu nó nằm sau dấu hai chấm : hoặc trong type ... =, nó là Luật lệ (Type Definition).
Ví dụ: let myFunc: () => void; (Luật: Biến này phải là hàm, không trả về gì).
Nếu nó nằm sau dấu bằng = trong code logic, nó là Hàm thực thi (Arrow Function).
Ví dụ: const myFunc = () => { console.log("Hi") }; (Hành động: In ra chữ Hi).
Bảng so sánh:
|
Cú pháp |
Loại |
Ý nghĩa |
|
() => Locator |
Type |
Đây là kiểu dữ liệu: Một hàm trả về Locator. |
|
() => page.locator(...) |
Value |
Đây là giá trị: Một hàm đang thực hiện việc tìm Locator. |
Phần 1: XỬ LÝ BẢNG ĐỘNG (DYNAMIC TABLE) & SMART MAPPING
Trước khi nói về giải pháp, chúng ta phải hiểu tại sao cách làm truyền thống lại "chết" nhanh chóng trong các dự án thực tế.
Cách làm cũ trông như thế nào?
Trong cách làm cũ (Legacy), Tester thường đếm tay vị trí cột trên màn hình và code cứng (hardcode) con số đó vào script.
// ❌ CÁCH CŨ: Dựa vào số thứ tự (index)
// Giả sử: Cột 1 là Checkbox, Cột 2 là Company, Cột 3 là Phone
const companyName = await row.locator('td:nth-child(2)').textContent();
const phone = await row.locator('td:nth-child(3)').textContent();
Bất cập chí mạng (Why it fails?)
Giả sử ngày mai, khách hàng yêu cầu Dev chèn thêm cột "Status" vào giữa Company và Phone.
Lúc này trên UI:
Checkbox
Company
Status (MỚI)
Phone (Bị đẩy xuống vị trí 4)
Hậu quả trong Code:
Dòng lệnh lấy phone (td:nth-child(3)) bây giờ sẽ lấy nhầm dữ liệu của cột Status.
Kết quả: Test fail, hoặc tệ hơn là Test pass nhưng dữ liệu sai (False Positive).
Bảo trì: Bạn phải đi tìm tất cả các file test, sửa số 3 thành số 4. Nếu có 100 test case dùng bảng này, bạn sửa 100 lần.
Kết luận: Dựa vào index cứng là xây nhà trên cát. UI thay đổi, Test sụp đổ.
GIẢI PHÁP - SMART COLUMN MAP (BẢN ĐỒ CỘT THÔNG MINH)
Thay vì nhớ "số nhà" (index), chúng ta dạy cho Automation Robot cách đọc "biển tên đường" (Header).
Nguyên lý hoạt động
Robot đọc toàn bộ Header của bảng.
Robot ghi nhớ: "À, cột 'Company Name' đang nằm ở vị trí số 2".
Khi Test Script hỏi: "Lấy cho tôi cột Company", Robot tra cứu trí nhớ và tự tìm đến vị trí số 2.
Phân tích chi tiết từng Method trong Code của bạn
Chúng ta sẽ đi sâu vào file TableColumnHelpers.ts và CRMCustomersPage.ts để xem giải pháp này được hiện thực hóa như thế nào.
Method createColumnMapSimple: Trái tim của giải pháp
Đây là method nằm trong Helper, chịu trách nhiệm quét Header và lập bản đồ.
Phân tích code:
export async function createColumnMapSimple(headers: Locator): Promise<ColumnMap> {
const count = await headers.count();
const map: ColumnMap = {};
for (let index = 0; index < count; index++) {
// BƯỚC 1: Đọc và làm sạch text
// Ví dụ raw: " Company Name " -> clean: "Company Name"
const rawText = await headers.nth(index).innerText();
const clean = cleanHeaderText(rawText);
const info = { index, text: clean };
// BƯỚC 2: Tạo nhiều KEY để map linh hoạt (QUAN TRỌNG)
// Key CamelCase (Dành cho Dev): "Company Name" -> "companyName"
// Giúp code test sạch đẹp: getColumnValues('companyName')
const camelKey = toCamelCase(clean);
if (camelKey) map[camelKey] = info;
// Key Lowercase (Dành cho sự đơn giản): "Company Name" -> "company name"
// Giúp dễ match text: getColumnValues('company name')
const lowerKey = clean.toLowerCase();
if (lowerKey) map[lowerKey] = info;
// Key Index (Fallback): "column2"
map[`column${index + 1}`] = info;
}
return map;
}
✅ Tác dụng:
Dù Dev có đổi vị trí cột Company Name xuống cuối bảng, vòng lặp này sẽ chạy lại và cập nhật index mới. Code test gọi map['companyName'] vẫn chạy đúng mà không cần sửa dòng nào.
Method ensureColumnMapCache: Tối ưu hiệu năng (Performance)
Đọc DOM (giao diện) rất chậm. Nếu bảng có 20 cột, mỗi lần tìm dòng lại đọc lại 20 cái header thì test sẽ chạy như rùa. Giải pháp là Caching trong POM.
Phân tích code:
// (Trong CRMCustomersPage.ts)
private columnMapCache: ColumnMap | null = null; // Biến lưu trữ tạm
private async ensureColumnMapCache(): Promise<ColumnMap> {
// Nếu chưa có bản đồ (Cache = null)
if (!this.columnMapCache) {
await this.waitForTableReady();
// Robot đi quét map lần đầu tiên và lưu vào túi (Cache)
this.columnMapCache = await createColumnMapSimple(this.getLocator('tableHeaders'));
}
// Những lần sau, cứ lấy bản đồ trong túi ra dùng, không cần quét lại
return this.columnMapCache;
}
✅ Tác dụng:
Tăng tốc độ test lên gấp nhiều lần khi thao tác nhiều row trên cùng một bảng. Chỉ tốn công đọc header 1 lần duy nhất.
Bản chất của Helper: "Stateless" (Không lưu trạng thái)
Các hàm trong TableColumnHelpers.ts như getColumnValuesSimple, findRowByFiltersSimple được thiết kế theo dạng Pure Function.
Đặc điểm: Nó nhận đầu vào (Input) Trả kết quả (Output).
Vấn đề: Nó KHÔNG CÓ BỘ NHỚ. Nó không biết lần trước nó đã chạy chưa, hay đã tính toán cái gì rồi.
Hãy xem kỹ hàm getColumnInfoSimple (Hàm con được gọi bên trong mọi hàm lấy dữ liệu):
export async function getColumnInfoSimple(
headersLocator: Locator,
columnKey: string,
columnMapCache?: ColumnMap | null // 👈 Tham số này là optional (có thể null)
): Promise<{ info: ColumnInfo; columnMap: ColumnMap }> {
// Bước 1: Kiểm tra xem người dùng có truyền cái Map cũ vào không?
let map: ColumnMap | null = columnMapCache || null;
// ⚠️ ĐIỂM QUAN TRỌNG NHẤT Ở ĐÂY ⚠️
// Nếu không có Map truyền vào -> NÓ SẼ TẠO LẠI TỪ ĐẦU
if (!map) {
map = await createColumnMapSimple(headersLocator); // <--- Đọc DOM tại đây
}
// ... (đoạn sau tìm column trong map)
}
Kịch bản: Chuyện gì xảy ra nếu KHÔNG CÓ Cache?
Giả sử trong Test Script bạn cần lấy dữ liệu của 3 cột để verify. Code sẽ chạy như sau nếu POM không lưu cache:
Lệnh 1: await customersPage.getColumnValues('companyName')
Gọi getColumnValuesSimple với columnMapCache = undefined.
Helper thấy map rỗng -> Truy cập Browser -> Đọc 10 thẻ <th> -> Tạo Map lần 1.
Tìm thấy 'companyName' ở index 2. Trả về dữ liệu.
Kết thúc hàm, biến map vừa tạo bị hủy (vì không ai lưu nó lại).
Lệnh 2: await customersPage.getColumnValues('phoneNumber')
Gọi getColumnValuesSimple với columnMapCache = undefined.
Helper thấy map rỗng (lại nữa!) -> Truy cập Browser -> Đọc 10 thẻ <th> -> Tạo Map lần 2.
Tìm thấy 'phoneNumber' ở index 3. Trả về dữ liệu.
Lại hủy biến map.
Lệnh 3: await customersPage.getColumnValues('email')
Lại thấy rỗng - > Truy cập Browser -> Đọc Header lần 3...
Kết luận: Nếu bảng có 20 cột và bạn cần check 5 cột, bạn sẽ bắt Playwright đọc đi đọc lại cái Header đó 5 lần. Việc giao tiếp giữa Code (Node.js) và Browser (Chrome) qua giao thức CDP rất tốn thời gian (Network I/O).
Giải pháp: Caching tại POM (Stateful)
Vì Helper là "người làm thuê không có bộ nhớ", nên ông chủ POM (CRMCustomersPage) phải là người giữ cuốn sổ tay (Cache).
Hãy xem cách POM khắc phục vấn đề này:
// Trích từ CRMCustomersPage.ts
// 1. Biến lưu trữ (Cuốn sổ tay)
private columnMapCache: ColumnMap | null = null;
// 2. Hàm đảm bảo luôn có Map
private async ensureColumnMapCache(): Promise<ColumnMap> {
// Nếu sổ tay chưa có gì (Lần đầu tiên gọi)
if (!this.columnMapCache) {
await this.waitForTableReady();
// Đọc DOM 1 lần duy nhất và ghi vào sổ
this.columnMapCache = await createColumnMapSimple(this.getLocator('tableHeaders'));
}
// Nếu sổ đã có dữ liệu -> Trả về ngay lập tức, KHÔNG ĐỌC DOM NỮA
return this.columnMapCache;
}
// 3. Khi dùng
async getColumnValues(columnKey: string) {
// Lấy bản đồ từ sổ tay ra trước
const map = await this.ensureColumnMapCache();
// Truyền cái bản đồ đó xuống cho Helper
return getColumnValuesSimple(..., map);
}
So sánh hiệu năng
|
Hoạt động |
Không dùng Cache (Helper thuần) |
Dùng Cache (POM Wrapper) |
|
Lần gọi 1 |
Đọc DOM (Chậm) |
Đọc DOM (Chậm) |
|
Lần gọi 2 |
Đọc DOM (Chậm) |
Đọc biến RAM (Siêu nhanh) |
|
Lần gọi 3 |
Đọc DOM (Chậm) |
Đọc biến RAM (Siêu nhanh) |
|
Độ trễ |
Phụ thuộc vào tốc độ mạng/browser |
Gần như tức thì (Instant) |
Tóm lại
Chính vì thế, kỹ thuật Caching trong POM (ensureColumnMapCache) không phải là code dư thừa, mà là bắt buộc để biến một Framework "chạy được" thành một Framework "chạy nhanh và chuyên nghiệp".
🛠 Sơ đồ luồng dữ liệu (Data Flow Visualization)
Hãy tưởng tượng bạn đang đi siêu thị (là hàm buildRowDataSimple) mua 3 món hàng (3 cột). Bạn cần một bản đồ siêu thị (columnMap) để biết hàng nằm ở đâu.
🔍 Phân tích chi tiết từng mắt xích
buildRowDataSimple: Người Nhạc Trưởng (Orchestrator)
Hàm này chịu trách nhiệm lấy dữ liệu cho 1 dòng. Nó chạy vòng lặp qua từng cột cần lấy.
export async function buildRowDataSimple(..., columnMapCache?: ColumnMap | null) {
const rowData: Record<string, string> = {};
// 1. Khởi tạo biến giữ bản đồ. Ban đầu nó là cái cache cũ (có thể null)
let currentColumnMap = columnMapCache;
// 2. Vòng lặp qua từng cột (Ví dụ: ['name', 'email'])
for (const key of columnKeys) {
// GỌI HÀM CON: "Này, tìm cho tao vị trí cột 'name',
// cầm theo cái bản đồ hiện tại (currentColumnMap) mà dùng nhé"
const result = await getColumnInfoSimple(..., key, currentColumnMap);
// ⚠️ ĐIỂM QUAN TRỌNG NHẤT: CẬP NHẬT BẢN ĐỒ ⚠️
// Hàm con trả về { info, columnMap }.
// Nếu hàm con vừa phải vẽ lại bản đồ mới, nó sẽ trả ra ở đây.
// Ta phải lưu lại để vòng lặp sau (cột 'email') dùng tiếp, đỡ phải vẽ lại.
currentColumnMap = result.columnMap;
// ... (Lấy cell text) ...
}
// 3. Trả về cả dữ liệu dòng VÀ cái bản đồ mới nhất
return { rowData, columnMap: currentColumnMap! };
}
Tại sao return như vậy?
Vì buildRowDataSimple thường được gọi bởi getTableDataSimple (lấy toàn bộ bảng).
Nếu dòng 1 đã tốn công vẽ bản đồ, nó phải trả cái bản đồ đó ra ngoài để dòng 2 dùng tiếp.
Nếu chỉ return rowData, công sức vẽ bản đồ sẽ bị mất, dòng 2 lại phải vẽ lại $\rightarrow$ Chậm.
getColumnInfoSimple: Người Dò Đường (Map Reader/Builder)
Hàm này nhận lệnh tìm vị trí cột. Nó thông minh ở chỗ: có bản đồ rồi thì dùng, chưa có thì đi vẽ, vẽ xong thì phải trả lại bản đồ cho người gọi.
export async function getColumnInfoSimple(..., columnMapCache?: ColumnMap | null) {
// 1. Kiểm tra xem người gọi có đưa bản đồ không?
let map = columnMapCache || null;
// 2. Nếu chưa có bản đồ -> PHẢI ĐI VẼ (Tốn thời gian đọc DOM)
if (!map) {
map = await createColumnMapSimple(headersLocator);
}
// 3. Tìm thông tin cột
let info = map[columnKey];
// (Logic retry nếu map cũ bị lỗi thời...)
// 4. Return
// Trả về info (để lấy dữ liệu) VÀ trả về map (để lưu cache)
return { info, columnMap: map };
}
Tại sao return { info, columnMap }?
info: Là mục đích chính (Vị trí cột ở đâu? Index mấy?).
columnMap: Là "tác dụng phụ" có giá trị. Vì hàm này có thể vừa tốn công tạo ra map mới (ở bước 2), nó phải trả cái map này ngược lên cho buildRowDataSimple để thằng cha cập nhật biến currentColumnMap.
getCellTextSimple: Công nhân bốc vác (Worker)
Hàm này đơn giản nhất, chỉ làm việc cụ thể, không quan tâm đến bản đồ.
export async function getCellTextSimple(...) {
// ... Logic clean text ...
return text; // Chỉ trả về string
}
Tại sao chỉ return string?
Vì hàm này không tạo ra tài nguyên gì mới cần tái sử dụng. Nó chỉ tiêu thụ và trả kết quả cuối cùng.
🎬 Kịch bản chạy thực tế (Scenario Walkthrough)
Giả sử bạn gọi buildRowDataSimple để lấy 2 cột: ['Company', 'Email'] và lúc đầu chưa có cache (columnMapCache = null).
Vòng lặp 1: Cột 'Company'
buildRowDataSimple gọi getColumnInfoSimple(..., 'Company', null).
getColumnInfoSimple thấy map là null -> Đọc DOM, tạo Map Mới.
getColumnInfoSimple trả về { info: {index: 1}, columnMap: Map_Mới }.
buildRowDataSimple cập nhật: currentColumnMap = Map_Mới.
Lấy text cột Company.
Vòng lặp 2: Cột 'Email'
buildRowDataSimple gọi getColumnInfoSimple(..., 'Email', Map_Mới).
getColumnInfoSimple thấy có Map_Mới rồi -> Dùng luôn, KHÔNG Đọc DOM nữa.
Trả về { info: {index: 2}, columnMap: Map_Mới }.
Lấy text cột Email.
Kết thúc hàm:
Trả về { rowData: {Company: 'A', Email: 'B'}, columnMap: Map_Mới }.
Cái Map_Mới này sẽ được hàm cha (ví dụ getTableDataSimple) chuyền tiếp cho Dòng thứ 2.
💡 Tổng kết
Cấu trúc return { data, map } này là biểu hiện của kỹ thuật "Propagating State" (Lan truyền trạng thái) trong lập trình hàm (Functional Programming).
Vì Helper không có biến toàn cục để lưu map.
Nên map phải được chuyền tay từ hàm này sang hàm khác như một quả bóng.
Ai cầm bóng thì dùng, ai tạo ra bóng mới thì phải chuyền quả bóng mới đó đi tiếp.
Đó là lý do tại sao code nhìn có vẻ rườm rà ở đoạn return, nhưng lại đảm bảo Hiệu năng tối đa (chỉ đọc DOM 1 lần duy nhất cho cả bảng).
KỸ THUẬT "PASSING DROP-DOWN CLEANING" (Dependency Injection)
Đây là phần tinh vi nhất: Làm sao Helper (dùng chung) hiểu được logic xử lý dữ liệu đặc thù của từng trang (POM)?
Ví dụ: Cột "Company" trên trang Customers bị dính chữ "View" và Icon, nhưng cột "Company" trên trang Orders thì lại sạch. Helper không thể đoán mò được.
Giải pháp: POM sẽ "gói" logic xử lý vào một hàm, và "thả" (pass down) hàm đó xuống cho Helper.
Tại POM: Định nghĩa quy tắc làm sạch (columnCleaners)
// (Trong CRMCustomersPage.ts)
private get columnCleaners(): Record<string, ColumnTextCleaner> {
return {
// Định nghĩa riêng cho cột 'company'
company: async (cell: Locator) => {
// Logic: Tìm thẻ <a>, lấy text, bỏ qua icon/nút View
const linkText = (await cell.locator('a').first().textContent())?.trim();
return linkText || '';
}
// Các cột khác không khai báo sẽ dùng logic mặc định
};
}
Tại Method gọi: Truyền quy tắc xuống dưới
// (Trong CRMCustomersPage.ts)
async getColumnValues(columnKey: string) {
return getColumnValuesSimple(
headers,
rows,
columnKey,
this.columnCleaners, // 👈 KEY: Ném bộ quy tắc xuống Helper
columnMap
);
}
Tại Helper: Thực thi quy tắc (getCellTextSimple)
Helper đóng vai trò là "người thực thi mù" (blind executor). Nó nhận công cụ từ POM và dùng nó.
// (Trong TableColumnHelpers.SIMPLE.ts)
export async function getCellTextSimple(
cell: Locator,
columnKey: string,
columnCleaners?: Record<string, ColumnTextCleaner> // Nhận bộ quy tắc
) {
// 1. Kiểm tra: Có quy tắc nào cho cột này không?
const cleaner = columnCleaners?.[columnKey];
// 2. Nếu CÓ: Dùng quy tắc đó để xử lý cell
if (cleaner) {
return cleaner(cell); // Chạy logic của POM
}
// 3. Nếu KHÔNG: Dùng mặc định (lấy textContent)
return (await cell.textContent() || '').trim();
}
✅ Tác dụng:
Decoupling (Tách biệt): Helper TableColumnHelpers hoàn toàn trong sạch, không chứa logic nghiệp vụ (không biết "View" hay "Edit" button là gì).
Flexibility (Linh hoạt): Mỗi trang (Customer, Product, User) tự định nghĩa cách clean data của riêng mình mà vẫn dùng chung 1 bộ khung Helper.
GIẢI PHẪU TABLE COLUMN HELPERS
Chúng ta sẽ đi qua từng nhóm hàm, từ những viên gạch nhỏ nhất cho đến những cỗ máy phức tạp nhất.
NHÓM 1: CÁC TRỢ THỦ LOGIC (INTERNAL HELPERS)
Nhóm này xử lý logic nghiệp vụ, tính toán, so sánh, không tương tác trực tiếp với trình duyệt.
textMatches: Bộ não so sánh đa hình
Hàm này giúp việc so sánh text trở nên cực kỳ linh hoạt (String, Regex, hoặc Function).
Code trích xuất:
const textMatches = (value: string, matcher: TextMatcher): boolean => {
// Case 1: String -> So sánh chứa (includes)
if (typeof matcher === 'string') {
return value.includes(matcher);
}
// Case 2: Regex -> So sánh mẫu (test)
if (matcher instanceof RegExp) {
return matcher.test(value);
}
// Case 3: Function -> Logic tùy biến
return matcher(value);
}
Phân tích:
Hàm kiểm tra typeof và instanceof để biết matcher là loại gì.
Tác dụng: Bạn có thể tìm dòng bằng textMatches("Order 123", "Order") (True) hoặc textMatches("Order 123", /\d+/) (True).
resolveColumnKeysForRowData: Bộ máy quyết định ưu tiên
Hàm này quyết định xem sẽ lấy dữ liệu của những cột nào sau khi tìm thấy dòng.
Code trích xuất:
const resolveColumnKeysForRowData = (
filters: Record<string, TextMatcher>,
columnKeys?: string[],
defaultColumnKeys?: string[]
): string[] => {
// Ưu tiên 1: Người dùng chỉ định rõ
if (columnKeys && columnKeys.length > 0) {
return columnKeys;
}
// Ưu tiên 2: Cấu hình mặc định (từ POM)
if (defaultColumnKeys && defaultColumnKeys.length > 0) {
return defaultColumnKeys;
}
// Ưu tiên 3: Lấy luôn các key dùng để filter
const keys = Object.keys(filters);
if (keys.length === 0) {
throw new Error('No column keys provided for row data extraction.');
}
return keys;
};
Phân tích:
Logic if... return tuần tự tạo ra cơ chế Priority.
Guard Clause: Dòng cuối cùng ném lỗi nếu không tìm được key nào, ngăn chặn code chạy tiếp mà không có dữ liệu đầu ra.
NHÓM 2: CƠ CHẾ LẤY DỮ LIỆU (DATA RETRIEVAL)
Nhóm này chịu trách nhiệm tương tác với DOM để lấy text.
getCellTextSimple: Công nhân làm sạch (Cleaning Injection)
Hàm này lấy text từ ô (cell) và áp dụng logic làm sạch nếu được cung cấp.
Code trích xuất:
export async function getCellTextSimple(
cell: Locator,
columnKey: string,
columnCleaners?: Record<string, ColumnTextCleaner>
): Promise<string> {
// 1. Tìm cleaner trong từ điển (Dictionary Lookup)
const cleaner = columnCleaners?.[columnKey];
// 2. Nếu có -> Gọi hàm cleaner (Dependency Injection)
if (cleaner) {
return cleaner(cell);
}
// 3. Nếu không -> Dùng mặc định
const text = await cell.textContent();
return (text || '').trim();
}
Phân tích:
Đây là nơi logic của POM (columnCleaners) được thực thi.
Helper không biết làm sạch như thế nào, nó chỉ biết gọi người biết làm.
getColumnInfoSimple: Người dò đường (The Bridge)
Cầu nối giữa Key (tên cột) và Index (vị trí).
Code trích xuất:
export async function getColumnInfoSimple(
headersLocator: Locator,
columnKey: string,
columnMapCache?: ColumnMap | null
): Promise<{ info: ColumnInfo; columnMap: ColumnMap }> {
// 1. Kiểm tra Cache
let map: ColumnMap | null = columnMapCache || null;
if (!map) {
// 2. Nếu chưa có -> Đọc DOM tạo mới
map = await createColumnMapSimple(headersLocator);
}
// 3. Tra cứu
let info = map[columnKey];
// ... (Logic retry nếu map lỗi thời) ...
// 4. Return cả info lẫn map (để update cache ngược lại)
return { info, columnMap: map };
}
Phân tích:
Hàm này đảm bảo createColumnMapSimple (hàm nặng nhất) chỉ chạy khi cần thiết.
Return { info, columnMap } để duy trì dòng chảy dữ liệu (Data Flow) cho hàm gọi nó.
buildRowDataSimple: Nhạc trưởng của Row
Xây dựng object dữ liệu cho 1 dòng cụ thể.
Code trích xuất:
export async function buildRowDataSimple(
headersLocator: Locator,
row: Locator,
columnKeys: string[],
columnCleaners?: Record<string, ColumnTextCleaner>,
columnMapCache?: ColumnMap | null
): Promise<{ rowData: Record<string, string>; columnMap: ColumnMap }> {
const rowData: Record<string, string> = {};
let currentColumnMap = columnMapCache;
for (const key of columnKeys) {
// 1. Lấy vị trí cột (và cập nhật map nếu cần)
const result = await getColumnInfoSimple(headersLocator, key, currentColumnMap);
currentColumnMap = result.columnMap; // Lưu map mới nhất
// 2. Xác định Cell (Lưu ý: CSS index bắt đầu từ 1 nên phải +1)
const cell = row.locator(`td:nth-child(${result.info.index + 1})`);
// 3. Lấy text
rowData[key] = await getCellTextSimple(cell, key, columnCleaners);
}
return { rowData, columnMap: currentColumnMap! };
}
Phân tích:
Biến currentColumnMap được cập nhật liên tục qua mỗi vòng lặp for, đảm bảo tối ưu hiệu năng (chỉ tạo map 1 lần ở vòng lặp đầu tiên nếu chưa có).
NHÓM 3: THÁM TỬ TÌM KIẾM (SEARCH ENGINES)
Nhóm này quét qua các dòng để tìm Locator.
findRowByColumnValueSimple: Tìm kiếm đơn giản
Tìm dòng dựa trên 1 cột duy nhất.
Code trích xuất:
export async function findRowByColumnValueSimple(...) {
// 1. Lấy index cột cần tìm
const result = await getColumnInfoSimple(headersLocator, columnKey, columnMapCache);
const count = await rowsLocator.count();
for (let i = 0; i < count; i++) {
const row = rowsLocator.nth(i);
// 2. Chọc đúng vào cột đó
const cell = row.locator(`td:nth-child(${result.info.index + 1})`);
// 3. Lấy text và So sánh
const text = await getCellTextSimple(cell, columnKey, columnCleaners);
if (textMatches(text, matcher)) { // Gọi hàm trợ thủ textMatches
return row; // Tìm thấy -> Trả về ngay
}
}
throw new Error(...); // Không thấy -> Báo lỗi
}
Phân tích:
Tối ưu: Chỉ tìm index cột 1 lần bên ngoài vòng lặp.
Sử dụng textMatches để hỗ trợ cả Regex và String.
findRowByFiltersSimple: Tìm kiếm nâng cao (AND Logic)
Tìm dòng thỏa mãn TẤT CẢ điều kiện.
Code trích xuất:
export async function findRowByFiltersSimple(...) {
const keys = requireFilters(filters);
const count = await rowsLocator.count();
// 1. Pre-calculation: Lấy hết index các cột cần check trước
const columnInfos: ColumnInfo[] = [];
for (const key of keys) {
const result = await getColumnInfoSimple(headersLocator, key, currentColumnMap);
currentColumnMap = result.columnMap;
columnInfos.push(result.info);
}
// 2. Duyệt từng dòng
for (let i = 0; i < count; i++) {
const row = rowsLocator.nth(i);
let matchedAll = true; // Cờ đánh dấu
// 3. Duyệt từng filter trong dòng đó
for (let j = 0; j < keys.length; j++) {
const info = columnInfos[j]; // Lấy index đã cache
const cell = row.locator(`td:nth-child(${info.index + 1})`);
const text = await getCellTextSimple(cell, keys[j], columnCleaners);
// Nếu MỘT điều kiện sai -> Dừng check dòng này ngay (Early Exit)
if (!textMatches(text, filters[keys[j]]!)) {
matchedAll = false;
break;
}
}
if (matchedAll) return row; // Thỏa mãn hết -> Trả về
}
throw new Error(...);
}
Phân tích:
Chiến thuật: Tách việc tìm index ra khỏi vòng lặp row (Pre-calculation) giúp tăng tốc đáng kể.
Early Exit: break ngay khi một filter sai giúp tiết kiệm thời gian, không cần check các filter còn lại của dòng đó.
NHÓM 4: COMBO TÌM & LẤY (SEARCH & EXTRACT)
Kết hợp tìm dòng và lấy dữ liệu trong 1 nốt nhạc.
getRowDataByFiltersSimple
Code trích xuất:
export async function getRowDataByFiltersSimple(...) {
// Bước 1: Tìm dòng (Trả về Locator)
const row = await findRowByFiltersSimple(headersLocator, rowsLocator, filters, ...);
// Bước 2: Quyết định lấy cột nào (Priority logic)
const resolvedKeys = resolveColumnKeysForRowData(filters, columnKeys, defaultColumnKeys);
// Bước 3: Lấy dữ liệu từ dòng đó
const result = await buildRowDataSimple(headersLocator, row, resolvedKeys, ...);
return result.rowData;
}
Phân tích:
Hàm này là một Facade Pattern (Mặt tiền), giúp che giấu sự phức tạp của việc kết hợp 3 hàm con (find..., resolve..., build...) lại với nhau.
NHÓM 5: XỬ LÝ PHÂN TRANG (PAGINATION)
Tìm kiếm dữ liệu nằm rải rác trên nhiều trang.
findRowByFiltersAcrossPagesSimple
Code trích xuất:
export async function findRowByFiltersAcrossPagesSimple(
...,
goToNextPage: () => Promise<boolean>, // Callback chuyển trang
...
): Promise<Locator> {
let currentPage = 1;
// Vòng lặp duyệt trang
while (currentPage <= maxPages) {
try {
// 1. Thử tìm ở trang hiện tại
const row = await findRowByFiltersSimple(...);
return row; // Tìm thấy -> Return luôn
} catch (error) {
// 2. Chỉ bắt lỗi "Unable to find row", lỗi khác thì throw tiếp
if (!(error instanceof Error) || !error.message.includes('Unable to find row')) {
throw error;
}
// 3. Không thấy -> Gọi Callback chuyển trang
const hasNext = await goToNextPage();
if (!hasNext) break; // Hết trang -> Thoát
currentPage++;
}
}
throw new Error(...); // Tìm hết các trang vẫn không thấy
}
Phân tích:
Sử dụng cơ chế try...catch để điều khiển luồng (Control Flow). Lỗi "Không tìm thấy" được coi là tín hiệu để chuyển trang.
Callback goToNextPage: Helper không cần biết nút Next nằm ở đâu, ID là gì. Nó chỉ ra lệnh "Next đi", việc thực hiện là của POM.
