Cypress Testing Library เพิ่ม query ของ Testing Library อย่าง findByRole, findByLabelText และตัวอื่น ๆ เข้ามาใน Cypress ทำให้ test หา element แบบเดียวกับที่ผู้ใช้หรือ screen reader มองเห็นหน้าเว็บ คือหาจาก role, accessible name และ label ไม่ใช่จาก CSS class หรือโครงสร้าง DOM ผลคือ test ไม่พังทุกครั้งที่มีคน refactor markup และจะ fail เมื่อ UI เริ่มใช้งานผ่านเทคโนโลยีช่วยเหลือไม่ได้ ซึ่งเป็น bug ที่ควรจับให้ได้อยู่แล้ว
บทความนี้พูดถึงการติดตั้ง ลำดับการเลือก query ที่ควรใช้ เหตุผลที่ data-testid ควรอยู่ท้ายสุด วิธีดู accessible role ของ element และรายละเอียดที่ใช้บ่อยอย่าง key action กับการแนบไฟล์ ถ้ายังไม่คุ้นกับ Cypress ให้อ่าน คู่มือ Cypress E2E testing ก่อนแล้วค่อยกลับมาที่นี่
ติดตั้ง Cypress Testing Library
ติดตั้งเป็น dev dependency:
npm install --save-dev @testing-library/cypressจากนั้น register command ใน support file สำหรับ E2E คือ cypress/support/commands.ts (ซึ่งถูก import จาก cypress/support/e2e.ts) ถ้าทำ component test ด้วย ให้ import ใน cypress/support/component.ts เพิ่มอีกที่
// cypress/support/commands.ts
import '@testing-library/cypress/add-commands';ถ้าเขียน test ด้วย TypeScript ให้เพิ่ม types เพื่อให้ cy.findByRole มี autocomplete:
{
"compilerOptions": {
"types": ["cypress", "@testing-library/cypress", "node"]
},
"include": ["**/*.ts"]
}วางไฟล์นี้ไว้ที่ cypress/tsconfig.json เพื่อไม่ให้ types ของ Cypress ไปปนกับโค้ดของแอป
ใน Cypress มีแค่ query กลุ่ม find*
DOM Testing Library มี query สามกลุ่มคือ getBy*, queryBy* และ findBy* แต่ Cypress Testing Library มีให้แค่ findBy* กับ findAllBy* เพราะ command ของ Cypress retry จนผ่านหรือจน timeout อยู่แล้ว ซึ่งตรงกับพฤติกรรมของ findBy* พอดี
| Query | หาเจอ | Fail เมื่อ |
|---|---|---|
cy.findByRole(...) | element เดียวพอดี | หาไม่เจอจน timeout หรือเจอมากกว่าหนึ่งตัว |
cy.findAllByRole(...) | หนึ่งตัวขึ้นไป | หาไม่เจอจน timeout |
ถ้าต้องการยืนยันว่า element ไม่มีอยู่ ให้ใช้ assertion แบบปฏิเสธแทนการหา queryBy*:
cy.findByRole('alert').should('not.exist');ลำดับการเลือก query: เริ่มจาก findByRole
Testing Library แนะนำให้เลือก query ตามลำดับนี้ ยิ่งอยู่สูง ยิ่งใกล้กับวิธีที่คนใช้หน้าเว็บจริง
- Query ที่ทุกคนเข้าถึงได้
findByRole: ปุ่ม ลิงก์ หัวข้อ ช่องกรอกข้อความ dialog หรืออะไรก็ตามที่มี role และ accessible namefindByLabelText: ช่องในฟอร์มที่มี<label>,aria-labelหรือaria-labelledbyfindByPlaceholderText: ทางเลือกสำรองสำหรับช่องที่ไม่มี label (ซึ่งตัวมันเองก็เป็นปัญหาด้าน accessibility)findByText: เนื้อหาที่ไม่ใช่ interactive element เช่น ย่อหน้าหรือข้อความแจ้งเตือนfindByDisplayValue: ช่องในฟอร์ม โดยหาจากค่าที่แสดงอยู่
- Semantic query
findByAltText: รูปภาพfindByTitle: element ที่มี attributetitleซึ่ง screen reader แต่ละตัวอ่านไม่เหมือนกัน
- Test ID
findByTestId: ใช้เมื่อทุกอย่างข้างบนใช้ไม่ได้จริง ๆ
ในทางปฏิบัติ findByRole ที่ใส่ option name ครอบคลุมงานส่วนใหญ่:
cy.findByRole('button', { name: /save changes/i }).click();
cy.findByRole('heading', { level: 1, name: 'Account settings' }).should('be.visible');
cy.findByRole('link', { name: 'Pricing' }).should('have.attr', 'href', '/pricing');
cy.findByRole('checkbox', { name: /subscribe/i }).check();
cy.findByRole('textbox', { name: 'Email' }).type('[email protected]');name ในที่นี้คือ accessible name เช่น ข้อความบนปุ่ม label ที่ผูกกับ input หรือ aria-label การส่ง regex ที่มี i ทำให้ test ไม่พังเพราะตัวพิมพ์เล็กใหญ่เปลี่ยน
ทำไมไม่ควรพึ่ง test ID
ผู้ใช้มองไม่เห็น data-testid test ที่คลิก [data-testid="submit-btn"] จะยังผ่านแม้ว่า:
- ปุ่มไม่มีข้อความให้อ่าน
- ปุ่มเป็น
<div>ที่คนใช้คีย์บอร์ดกดไม่ได้ - ช่องในฟอร์มไม่มี label แล้ว
ส่วน findByRole('button', { name: 'Submit' }) จะ fail ทั้งสามกรณี test suite ของคุณจึงกลายเป็นตัวตรวจ accessibility ที่รันอยู่ตลอดโดยแทบไม่มีต้นทุนเพิ่ม
นอกจากนี้ test ID ยังเป็น markup ที่มีไว้เพื่อ test อย่างเดียว และมักไม่ตรงกับของจริงไปเรื่อย ๆ ให้เก็บไว้ใช้กับ element ที่ไม่มี role หรือข้อความที่มีความหมาย เช่น canvas ของกราฟ หรือจุดจับสำหรับลาก
ให้ Testing Library แนะนำ query ที่ดีกว่า
Testing Library สั่งให้ test fail ได้ เมื่อมี query ที่เหมาะกว่าตัวที่ใช้อยู่ เปิดใช้ใน support file:
// cypress/support/e2e.ts
import { configure } from '@testing-library/cypress';
configure({ throwSuggestions: true });พอเปิดแล้ว cy.findByTestId('submit-btn') บนปุ่มที่มีข้อความ "Submit" จะ fail พร้อมข้อความแนะนำให้ใช้ findByRole('button', { name: /submit/i }) แทน ฟีเจอร์นี้ยังเป็น experimental จึงเหมาะกับช่วงที่กำลังย้าย test เก่า แล้วค่อยดูว่าทีมอยากเปิดไว้ถาวรหรือไม่ ถ้าอยากปิดเฉพาะ query เดียว ให้ส่ง { suggest: false }
วิธีหา accessible role ของ element
element หลายตัวมี implicit role อยู่แล้ว ไม่ต้องเติม attribute role เอง:
| HTML | Role | ตัวอย่าง query |
|---|---|---|
<button> | button | findByRole('button', { name: 'Save' }) |
<a href="..."> | link | findByRole('link', { name: 'Docs' }) |
<h1> ถึง <h6> | heading | findByRole('heading', { level: 2 }) |
<input type="text">, <textarea> | textbox | findByRole('textbox', { name: 'Title' }) |
<input type="checkbox"> | checkbox | findByRole('checkbox', { checked: true }) |
<select> | combobox | findByRole('combobox', { name: 'Country' }) |
<ul>, <li> | list, listitem | findAllByRole('listitem') |
<nav> | navigation | findByRole('navigation') |
<dialog> หรือ role="dialog" | dialog | findByRole('dialog', { name: 'Confirm' }) |
<img alt="..."> | img | findByRole('img', { name: 'Logo' }) |
ข้อควรระวัง: <a> ที่ไม่มี href จะไม่มี role link และ <input type="password"> ไม่มี role เลย ช่องรหัสผ่านจึงต้องหาด้วย findByLabelText
ถ้าไม่แน่ใจ ให้เปิด DevTools (F12) ใน Chrome เลือก element แล้วเปิดแท็บ Accessibility จะเห็น role และ accessible name ที่ browser คำนวณจาก accessibility tree ซึ่งเป็นข้อมูลชุดเดียวกับที่ findByRole ใช้เทียบ Firefox ก็มี Accessibility Inspector ที่ทำงานคล้ายกัน
option อื่นของ findByRole ที่ใช้บ่อยนอกจาก name ได้แก่ level สำหรับหัวข้อ, checked, selected, expanded, pressed และ hidden: true เมื่อต้องการรวม element ที่ถูกซ่อนจาก accessibility tree
จำกัดขอบเขต query ด้วย within
หน้าเว็บมักมีปุ่มชื่อเดียวกันหลายจุด ให้จำกัดขอบเขตการค้นหาไว้ใน container แทนการใช้ .eq(2):
cy.findByRole('dialog', { name: 'Delete project' }).within(() => {
cy.findByRole('button', { name: 'Delete' }).click();
});
cy.findAllByRole('row')
.filter(':contains("[email protected]")')
.within(() => {
cy.findByRole('button', { name: 'Edit' }).click();
});query ของ Testing Library ยังค้นหาต่อจาก subject ก่อนหน้าได้ด้วย เช่น cy.findByRole('navigation').findByRole('link', { name: 'Blog' }) จะค้นเฉพาะใน nav
Key action ด้วย type()
.type() รับ special character ที่อยู่ในวงเล็บปีกกา:
| Sequence | ผลลัพธ์ |
|---|---|
{enter} | กด Enter (submit ฟอร์ม) |
{selectAll} | เลือกข้อความทั้งหมดในช่อง |
{del} | ปุ่ม Delete |
{backspace} | ปุ่ม Backspace |
{esc} | ปุ่ม Escape |
{upArrow} / {downArrow} | ปุ่มลูกศร ใช้กับ combobox บ่อย |
{ctrl}, {shift}, {alt}, {meta} | modifier key ที่กดค้างไว้จนจบข้อความ |
cy.findByRole('searchbox', { name: 'Search articles' }).type('kubernetes{enter}');
cy.findByLabelText('Display name').type('{selectAll}{del}New name');.clear() ทำงานเหมือน .type('{selectAll}{del}') แต่อ่านง่ายกว่า ถ้าต้องการพิมพ์ { ตามตัวอักษรจริง ให้ส่ง { parseSpecialCharSequences: false }
การแนบไฟล์
ตั้งแต่ Cypress 9.3 มี .selectFile() ในตัวแล้ว ไม่ต้องติดตั้ง plugin cypress-file-upload และใช้ attachFile อีกต่อไป
// A file from the project, path relative to the project root
cy.findByLabelText('Profile photo').selectFile('cypress/fixtures/avatar.png');
// Several files at once
cy.findByLabelText('Attachments').selectFile([
'cypress/fixtures/invoice.pdf',
'cypress/fixtures/receipt.pdf',
]);
// Drop onto a drop zone instead of using the input
cy.findByText(/drag files here/i).selectFile('cypress/fixtures/avatar.png', {
action: 'drag-drop',
});
// Content created in the test, no fixture file needed
cy.findByLabelText('Import CSV').selectFile({
contents: Cypress.Buffer.from('name,email\nAna,[email protected]'),
fileName: 'users.csv',
mimeType: 'text/csv',
});ปุ่มอัปโหลดแบบ custom มักซ่อน <input type="file"> ตัวจริงไว้ ถ้า input มี label อยู่ findByLabelText ก็ยังหาเจอ แต่ Cypress จะไม่ยอม interact กับ element ที่ถูกซ่อน จึงต้องเติม { force: true } ส่วนการตรวจว่าแอปส่งอะไรไปที่ server ให้ intercept request ของการอัปโหลด ตามที่อธิบายไว้ใน การ mock API ใน Cypress ด้วย intercept
ตัวอย่างเต็ม
test หน้า login ที่ใช้แต่ accessible query:
describe('Sign in', () => {
beforeEach(() => {
cy.visit('/login');
});
it('shows an error for wrong credentials', () => {
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('wrong-password{enter}');
cy.findByRole('alert').should('contain.text', 'Invalid email or password');
cy.findByRole('button', { name: 'Sign in' }).should('be.enabled');
});
it('opens the dashboard after signing in', () => {
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('correct-password');
cy.findByRole('button', { name: 'Sign in' }).click();
cy.location('pathname').should('eq', '/dashboard');
cy.findByRole('heading', { level: 1, name: /welcome back/i }).should('be.visible');
});
});test นี้ไม่ผูกกับ class name, id หรือการซ้อนกันของ element เลย designer จะเปลี่ยนหน้าตาฟอร์ม หรือ developer จะเปลี่ยน component library ก็ได้ test ยังผ่านตราบใดที่ฟอร์มยังใช้งานได้สำหรับผู้ใช้
คำถามที่พบบ่อย
findBy กับ findAllBy ใน Cypress Testing Library ต่างกันอย่างไร
findBy* ต้องเจอ element เดียวพอดี ถ้าไม่เจอหรือเจอเกินหนึ่งตัวจะ fail ส่วน findAllBy* คืนทุกตัวที่ตรง และ fail เฉพาะเมื่อไม่เจอเลย ใช้ findAllBy* กับ list และตาราง
ทำไม Cypress Testing Library ไม่มี getBy หรือ queryBy
command ของ Cypress retry ให้อัตโนมัติอยู่แล้ว ซึ่งก็คือสิ่งที่ findBy* ทำใน Testing Library ตัวอื่น จึงตัด getBy* กับ queryBy* ออกเพื่อไม่ให้มีสองวิธีทำเรื่องเดียวกัน ถ้าจะเช็กว่าไม่มี element ให้ใช้ .should('not.exist')
findByRole ช้าไหม
บนหน้าที่ใหญ่มากอาจช้ากว่า CSS selector เพราะต้องคำนวณ accessibility tree แต่ในหน้าแอปทั่วไปต่างกันไม่มาก ถ้าหน้าไหนช้าจริง ให้จำกัดขอบเขตด้วย within() หรือ query ต่อจาก parent
ควรลบ data-testid ออกทั้งหมดไหม
ไม่จำเป็น เก็บไว้ใช้กับ element ที่ไม่มี accessible role หรือข้อความ เช่น canvas หรือแผนที่ แค่อย่าใช้กับปุ่ม ลิงก์ และช่องในฟอร์มที่ผู้ใช้ระบุได้อยู่แล้ว
อัปโหลดไฟล์ใน Cypress โดยไม่ใช้ plugin ได้ไหม
ได้ ใช้ .selectFile() ที่มีในตัวตั้งแต่ Cypress 9.3 ส่ง path ของไฟล์, array ของ path หรือ object ที่มี contents, fileName และ mimeType
สรุปสิ่งที่ควรทำ
- ติดตั้ง
@testing-library/cypressแล้ว importadd-commandsใน support file - เริ่มจาก
findByRoleพร้อมnameถัดมาคือfindByLabelTextแล้วจึงfindByText - ใช้
findByTestIdเป็นทางเลือกสุดท้าย และลองเปิดthrowSuggestionsระหว่างย้าย test เก่า - ดู role และ accessible name ได้จากแท็บ Accessibility ใน DevTools
- ใช้
{enter},{selectAll}และ.clear()สำหรับคีย์บอร์ด และใช้.selectFile()สำหรับอัปโหลดไฟล์
เมื่อ query นิ่งแล้ว ขั้นต่อไปคือควบคุมข้อมูลที่อยู่เบื้องหลังหน้าเว็บ ส่วน Cypress component testing ก็ใช้ query ชุดเดียวกันนี้ทดสอบ component แบบแยกส่วน ถ้าต้องการคนช่วยวางกลยุทธ์การทดสอบให้เว็บแอป Vectorkub รับพัฒนาและทดสอบเว็บแอปที่ใช้งานจริงบน production
