บทที่ 4: เขียน Test แรก — describe/it Pattern¶
Pre-chapter Retrieval¶
ก่อนอ่านบทนี้ ลองตอบก่อน:
ใน
wdio.conf.jscapabilities key ที่มี:เช่นappium:automationNameต้องเขียนยังไง? และ@wdio/appium-serviceมีประโยชน์อะไร?
ดูเฉลย
ต้องใส่ quotes: `'appium:automationName': 'UIAutomator2'` — เพราะ `:` มีความหมายพิเศษใน JS object literal `@wdio/appium-service` auto-start/stop Appium server อัตโนมัติ ไม่ต้องเปิด terminal แยกวัตถุประสงค์¶
อ่านจบบทนี้แล้วคุณจะ:
- เขียน test file ด้วย Mocha
describe/itpattern ได้ - ใช้
before,after,beforeEach,afterEachhooks ได้ - ใช้
expectสำหรับ assertions ได้ - เข้าใจ
async/awaitใน context ของ WDIO ได้
ทำไมต้องรู้? (Why)¶
describe/it คือ "ภาษา" ของ test ใน JavaScript world — ต่างจาก RF ที่ใช้ *** Test Cases ***
เมื่อเข้าใจ pattern นี้แล้ว การอ่าน test ที่คนอื่นเขียน หรือการเขียน test ใหม่จะรู้สึกเป็นธรรมชาติ
Analogy: describe/it เหมือน หัวข้อ/รายการทดสอบ¶
นึกถึงรายงาน QA:
- describe('Login') = หัวข้อ: "ทดสอบ Login Feature"
- it('should login with valid credentials') = รายการ: "ทดสอบ login ด้วย credentials ที่ถูกต้อง"
- it('should show error for wrong password') = "ทดสอบ login ด้วย password ผิด"
โครงสร้างนี้ทำให้ report อ่านง่ายเหมือนเอกสาร QA จริงๆ
⚠️ ถ้าเชื่อ analogy นี้ 100% จะเข้าใจผิดว่า: -
describe= test case และit= step → จริงๆitแต่ละอัน = 1 test case ที่ independent กัน -describeซ้อนได้แค่ 2 ชั้น → ซ้อนได้หลายชั้นตามต้องการ
เนื้อหาหลัก¶
โครงสร้าง Test File¶
// test/specs/login.test.js
describe('Login Feature', () => {
before(async () => {
// รันครั้งเดียวก่อน describe block ทั้งหมด
// ใช้สำหรับ: setup ที่ทำครั้งเดียว
});
after(async () => {
// รันครั้งเดียวหลัง describe block ทั้งหมดจบ
// ใช้สำหรับ: cleanup
});
beforeEach(async () => {
// รันก่อนทุก it block
// ใช้สำหรับ: reset state ก่อนแต่ละ test
});
afterEach(async () => {
// รันหลังทุก it block
// ใช้สำหรับ: teardown แต่ละ test
});
it('should login with valid credentials', async () => {
// Test case 1
});
it('should show error for wrong password', async () => {
// Test case 2
});
});
"A basic Mocha test looks like this: describe('my awesome website', () => { it('should do some assertions', async () => { ... }) })" (webdriver.io/docs/frameworks)
Hooks เทียบกับ RF¶
| RF | WDIO (Mocha) | ใช้เมื่อ |
|---|---|---|
[Setup] ใน Test Case |
beforeEach |
ทำก่อนแต่ละ test |
[Teardown] ใน Test Case |
afterEach |
ทำหลังแต่ละ test |
Suite Setup |
before |
ทำครั้งเดียวก่อน suite ทั้งหมด |
Suite Teardown |
after |
ทำครั้งเดียวหลัง suite ทั้งหมดจบ |
Assertions ด้วย expect¶
WDIO ใช้ Jasmine expect syntax:
// ตรวจ element มีอยู่
const loginBtn = await $('~login_button');
await expect(loginBtn).toBeDisplayed();
// ตรวจข้อความ
const welcomeText = await $('~welcome_message');
await expect(welcomeText).toHaveText('ยินดีต้อนรับ');
// ตรวจ element enabled/disabled
const submitBtn = await $('~submit_btn');
await expect(submitBtn).toBeEnabled();
// ตรวจ element ไม่มี
await expect(await $('~error_msg')).not.toBeDisplayed();
$() Selector — หา Element¶
// accessibility_id (เหมือน accessibility_id= ใน RF)
const btn = await $('~login_button');
// resource-id
const field = await $('android=new UiSelector().resourceId("com.app:id/et_username")');
// xpath
const el = await $('//android.widget.Button[@text="Login"]');
// รอให้ element พร้อม
await btn.waitForDisplayed({ timeout: 10000 });
"Accessibility ID: The accessibility id strategy works across platforms—for iOS it maps to accessibility identifiers, while for Android it corresponds to content-description for the element." (webdriver.io/docs/selectors)
~ prefix = accessibility_id strategy — shorthand ของ WDIO
ตัวอย่าง 3 ระดับ¶
Beginner: Test Login ครบ flow¶
// test/specs/login.test.js
// tested: WDIO v9, Appium 2.x, Android API 33 emulator
describe('Login', () => {
afterEach(async () => {
// Reset app หลังแต่ละ test
await driver.reset();
});
it('should login successfully', async () => {
// รอ login screen โหลด
const usernameField = await $('~username_input');
await usernameField.waitForDisplayed({ timeout: 15000 });
// กรอก credentials
await usernameField.setValue('user@email.com');
await $('~password_input').setValue('ValidPass123');
await $('~login_button').click();
// ตรวจว่า home screen ขึ้น
const homeScreen = await $('~home_screen');
await homeScreen.waitForDisplayed({ timeout: 15000 });
await expect(homeScreen).toBeDisplayed();
});
it('should show error for wrong password', async () => {
const usernameField = await $('~username_input');
await usernameField.waitForDisplayed({ timeout: 15000 });
await usernameField.setValue('user@email.com');
await $('~password_input').setValue('WrongPass');
await $('~login_button').click();
const errorMsg = await $('~error_message');
await errorMsg.waitForDisplayed({ timeout: 10000 });
await expect(errorMsg).toHaveTextContaining('Invalid credentials');
});
});
Intermediate: Nested describe + shared helpers¶
// test/specs/transfer.test.js
// tested: WDIO v9, Appium 2.x
describe('Transfer Feature', () => {
before(async () => {
// Login ครั้งเดียวก่อนทุก transfer test
await $('~username_input').waitForDisplayed({ timeout: 15000 });
await $('~username_input').setValue('user@email.com');
await $('~password_input').setValue('ValidPass123');
await $('~login_button').click();
await $('~home_screen').waitForDisplayed({ timeout: 15000 });
});
describe('Transfer Amount Validation', () => {
beforeEach(async () => {
await $('~transfer_button').click();
await $('~transfer_screen').waitForDisplayed({ timeout: 10000 });
});
afterEach(async () => {
// กลับ home ก่อน test ถัดไป
await $('~back_button').click();
});
it('should reject transfer when amount is 0', async () => {
await $('~amount_field').setValue('0');
await $('~confirm_button').click();
await expect(await $('~error_amount')).toBeDisplayed();
});
it('should reject transfer exceeding daily limit', async () => {
await $('~amount_field').setValue('9999999');
await $('~confirm_button').click();
const error = await $('~error_limit');
await error.waitForDisplayed({ timeout: 5000 });
await expect(error).toHaveTextContaining('exceeds daily limit');
});
});
});
Advanced: Custom Assertion Helper¶
// helpers/assertions.js
export async function waitForAndAssert(selector, options = {}) {
const { timeout = 15000, message } = options;
const element = await $(selector);
await element.waitForDisplayed({ timeout, timeoutMsg: message });
return element;
}
export async function assertTextContains(selector, expectedText) {
const element = await waitForAndAssert(selector);
const text = await element.getText();
if (!text.includes(expectedText)) {
throw new Error(
`Expected element '${selector}' to contain '${expectedText}'\nActual: '${text}'`
);
}
return element;
}
// test/specs/dashboard.test.js
import { waitForAndAssert, assertTextContains } from '../../helpers/assertions.js';
describe('Dashboard', () => {
it('should display account balance', async () => {
const balance = await waitForAndAssert(
'~account_balance',
{ message: 'Balance did not appear within 15s' }
);
// ตรวจว่า balance มี format ถูก (มี ฿ หรือ THB)
await assertTextContains('~account_balance', '฿');
});
});
Common Mistakes¶
❌ ลืม await ก่อน selector
const btn = $('~login_button'); // ❌ ได้ Promise ไม่ใช่ element
btn.click(); // ❌ click บน Promise — ไม่ทำงาน
await ก่อนทุก WDIO call เสมอ
(source: webdriver.io/docs/gettingstarted)
❌ ใช้ before แทน beforeEach โดยไม่เข้าใจความต่าง
→ state ไม่ reset ระหว่าง test ทำให้ test ขึ้นกับ order
✅ before = ทำครั้งเดียว / beforeEach = ทำก่อนทุก it เลือกให้ถูก
(source: webdriver.io/docs/frameworks)
❌ test ที่ตั้งชื่อด้วย it('should...') แต่ทดสอบหลาย behavior ในอัน
สรุปบท¶
ลองตอบก่อนดูเฉลย:
คำถาม 1:
beforeกับbeforeEachต่างกันยังไง? ให้ยกตัวอย่าง scenario ที่ควรใช้แต่ละอันคำถาม 2:
~ใน$('~login_button')หมายความว่าอะไร? ต่างจาก$('//android.widget.Button')ยังไง?คำถาม 3: ถ้า test case หนึ่งทำให้ state ของ app เปลี่ยน (เช่น login แล้ว) และส่งผลกระทบต่อ test case ถัดไป — คุณจะแก้ปัญหานี้ยังไง?