NỘI DUNG BÀI HỌC
-
🚀 Bắt đầu: Hiểu rõ vấn đề của code đồng bộ và tại sao cần bất đồng bộ.
-
🤝 Nền tảng: Làm chủ Promise và phương thức
.then()
,.catch()
. -
✨ Nâng cao: Tinh giản code với cú pháp
async/await
hiện đại. -
🔒 Bảo vệ: Viết code bền vững, chống sập với
try...catch...finally
. -
🗂️ Tổ chức: Sắp xếp dự án chuyên nghiệp bằng Modules.
Phần 1: Vấn đề - Thế giới không chờ đợi ai
Hãy tưởng tượng bạn đang ở một quán cà phê.
- Lập trình Đồng bộ (Synchronous): 🚶♂️➡️☕️➡️🚶♀️
Bạn xếp hàng, gọi một ly cà phê, và bạn phải đứng chờ ngay tại quầy cho đến khi nhân viên pha xong và đưa cho bạn. Trong suốt thời gian đó, bạn không thể làm gì khác. Dòng người phía sau bạn cũng bị chặn lại. Đây là cách JavaScript hoạt động mặc định: nó thực thi từng dòng lệnh một và phải chờ cho dòng lệnh trước hoàn thành xong. - Lập trình Bất đồng bộ (Asynchronous): 🚶♂️➡️🧾 ... 🤳 ... ☕️
Bạn xếp hàng, gọi một ly cà phê. Nhân viên nhận yêu cầu, đưa cho bạn một tờ hóa đơn có số thứ tự (một lời hứa - a Promise), và nói: "Khi nào xong em gọi ạ!".
Bây giờ, bạn có thể đi ra bàn ngồi, lướt điện thoại, làm việc khác... Khi cà phê của bạn được pha xong, nhân viên sẽ gọi số của bạn và bạn ra lấy. Dòng người phía sau vẫn tiếp tục gọi món mà không bị chặn lại.
Hầu hết các tác vụ quan trọng trong automation đều là bất đồng bộ:
- Truy cập một trang web (mất thời gian tải).
- Click vào một nút (chờ trang phản hồi).
- Gửi một yêu cầu API (chờ máy chủ trả lời).
Nếu JavaScript phải "đứng chờ" cho mỗi hành động này, toàn bộ chương trình của bạn sẽ bị "đơ". Vì vậy, chúng ta cần cơ chế bất đồng bộ.
Phần 2: Lời hứa (Promise) - "Tờ hóa đơn" của JavaScript
Promise là một đối tượng đặc biệt trong JavaScript, đại diện cho kết quả trong tương lai của một hành động bất đồng bộ.
Một Promise giống hệt như tờ hóa đơn có số thứ tự ở quán cà phê. Nó có 3 trạng thái:
- pending (đang chờ): Trạng thái ban đầu. Bạn vừa nhận được hóa đơn và đang chờ cà phê.
- fulfilled (hoàn thành): Hành động đã thành công. Nhân viên đã gọi số của bạn, và bạn nhận được ly cà phê (value).
- rejected (bị từ chối): Hành động đã thất bại. Máy pha cà phê bị hỏng, và nhân viên báo xin lỗi bạn (error).
Cách "sử dụng" một Promise: .then() và .catch()
Khi bạn có một Promise (ví dụ: một lệnh của Playwright), bạn sẽ dùng các phương thức sau để xử lý kết quả của nó:
- .then(ketQua => { ... }): Đăng ký một hàm callback sẽ được thực thi khi Promise được hoàn thành (fulfilled). ketQua chính là "ly cà phê" bạn nhận được.
- .catch(loi => { ... }): Đăng ký một hàm callback sẽ được thực thi khi Promise bị từ chối (rejected). loi là lý do tại sao nó thất bại.
- Ví dụ cơ bản:
// Giả lập một hàm tải dữ liệu (trả về một Promise)
function taiDuLieu() {
return new Promise((resolve, reject) => {
console.log("Bắt đầu tải dữ liệu...");
// Giả lập việc tải mất 2 giây
setTimeout(() => {
const thanhCong = true; // Thử đổi thành false để xem .catch() hoạt động
if (thanhCong) {
resolve({ data: "Đây là dữ liệu người dùng" }); // Hoàn thành
} else {
reject("Lỗi mạng!"); // Bị từ chối
}
}, 2000);
});
}
// Sử dụng Promise
taiDuLieu()
.then(response => {
console.log("Tải thành công!", response.data);
})
.catch(error => {
console.error("Tải thất bại!", error);
});
console.log("Dòng này sẽ chạy ngay lập tức mà không cần chờ tải xong.");
Hiểu Sâu về Promise: Dòng chảy Bất đồng bộ
Hãy quay lại với ví dụ quán cà phê. Promise không chỉ là "tờ hóa đơn", mà nó là một vật thể chứa đựng toàn bộ quá trình: từ lúc bạn gọi món cho đến khi bạn nhận được cà phê (hoặc nhận được lời xin lỗi).
"Bên trong" một Promise - Nhà bếp và hai cánh cửa
Khi bạn tạo một new Promise, bạn đang khởi động một "nhà bếp" để thực hiện một công việc bất đồng bộ. Bạn phải cung cấp cho "nhà bếp" này một hàm thực thi (executor function) có hai tham số đặc biệt: resolve và reject.
- resolve (Hoàn thành): Là cánh cửa THÀNH CÔNG. Khi công việc trong bếp hoàn thành tốt đẹp, bạn gọi resolve() và "đẩy" kết quả (ly cà phê) ra cánh cửa này.
- reject (Từ chối): Là cánh cửa THẤT BẠI. Nếu có lỗi xảy ra (hết hạt cà phê), bạn gọi reject() và "đẩy" lý do thất bại (lời xin lỗi) ra cánh cửa này.
Ví von: 🧠 resolve và reject giống như hai nút bấm mà người pha chế có trong tay. Pha xong, họ bấm nút resolve. Làm đổ, họ bấm nút reject.
// Giả lập việc lấy dữ liệu User từ một API
const layDuLieuUser = new Promise((resolve, reject) => {
// Công việc bất đồng bộ xảy ra ở đây (bên trong "nhà bếp")
console.log("Đang gửi yêu cầu đến máy chủ...");
setTimeout(() => {
const duLieuTraVe = { id: 1, name: "Automation Tester" };
const coLoiMang = false; // Thử đổi thành true để xem reject hoạt động
if (!coLoiMang) {
// Công việc thành công, bấm nút 'resolve' và đưa ra dữ liệu
console.log("...Nhận được phản hồi thành công!");
resolve(duLieuTraVe);
} else {
// Công việc thất bại, bấm nút 'reject' và đưa ra lỗi
console.log("...Gặp lỗi mạng!");
reject("Lỗi: Không thể kết nối tới máy chủ.");
}
}, 2000); // Giả lập mạng chậm 2 giây
});
"Bên ngoài" một Promise - Phòng chờ và cách xử lý
Khi bạn có một Promise (layDuLieuUser), bạn không thể vào "nhà bếp" để xem. Bạn phải đợi ở "phòng chờ" và đăng ký cách xử lý cho từng trường hợp.
- .then(onFulfilled) - Xử lý khi thành công:
- Đây là hàm bạn đăng ký để xử lý kết quả được đẩy ra từ cửa resolve.
- Giá trị mà resolve( ... ) đẩy ra sẽ trở thành tham số của hàm này.
- .catch(onRejected) - Xử lý khi thất bại:
- Đây là hàm bạn đăng ký để xử lý lỗi được đẩy ra từ cửa reject.
- Lý do mà reject( ... ) đẩy ra sẽ trở thành tham số của hàm này.
- .finally(onFinally) - Luôn luôn chạy:
- Đây là hàm sẽ được chạy dù Promise thành công hay thất bại.
- Ví von: 🧠 Dù bạn nhận được cà phê hay lời xin lỗi, bạn vẫn phải dọn dẹp bàn và rời đi. .finally() hoàn hảo cho các tác vụ dọn dẹp (cleanup) trong automation, ví dụ như đóng trình duyệt.
Nối chuyền các "Lời hứa" (Promise Chaining)
Đây là sức mạnh lớn nhất của Promise. Phương thức .then() bản thân nó cũng trả về một Promise mới, cho phép bạn nối các hành động bất đồng bộ lại với nhau một cách tuần tự và dễ đọc.
Kịch bản:
- Lấy thông tin user.
- Dùng userId từ bước 1 để lấy danh sách bài viết của user đó.
- In ra số lượng bài viết.
function layUserId() {
return new Promise(resolve => setTimeout(() => resolve(1), 1000));
}
function layBaiVietCuaUser(userId) {
console.log(`Đang lấy bài viết cho user ID: ${userId}...`);
return new Promise(resolve => setTimeout(() => resolve(["Bài viết 1", "Bài viết 2"]), 1000));
}
// Bắt đầu chuỗi Promise
layUserId()
.then(userId => {
// .then() đầu tiên nhận được userId và gọi hàm tiếp theo,
// trả về một Promise MỚI.
return layBaiVietCuaUser(userId);
})
.then(baiViet => {
// .then() thứ hai sẽ chờ Promise của layBaiVietCuaUser hoàn thành
// và nhận kết quả là 'baiViet'.
console.log(`Người dùng này có ${baiViet.length} bài viết.`);
})
.catch(error => {
// Một .catch() duy nhất ở cuối có thể bắt lỗi của BẤT KỲ bước nào trong chuỗi.
console.error("Đã có lỗi trong chuỗi Promise:", error);
});
Cách viết này gọn gàng và dễ quản lý hơn rất nhiều so với việc lồng các hàm callback vào nhau ("Callback Hell").
Phần 3: async/await - Cách viết code bất đồng bộ "thanh lịch" nhất
Viết code với .then().catch() có thể trở nên rối rắm. async/await là một cú pháp hiện đại (syntactic sugar) được xây dựng trên nền tảng Promise, giúp bạn viết code bất đồng bộ trông giống hệt như code đồng bộ.
Đây là cách viết tiêu chuẩn trong các kịch bản test Playwright.
- async: Từ khóa này được đặt trước một function. Nó biến hàm đó thành một hàm bất đồng bộ, có nghĩa là hàm đó sẽ luôn ngầm trả về một Promise.
- await: Từ khóa này chỉ có thể được dùng bên trong một hàm async. Khi bạn đặt await trước một Promise, nó sẽ tạm dừng việc thực thi của hàm đó cho đến khi Promise được giải quyết (hoàn thành hoặc bị từ chối), sau đó trả về kết quả.
Ví dụ viết lại bằng async/await
// Hàm tải dữ liệu vẫn như cũ (trả về Promise)
function taiDuLieu() { /* ... */ }
// Viết một hàm async để chứa các lệnh await
async function xuLyDuLieu() {
console.log("Bắt đầu xử lý...");
try {
// Tạm dừng ở đây cho đến khi taiDuLieu() hoàn thành,
// sau đó gán kết quả vào biến 'response'
const response = await taiDuLieu();
console.log("Tải thành công!", response.data);
} catch (error) {
// Nếu Promise bị reject, nó sẽ nhảy vào khối catch
console.error("Tải thất bại!", error);
}
console.log("Dòng này chỉ chạy sau khi await kết thúc.");
}
xuLyDuLieu();
Lợi ích: Code trông tuần tự, dễ đọc và dễ gỡ lỗi hơn rất nhiều! try...catch là cách chuẩn để xử lý lỗi trong code async/await.
Ứng dụng trong Automation (Playwright)
Hầu như mọi lệnh trong Playwright (page.goto, page.click, page.fill, expect(...)) đều trả về một Promise. Do đó, bạn phải luôn dùng await trước chúng.
// test() của Playwright đã là một hàm async
test('should login successfully', async ({ page }) => {
// Tạm dừng cho đến khi trang được tải xong
await page.goto('https://the-internet.herokuapp.com/login');
// Tạm dừng cho đến khi điền username xong
await page.locator('#username').fill('tomsmith');
// Tạm dừng cho đến khi điền password xong
await page.locator('#password').fill('SuperSecretPassword!');
// Tạm dừng cho đến khi click xong
await page.locator('button[type="submit"]').click();
// Tạm dừng cho đến khi kiểm tra xong
await expect(page.locator('#flash')).toContainText('You logged into a secure area!');
});
async/await - Viết code Bất đồng bộ như Đồng bộ
async/await là một "cú pháp ngọt ngào" (syntactic sugar) được xây dựng trên nền tảng Promise. Mục tiêu của nó là giúp chúng ta thoát khỏi việc phải viết các hàm callback lồng nhau (.then()), cho phép code bất đồng bộ trông và hoạt động gần giống như code đồng bộ (tuần tự từ trên xuống dưới).
"Bóc tách" từng từ khóa
async - Người đánh dấu- Công dụng: Khi bạn đặt từ khóa async trước một hàm, nó sẽ làm hai việc:
- Nó "đánh dấu" hàm này là một hàm bất đồng bộ.
- Nó đảm bảo rằng hàm này sẽ luôn luôn trả về một Promise.
- Nếu bạn return một giá trị từ hàm async (ví dụ: return "Xong"), JavaScript sẽ tự động "gói" giá trị đó vào một Promise đã được resolve.
- Nếu hàm async ném ra một lỗi, nó sẽ trả về một Promise bị reject.
- Ví von: 🧠 async giống như bạn dán một nhãn "Hàng cần chờ xử lý" lên một thùng hàng. Ai nhận được thùng hàng này đều biết rằng họ có thể sẽ phải await (chờ đợi) kết quả bên trong.
async function layTenNguoiDung() {
// Dù bạn chỉ return một chuỗi, hàm này thực chất trả về một Promise
return "Tester";
}
await - Người chờ đợi- Công dụng: Từ khóa này là "phép thuật" chính. Nó chỉ có thể được dùng bên trong một hàm async.
- Khi bạn đặt await trước một Promise, nó sẽ tạm dừng việc thực thi của hàm async đó tại chính dòng đó.
- Nó sẽ chờ cho đến khi Promise được giải quyết (hoặc fulfilled hoặc rejected).
- Nếu Promise fulfilled, await sẽ "mở gói" Promise và trả về giá trị bên trong.
- Nếu Promise rejected, await sẽ ném ra một lỗi.
- Ví von: 🧠 await chính là hành động bạn đứng chờ ly cà phê. Bạn không rời khỏi quán, bạn chỉ tạm dừng "nhiệm vụ lấy cà phê" của mình. Các việc khác trong quán (JavaScript event loop) vẫn tiếp tục diễn ra. Khi cà phê của bạn xong, "nhiệm vụ" của bạn tiếp tục ngay tại nơi đã dừng.
try...catch - Lưới an toàn cho await
Khi một Promise bị reject, await sẽ ném ra lỗi. Để bắt lỗi này, chúng ta sử dụng khối try...catch, đây là cách xử lý lỗi tiêu chuẩn cho code async/await.
- try { ... }: Đặt tất cả các lệnh await và logic "hạnh phúc" (happy path) của bạn vào đây.
- catch (error) { ... }: Khối này sẽ chỉ được thực thi nếu có bất kỳ Promise nào trong khối try bị reject.
So sánh Trực tiếp: .then/.catch vs async/await
Hãy xem lại ví dụ nối chuyền Promise ở phần trước và viết lại nó bằng async/await.
- Dùng .then/.catch (Khó đọc hơn):
layUserId()
.then(userId => {
return layBaiVietCuaUser(userId);
})
.then(baiViet => {
console.log(`Người dùng này có ${baiViet.length} bài viết.`);
})
.catch(error => {
console.error("Đã có lỗi:", error);
});
- Dùng async/await (Sạch sẽ, tuần tự):
async function xuLyBaiViet() {
try {
console.log("Bắt đầu lấy ID người dùng...");
const userId = await layUserId(); // Tạm dừng, chờ lấy xong userId
console.log("...Đã có ID, bắt đầu lấy bài viết...");
const baiViet = await layBaiVietCuaUser(userId); // Tạm dừng, chờ lấy xong bài viết
console.log("...Đã có bài viết!");
console.log(`Người dùng này có ${baiViet.length} bài viết.`);
} catch (error) {
console.error("Đã có lỗi:", error);
}
}
xuLyBaiViet();
Nhận xét: Rõ ràng, phiên bản async/await đọc giống như một câu chuyện tuần tự, cực kỳ dễ hiểu và dễ gỡ lỗi.
Phần 4: Modules (import/export) - Chia để trị
Vấn đề: Một file cho tất cả?
Khi dự án của bạn lớn dần, việc viết toàn bộ code vào một file duy nhất sẽ trở thành một thảm họa:
- Khó tìm kiếm: Tìm một hàm cụ thể giống như mò kim đáy bể.
- Khó bảo trì: Sửa một chỗ có thể vô tình làm hỏng một chỗ khác không liên quan.
- Khó tái sử dụng: Không thể dễ dàng lấy một phần code từ dự án này sang dự án khác.
Modules là giải pháp. Nó cho phép bạn chia code ra thành nhiều file nhỏ hơn, mỗi file có một nhiệm vụ cụ thể.
Ví von: 🧠 Thay vì xây cả một tòa nhà khổng lồ bằng một khối bê tông duy nhất, bạn sẽ tạo ra những viên gạch LEGO riêng lẻ (các file/module). Sau đó, bạn lắp ráp chúng lại với nhau.
export - "Chia sẻ ra bên ngoài"
Từ khóa export cho phép bạn "công khai" các biến, hàm, hoặc class từ một file để các file khác có thể sử dụng chúng. Có hai cách export chính:
Named Export (Export theo tên)
Bạn có thể export nhiều thứ từ một file bằng cách đặt export trước chúng.
// File: utils.js (Thư viện các hàm tiện ích)
export const PI = 3.14;
export const cleanText = (text) => {
return text.trim().toLowerCase();
};
export class TestUser {
// ...
}
Default Export (Export mặc định)
Mỗi file chỉ có thể có một export default. Nó thường được dùng để export thứ quan trọng nhất, đại diện cho toàn bộ file đó.
// File: LoginPage.js (Mô hình POM)
export default class LoginPage {
constructor(page) {
this.page = page;
this.usernameInput = page.locator('#username');
// ...
}
async login(username, password) {
// ...
}
}
import - "Sử dụng từ bên ngoài"
Từ khóa import cho phép bạn lấy các biến, hàm, class đã được export từ một file khác.
Import theo tên (Named Import)
Dùng dấu ngoặc nhọn {} để chỉ rõ những gì bạn muốn import.
// File: my-test.spec.js
// Import cleanText và TestUser từ file utils.js
import { cleanText, TestUser } from './utils.js';
const cleaned = cleanText(" Hello World ");
const user = new TestUser();
Import mặc định (Default Import)
Không cần dấu ngoặc nhọn, và bạn có thể đặt tên cho nó là gì cũng được.
/ File: my-test.spec.js
// Import class LoginPage và đặt tên là LoginPage
import LoginPage from './LoginPage.js';
// ...
const loginPage = new LoginPage(page);
Kết hợp cả hai:
JavaScript
import LoginPage, { someOtherUtil } from './LoginPage.js';
Phần 5: Xử lý Lỗi (try...catch) - "Lưới an toàn"
Vấn đề: Code "Lạc quan"
Mặc định, code của chúng ta thường được viết với giả định rằng mọi thứ sẽ luôn hoạt động hoàn hảo. Nhưng trong thực tế:
- Element bạn tìm có thể không tồn tại.
- API bạn gọi có thể bị lỗi.
- Dữ liệu bạn nhận về có thể không đúng định dạng.
Nếu không có cơ chế xử lý, một lỗi nhỏ sẽ làm "sập" toàn bộ kịch bản test của bạn.
Cú pháp try...catch...finally
try...catch cung cấp một "lưới an toàn" để bạn xử lý các lỗi có thể xảy ra một cách mượt mà.
- try { ... }: Đặt các đoạn code "nguy hiểm" (những dòng có khả năng gây lỗi) vào đây.
- catch (error) { ... }: Khối này sẽ chỉ được thực thi nếu có lỗi xảy ra trong khối try. Biến error sẽ chứa thông tin về lỗi đó.
- finally { ... }: Khối này sẽ luôn luôn được thực thi, dù có lỗi hay không. Nó hoàn hảo cho các tác vụ "dọn dẹp" (ví dụ: đóng trình duyệt, ngắt kết nối database).
Ví von: 🧠 try...catch giống như một diễn viên xiếc đi trên dây.
- try: Là hành động đi trên dây.
- catch: Là tấm lưới an toàn bên dưới. Nếu diễn viên ngã, họ sẽ rơi vào tấm lưới này thay vì rơi xuống đất.
- finally: Là hành động cúi chào khán giả cuối buổi diễn, dù buổi diễn có thành công hay thất bại.
Các ví dụ thực tế
Hãy xem cách "tấm lưới an toàn" này hoạt động qua các kịch bản phổ biến.
Ví dụ 1: Xử lý Element không tồn tại (Automation Test)
Đây là tình huống kinh điển khi kiểm thử giao diện người dùng.
😫 Code "Lạc quan" (Sẽ bị sập):
console.log("Bước 1: Tìm nút đăng nhập...");
// Dòng này sẽ gây lỗi nếu ID bị sai hoặc element chưa xuất hiện
const loginButton = await page.locator('#login-button-that-does-not-exist');
await loginButton.click();
cole.log("Bước 2: Điền thông tin..."); // Sẽ không bao giờ được chạy
😎 Code với "Lưới an toàn" (An toàn và có kiểm soát):
console.log("Bắt đầu kịch bản đăng nhập...");
try {
console.log("TRY: Đang cố gắng tìm và click vào nút đăng nhập...");
const loginButton = await page.locator('#login-button-that-does-not-exist');
await loginButton.click();
console.log("TRY: Đã click vào nút đăng nhập thành công!");
} catch (error) {
console.error("CATCH: Đã xảy ra lỗi! Không tìm thấy nút đăng nhập.");
console.error("Chi tiết lỗi:", error.message); // In lỗi để debug
await page.screenshot({ path: 'Loi_Khong_Tim_Thay_Nut.png' }); // Chụp ảnh màn hình lỗi
} finally {
console.log("FINALLY: Hoàn thành khối xử lý đăng nhập (dù thành công hay thất bại).");
}
console.log("Tiếp tục thực hiện các bước test khác..."); // Chương trình vẫn chạy tới đây!
Ví dụ 2: Gọi API bị lỗi hoặc không phản hồi
Ứng dụng của bạn cần lấy dữ liệu từ server, nhưng server có thể đang bảo trì hoặc gặp lỗi.
😫 Code "Lạc quan" (Sẽ bị sập):
async function getUserData(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`); // Giả sử API lỗi 500
const userData = await response.json(); // Lỗi! Không thể parse JSON từ một phản hồi lỗi
console.log("Tên người dùng:", userData.name);
}
😎 Code với "Lưới an toàn" (Xử lý lỗi một cách duyên dáng):
async function getUserDataSafely(userId) {
try {
console.log("TRY: Gửi yêu cầu lấy thông tin người dùng...");
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// Chủ động tạo ra lỗi để khối catch bắt được
throw new Error(`API trả về lỗi! Status: ${response.status}`);
}
const userData = await response.json();
console.log("Lấy dữ liệu thành công:", userData);
return userData;
} catch (error) {
console.error("CATCH: Không thể lấy dữ liệu người dùng.");
console.error("Lý do:", error.message);
// Trả về một giá trị mặc định để ứng dụng không bị lỗi
return { name: "Người dùng không tồn tại", email: "N/A" };
}
}
Tổng kết: Tại sao try...catch lại quan trọng?
Chống sập chương trình (Robustness): Ngăn một lỗi nhỏ phá hỏng toàn bộ quy trình.
Gỡ lỗi dễ dàng hơn (Debugging): Khối catch là nơi lý tưởng để ghi log chi tiết, chụp ảnh màn hình, giúp bạn biết chính xác lỗi gì, ở đâu, và khi nào.
Tạo ra các kịch bản test ổn định: Kịch bản test của bạn sẽ không thất bại chỉ vì một yếu tố không ổn định tạm thời (ví dụ: mạng chậm).
Tách biệt logic: Giúp code của bạn sạch sẽ hơn bằng cách tách logic chính (trong try) ra khỏi logic xử lý lỗi (trong catch).