NỘI DUNG BÀI HỌC
🛠️ Phần 1: Các "Viên gạch" Nền tảng (Utility Types)
🔑 Phần 2: Phép biến hình dữ liệu (Type Operations)
🏹 Phần 3: Giải mã Cú pháp Hàm (Function Types)
🧠 Phần 4: Tư duy Logic & Trừu tượng (Core Concepts)
🚀 Phần 5: THỰC CHIẾN - Combo "Hủy Diệt" (The Killer Pattern)
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. |
