บทที่ 6: Page Objects ด้วย JavaScript Class¶
Pre-chapter Retrieval¶
ก่อนอ่านบทนี้ ลองตอบก่อน:
$$('~item')คืนอะไร? และต่างจาก$('~item')ยังไง? พร้อมตอบว่าควรใช้แต่ละอันเมื่อไหร่
ดูเฉลย
`$$` คืน array ของทุก element ที่ match / `$` คืนแค่ element แรก ใช้ `$` เมื่อ element unique ใช้ `$$` เมื่อต้องการทุก items เช่นใน listวัตถุประสงค์¶
อ่านจบบทนี้แล้วคุณจะ:
- สร้าง Page Object ด้วย JavaScript class ได้
- ใช้ getter functions เพื่อ lazy-load selectors ได้
- จัดโครงสร้าง project ที่ scale ได้ดี
- เข้าใจว่า Page Object ใน WDIO ต่างจากใน RF อย่างไร
ทำไมต้องรู้? (Why)¶
Test ที่ไม่มี Page Object จะ scale ไม่ได้เมื่อ app โตขึ้น
ใน RF เราแยก locators และ keywords ออกเป็น resource file — ใน WDIO เราทำเหมือนกัน แต่ใช้ JavaScript class แทน
ความต่างหลักคือ WDIO Page Object ใช้ getter functions สำหรับ selectors — เพื่อ lazy evaluation (หา element เมื่อใช้จริง ไม่ใช่ตอนสร้าง object)
Analogy: Page Object class เหมือน Blueprint ของห้อง¶
ห้องแต่ละห้องมี blueprint:
- LoginPage class = blueprint ของ Login screen
- getter usernameField = ตำแหน่งของ username field ในห้อง
- method login() = ขั้นตอนการทำ action ในห้อง
ถ้าห้องเปลี่ยน (UI เปลี่ยน) แค่แก้ blueprint ที่เดียว ทุกคนที่ใช้ blueprint นั้นอัปเดตตามอัตโนมัติ
⚠️ ถ้าเชื่อ analogy นี้ 100% จะเข้าใจผิดว่า: - Page Object 1 class = 1 page เสมอ → บาง app มี reusable components (header, nav bar) ที่ทำเป็น Component Object แยก - getter ต้อง return
$()เสมอ → บาง getter return$$()สำหรับ list elements
เนื้อหาหลัก¶
โครงสร้าง Project แบบ Page Object¶
wdio-mobile-tests/
├── wdio.conf.js
├── test/
│ └── specs/
│ ├── login.test.js
│ └── transfer.test.js
└── pageObjects/
├── BasePage.js ← shared methods ทุก page
├── LoginPage.js ← Login screen
├── HomePage.js ← Home screen
└── TransferPage.js ← Transfer screen
BasePage — Shared Methods¶
// pageObjects/BasePage.js
export default class BasePage {
/**
* รอให้ loading สิ้นสุด (ถ้ามี spinner)
*/
async waitForLoading(timeout = 15000) {
try {
await $('~loading_spinner').waitForDisplayed({
timeout: 3000,
reverse: false,
});
await $('~loading_spinner').waitForDisplayed({
timeout,
reverse: true,
});
} catch {
// spinner ไม่ปรากฏ = ไม่มี loading = ok
}
}
/**
* ซ่อน keyboard
*/
async hideKeyboard() {
await driver.hideKeyboard();
}
/**
* กลับหน้าก่อนหน้า
*/
async goBack() {
await driver.back();
}
}
LoginPage¶
// pageObjects/LoginPage.js
import BasePage from './BasePage.js';
class LoginPage extends BasePage {
// Getters — lazy evaluation
// selector ถูกสร้างเมื่อเรียกใช้ ไม่ใช่เมื่อ new LoginPage()
get usernameField() {
return $('~username_input');
}
get passwordField() {
return $('~password_input');
}
get loginButton() {
return $('~login_button');
}
get errorMessage() {
return $('~error_message');
}
// Methods — actions บน page นี้
async waitForReady() {
await (await this.usernameField).waitForDisplayed({ timeout: 15000 });
}
async fillUsername(username) {
await (await this.usernameField).setValue(username);
}
async fillPassword(password) {
await (await this.passwordField).setValue(password);
await this.hideKeyboard();
}
async submit() {
await (await this.loginButton).click();
}
async login(username, password) {
await this.waitForReady();
await this.fillUsername(username);
await this.fillPassword(password);
await this.submit();
}
async getErrorMessage() {
await (await this.errorMessage).waitForDisplayed({ timeout: 10000 });
return (await this.errorMessage).getText();
}
}
// export เป็น singleton instance
export default new LoginPage();
"The goal of using page objects is to abstract any page information away from the actual tests." (webdriver.io/docs/pageobjects)
"Selectors defined as getter functions are evaluated when accessed, ensuring elements are requested immediately before use rather than during object initialization." (webdriver.io/docs/pageobjects)
HomePage¶
// pageObjects/HomePage.js
import BasePage from './BasePage.js';
class HomePage extends BasePage {
get balanceText() {
return $('~account_balance');
}
get transferButton() {
return $('~transfer_button');
}
get historyButton() {
return $('~history_button');
}
async waitForReady() {
await this.waitForLoading();
await (await this.balanceText).waitForDisplayed({ timeout: 15000 });
}
async getBalance() {
return (await this.balanceText).getText();
}
async goToTransfer() {
await (await this.transferButton).click();
}
}
export default new HomePage();
Test File ที่ใช้ Page Objects¶
// test/specs/login.test.js
// tested: WDIO v9, Appium 2.x
import LoginPage from '../../pageObjects/LoginPage.js';
import HomePage from '../../pageObjects/HomePage.js';
describe('Login', () => {
it('should login successfully', async () => {
await LoginPage.login('user@email.com', 'ValidPass123');
await HomePage.waitForReady();
await expect(await HomePage.balanceText).toBeDisplayed();
});
it('should show error for wrong password', async () => {
await LoginPage.waitForReady();
await LoginPage.login('user@email.com', 'WrongPass');
const error = await LoginPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
});
ตัวอย่าง 3 ระดับ¶
Beginner: TransferPage ครบ¶
// pageObjects/TransferPage.js
import BasePage from './BasePage.js';
class TransferPage extends BasePage {
get recipientField() { return $('~recipient_phone'); }
get amountField() { return $('~transfer_amount'); }
get confirmButton() { return $('~confirm_transfer'); }
get successScreen() { return $('~transfer_success'); }
get errorMessage() { return $('~transfer_error'); }
async waitForReady() {
await (await this.recipientField).waitForDisplayed({ timeout: 10000 });
}
async transfer(recipient, amount) {
await this.waitForReady();
await (await this.recipientField).setValue(recipient);
await (await this.amountField).setValue(amount);
await this.hideKeyboard();
await (await this.confirmButton).click();
}
async isTransferSuccessful() {
try {
await (await this.successScreen).waitForDisplayed({ timeout: 15000 });
return true;
} catch {
return false;
}
}
}
export default new TransferPage();
// test/specs/transfer.test.js
import LoginPage from '../../pageObjects/LoginPage.js';
import HomePage from '../../pageObjects/HomePage.js';
import TransferPage from '../../pageObjects/TransferPage.js';
describe('Transfer', () => {
before(async () => {
await LoginPage.login('user@email.com', 'ValidPass123');
await HomePage.waitForReady();
});
it('should complete transfer successfully', async () => {
await HomePage.goToTransfer();
await TransferPage.transfer('0812345678', '500');
const success = await TransferPage.isTransferSuccessful();
expect(success).toBe(true);
});
});
Intermediate: Component Object สำหรับ shared UI¶
// pageObjects/components/BottomNavBar.js
import BasePage from '../BasePage.js';
class BottomNavBar extends BasePage {
get homeTab() { return $('~nav_home'); }
get transferTab() { return $('~nav_transfer'); }
get historyTab() { return $('~nav_history'); }
get profileTab() { return $('~nav_profile'); }
async goToHome() { await (await this.homeTab).click(); }
async goToTransfer() { await (await this.transferTab).click(); }
async goToHistory() { await (await this.historyTab).click(); }
async goToProfile() { await (await this.profileTab).click(); }
}
export default new BottomNavBar();
// ใน test
import BottomNavBar from '../../pageObjects/components/BottomNavBar.js';
await BottomNavBar.goToHistory();
Advanced: TypeScript Page Object¶
// pageObjects/LoginPage.ts
import { ChainablePromiseElement } from 'webdriverio';
import BasePage from './BasePage.js';
class LoginPage extends BasePage {
get usernameField(): ChainablePromiseElement {
return $('~username_input');
}
get passwordField(): ChainablePromiseElement {
return $('~password_input');
}
async login(username: string, password: string): Promise<void> {
await this.usernameField.setValue(username);
await this.passwordField.setValue(password);
await $('~login_button').click();
}
}
export default new LoginPage();
TypeScript จะจับ error เช่น ส่ง number แทน string ก่อน runtime
Common Mistakes¶
❌ ใส่ logic ของ test ใน Page Object method
async testLogin() {
await this.login('user', 'pass');
expect(await $('~home')).toBeDisplayed(); // ❌ assertion ไม่ควรอยู่ใน Page Object
}
// Page Object
async login(u, p) { ... }
// Test
await LoginPage.login('user', 'pass');
await expect(await $('~home')).toBeDisplayed(); // ✅ assertion ใน test
❌ ลืม await ตอน call getter
const text = LoginPage.errorMessage.getText(); // ❌
// loginPage.errorMessage คือ ChainablePromiseElement ไม่ใช่ resolved element
await ทั้ง getter และ method
(source: webdriver.io/docs/pageobjects)
❌ Export class แทน instance
✅ Export instance เป็น singleton (source: webdriver.io/docs/pageobjects)สรุปบท¶
ลองตอบก่อนดูเฉลย:
คำถาม 1: ทำไม WDIO Page Object ถึงใช้
getgetter แทนการ assign selector ใน constructor? ถ้า assign ใน constructor จะมีปัญหาอะไร?คำถาม 2: ทำไม
BasePageถึงมีประโยชน์? ถ้าไม่มี BasePage คุณต้องทำอะไรซ้ำในทุก page class?คำถาม 3: Page Object ใน WDIO (JS class) กับ RF (resource file) ต่างกันอย่างไร? อะไรที่ทำได้ดีกว่าในแต่ละแบบ?
ดูเฉลย
**เฉลย 1:** getter ทำ lazy evaluation — selector ถูก resolve เมื่อเรียกใช้จริง ถ้า assign ใน constructor selector จะถูก resolve ตอนสร้าง object ซึ่งอาจเกิดก่อน session start หรือก่อน page load ทำให้ element not found **เฉลย 2:** BasePage รวม shared logic ไว้ที่เดียว เช่น `waitForLoading()`, `hideKeyboard()`, `goBack()` ถ้าไม่มีต้อง copy method เดิมในทุก page class ซึ่งทำให้แก้ยากเมื่อ logic เปลี่ยน **เฉลย 3:** RF resource file — readable สำหรับ non-programmers, อ่านง่ายเป็น natural language / WDIO JS class — ยืดหยุ่นกว่า (TypeScript, inheritance, complex logic), IDE support ดีกว่า (autocomplete, type checking)บทต่อไป: บทที่ 7 — เปรียบเทียบ RF vs WDIO