จัดการ Shadow DOM ในการทดสอบเบราว์เซอร์อัตโนมัติ
Shadow DOM แยกส่วนภายในของเว็บคอมโพเนนต์ออกจากภายนอกอย่างชัดเจน สไตล์ภายนอกไม่รั่วเข้ามา ตัวเลือกไม่รั่วออกไป และรายละเอียดภายในคอมโพเนนต์ยังคงถูกซ่อนไว้ ระบบออกแบบขนาดใหญ่ เช่น เฟรมเวิร์ก Katal ของ Amazon, Salesforce Lightning Web Components และ Material Web Components ใช้ Shadow DOM อย่างกว้างขวาง เพราะการแยกส่วนนี้ช่วยให้พฤติกรรมของคอมโพเนนต์คาดการณ์ได้และนำไปใช้ในระบบอื่นได้
ปัญหาคือการแยกส่วนที่ทำให้ Shadow DOM มีประโยชน์ในการใช้งานจริงกลับสร้างความยุ่งยากอย่างมากในการทดสอบอัตโนมัติ เฟรมเวิร์กสำหรับการทำงานอัตโนมัติส่วนใหญ่ รวมถึง Selenium, Cypress และ Playwright ในบางส่วน สร้างขึ้นโดยอาศัย document.querySelector ขณะที่ Shadow DOM ถูกออกแบบให้ document.querySelector ค้นหาเข้าไปไม่ถึง
เหตุใด Shadow DOM จึงทำให้การทำงานอัตโนมัติใช้ไม่ได้
ปัญหาหลักอยู่ที่ขอบเขตของ Shadow DOM เมื่อคอมโพเนนต์แสดงผลภายใน shadow root ต้นไม้ DOM ย่อยของคอมโพเนนต์จะอยู่ในส่วนเอกสารที่แยกต่างหาก ซึ่งการค้นหา DOM ตามปกติเข้าไปไม่ถึง คำสั่งอย่าง document.querySelector(‘.checkout-button’) จะไม่พบองค์ประกอบที่มีคลาสดังกล่าวหากอยู่ใน shadow root แม้องค์ประกอบนั้นจะปรากฏบนหน้าและผู้ใช้โต้ตอบได้ตามปกติก็ตาม
นี่ไม่ใช่ข้อผิดพลาดของเบราว์เซอร์ แต่เป็นการทำงานตามข้อกำหนด การแยกส่วนของ Shadow DOM ป้องกันไม่ให้ตัวเลือก CSS และการค้นหา DOM จากภายนอกเข้าถึงส่วนภายในของคอมโพเนนต์ ส่วน JavaScript จากภายนอกจะเข้าถึงได้ผ่านการอ้างอิงโดยตรงเท่านั้น เช่น พร็อพเพอร์ตี shadowRoot ของ root แบบ open เมธอด querySelector บน document หรือองค์ประกอบ DOM ทั่วไปจะหยุดที่ขอบเขตของ Shadow DOM ลองดูโครงสร้างนี้:
<!-- Light DOM -->
<katal-button>
#shadow-root
<button class="kds-button">Add to cart</button>
</katal-button>
ตัวเลือกอย่าง document.querySelector(‘katal-button .kds-button’) จะคืนค่า null องค์ประกอบนั้นมีอยู่ใน DOM แต่คำสั่งค้นหาเข้าไปไม่ถึง เฟรมเวิร์กสำหรับการทำงานอัตโนมัติที่ใช้สตริงตัวเลือกเพียงชุดเดียวจากรากของเอกสารจึงล้มเหลวโดยไม่แสดงข้อผิดพลาดในกรณีนี้ องค์ประกอบไม่ได้หายไป แต่ถูกแยกไว้ภายในคอมโพเนนต์
shadow root แบบ open และ closed
shadow root มีสองโหมดคือ open และ closed ความแตกต่างนี้มีผลอย่างมากต่อวิธีทำงานอัตโนมัติที่ใช้ได้
shadow root แบบ open เปิดให้เข้าถึงส่วนภายในผ่านคุณสมบัติ shadowRoot ขององค์ประกอบเจ้าบ้าน:
const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');
shadow root แบบ closed จะคืนค่า null เมื่ออ่าน element.shadowRoot ระบบคอมโพเนนต์ระดับองค์กรบางระบบใช้ shadow root แบบ closed ในสภาพแวดล้อมใช้งานจริง เพื่อป้องกันไม่ให้โค้ดภายนอกพึ่งพารายละเอียดการทำงานภายใน shadow root แบบ closed มีไว้เพื่อปิดกั้นวิธีเข้าถึงข้างต้นโดยเฉพาะ
ในทางปฏิบัติ วิศวกรที่ทำงานด้านระบบอัตโนมัติส่วนใหญ่พบ shadow root แบบ open บ่อยกว่า องค์ประกอบที่เบราว์เซอร์มีให้ เช่น <input type="date"> และ <video> ก็แสดงส่วนควบคุมใน shadow tree เช่นกัน แต่ root ที่เบราว์เซอร์สร้างเหล่านี้ไม่เปิดให้สคริปต์ของหน้าเว็บหรือตัวระบุตำแหน่งองค์ประกอบของ Playwright เข้าถึง ส่วน shadow root แบบ closed พบเป็นหลักในระบบออกแบบระดับองค์กร และต้องใช้แนวทางที่ต่างออกไปโดยพื้นฐาน ซึ่งเราจะกล่าวถึงแยกต่างหาก
ไล่ค้นหาทีละขั้นด้วย shadowRoot.querySelector
สำหรับ shadow root แบบ open วิธีตรงไปตรงมาคือไล่ค้นหาทีละขั้นอย่างชัดเจน แทนที่จะใช้ตัวเลือกเพียงชุดเดียวจากรากของเอกสาร คุณจะข้ามขอบเขตของ Shadow DOM ทีละชั้น ใน Playwright เมธอด evaluate เรียกใช้ JavaScript ในบริบทของหน้าเว็บและไล่ค้นหาผ่าน shadow root ได้โดยตรง:
// Doesn't work — evaluate can't return DOM elements by value
const button = await page.evaluate(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
// Works — evaluateHandle returns a handle to the live element
const buttonHandle = await page.evaluateHandle(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
await buttonHandle.asElement()?.click();
ความแตกต่างระหว่าง evaluate และ evaluateHandle มีความสำคัญในกรณีนี้ evaluate แปลงค่าที่คืนมาให้อยู่ในรูปแบบที่ส่งข้ามจากเบราว์เซอร์ไปยัง Node.js ได้ แต่โหนด DOM ไม่สามารถแปลงเช่นนั้นได้ จึงได้ค่า undefined แทนองค์ประกอบ ส่วน evaluateHandle คืนออบเจ็กต์แฮนเดิลที่คงการอ้างอิงไว้ในกระบวนการของเบราว์เซอร์ ทำให้เรียกคำสั่งโต้ตอบต่อได้
อย่างไรก็ตาม วิธีนี้เปราะบาง การอัปเดตคอมโพเนนต์ที่เรนเดอร์โหนดภายในใหม่หรือแทนที่โหนดนั้นจะทำให้แฮนเดิลยังคงชี้ไปยังองค์ประกอบที่หลุดออกจาก DOM หากซ้อนหลายชั้น เช่น shadow root ภายใน shadow root ที่อยู่ใน shadow root อีกชั้น โค้ดสำหรับไล่ค้นหาจะยาวและเสียได้ง่าย ยังมีวิธีที่ดีกว่านี้
ตัวเลือกที่ค้นหาข้ามขอบเขต Shadow DOM
ตัวระบุตำแหน่งองค์ประกอบแบบ CSS ของ Playwright ค้นหาข้าม shadow root แบบ open โดยค่าเริ่มต้น ดังนั้น page.locator('katal-button .kds-button') จึงเข้าถึงปุ่มได้อยู่แล้ว โทเคน >> ใช้เชื่อมตัวเลือก โดยค้นหาแต่ละส่วนเทียบกับผลลัพธ์ที่ตรงกันก่อนหน้า ทำให้เห็นโครงสร้างจากองค์ประกอบเจ้าบ้านไปยังส่วนภายในอย่างชัดเจน:
// Pierce through the shadow root of katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();
เมื่อใช้ >> ฝั่งซ้ายค้นหาองค์ประกอบเจ้าบ้าน ส่วนฝั่งขวาค้นหาโดยอิงจากองค์ประกอบนั้น เนื่องจากกลไก CSS ของ Playwright ค้นหาข้าม shadow root แบบ open จึงครอบคลุม shadow root ขององค์ประกอบเจ้าบ้านและ root แบบ open ที่ซ้อนอยู่ภายในด้วย หากซ้อนลึกกว่านั้นก็เชื่อมตัวเลือกต่อกันได้:
// Three levels deep
const input = page.locator('my-form >> my-field >> input[type="text"]');
วิธีนี้อ่านง่ายกว่าการไล่ค้นหาทีละขั้นมาก และรับมือกับการเปลี่ยนโครงสร้างของคอมโพเนนต์ได้ดีกว่ารูปแบบที่ใช้ evaluateHandle กลไกลองใหม่ในตัวของ Playwright ทำให้ตัวระบุตำแหน่งองค์ประกอบลองค้นหาซ้ำจนกว่าองค์ประกอบจะปรากฏ จึงรองรับการแสดงผลแบบอะซิงโครนัสโดยอัตโนมัติ
ตัวระบุตำแหน่งองค์ประกอบตามความหมายของ Playwright ได้แก่ getByRole, getByLabel และ getByText ก็ค้นหาข้าม shadow root แบบ open ได้เช่นกัน ใน Playwright ตัวระบุตำแหน่งทุกประเภทยกเว้น XPath ทำเช่นนี้ได้ สำหรับคอมโพเนนต์ที่รองรับ ARIA นี่เป็นทางเลือกที่ดูแลรักษาได้ง่ายที่สุด เพราะทดสอบพฤติกรรมแทนโครงสร้าง:
// Works if the shadow DOM button has accessible role and text
await page.getByRole('button', { name: 'Add to cart' }).click();
ข้อจำกัดของตัวเลือกที่ค้นหาข้ามขอบเขต Shadow DOM คือใช้ได้เฉพาะกับ shadow root แบบ open เท่านั้น ตัวระบุตำแหน่งองค์ประกอบของ Playwright ไม่รองรับ shadow root แบบ closed
รอให้ shadow root พร้อมใช้งาน
Shadow DOM ทำให้เกิดปัญหาเรื่องจังหวะเวลาที่การทดสอบ light DOM แทบไม่เคยพบ เมื่อองค์ประกอบแบบกำหนดเองได้รับการอัปเกรดและแนบ shadow root จะมีเหตุการณ์แบบอะซิงโครนัสสองอย่างแยกกัน คือองค์ประกอบปรากฏใน DOM และมีเนื้อหาแสดงภายใน shadow root การรอให้องค์ประกอบเจ้าบ้านปรากฏไม่ได้รับประกันว่าเนื้อหาภายใน shadow root พร้อมแล้ว
// Unreliable — waits for the host, not the shadow content
await page.waitForSelector('katal-button');
// This may still fail immediately after
await page.locator('katal-button >> .kds-button').click();
วิธีที่ถูกต้องคือรอเนื้อหาภายใน shadow root โดยตรง เนื่องจากตัวระบุตำแหน่งองค์ประกอบของ Playwright ลองใหม่โดยอัตโนมัติ locator.click() จึงจัดการกรณีนี้ได้อยู่แล้วในสถานการณ์ส่วนใหญ่ แต่คอมโพเนนต์ที่ใช้ข้อมูลและดึงการตั้งค่าก่อนแสดงผลอาจใช้เวลานานจนหมดเวลาลองใหม่ก่อนที่เนื้อหาจะปรากฏ การรออย่างชัดเจนด้วยตัวเลือกที่สื่อถึงสิ่งที่ต้องการจึงเชื่อถือได้มากกว่า:
// Wait for a specific shadow DOM element to become visible
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();
// For async-loaded components, wait for a data-ready indicator
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();
การเรียก waitFor อย่างชัดเจนยังให้ข้อความเมื่อเกิดข้อผิดพลาดที่บอกสาเหตุได้มากกว่า หาก waitForSelector(‘katal-button’) หมดเวลา หมายความว่าไม่พบองค์ประกอบเจ้าบ้าน แต่หาก waitFor({ state: ‘visible’ }) บนตัวระบุตำแหน่งที่ค้นหาข้ามขอบเขต Shadow DOM หมดเวลา หมายความว่าเนื้อหาภายใน shadow root ยังไม่พร้อมโดยเฉพาะ ความแตกต่างนี้สำคัญเมื่อตรวจหาสาเหตุของการทดสอบที่ผ่านบ้างไม่ผ่านบ้าง
การส่งต่อเหตุการณ์ข้ามขอบเขต shadow root
การจัดการเหตุการณ์ข้ามขอบเขต Shadow DOM มีรายละเอียดที่ส่งผลต่อพฤติกรรมของการทดสอบการโต้ตอบ เหตุการณ์ส่วนติดต่อผู้ใช้ที่เบราว์เซอร์ส่ง เช่น เหตุการณ์คลิก ป้อนข้อมูล และแป้นพิมพ์ เป็นเหตุการณ์แบบ composed (composed: true) จึงส่งผ่านขอบเขตของ Shadow DOM และส่งต่อขึ้นไปยังเอกสารภายนอกได้ เหตุการณ์อื่นส่วนใหญ่ รวมถึงเหตุการณ์สังเคราะห์ที่สร้างโดยไม่ได้กำหนด composed: true ไม่ใช่เหตุการณ์แบบ composed
แต่คุณสมบัติ target จะถูกเปลี่ยนเป้าหมาย ตัวฟังเหตุการณ์บนองค์ประกอบเจ้าบ้านจะเห็นองค์ประกอบเจ้าบ้านเองเป็นเป้าหมาย แทนองค์ประกอบภายใน Shadow DOM ที่เป็นจุดกำเนิดเหตุการณ์ เรื่องนี้มีผลหากการทดสอบของคุณตรวจสอบเป้าหมายของเหตุการณ์ หรือโค้ดของแอปพลิเคชันใช้ event.target เพื่อระบุว่าเกิดการโต้ตอบกับองค์ประกอบใด:
host.addEventListener('click', (e) => {
console.log(e.target); // logs the host element, not the internal button
console.log(e.composedPath()); // logs the full path including shadow internals
});
สำหรับ shadow root แบบ open นั้น event.composedPath() เป็นวิธีที่เชื่อถือได้ในการตรวจสอบว่าเกิดอะไรขึ้นจริงระหว่างการโต้ตอบ ส่วน root แบบ closed ตัวรับฟังเหตุการณ์ที่อยู่นอกองค์ประกอบเจ้าบ้านจะเห็นเส้นทางหยุดที่องค์ประกอบเจ้าบ้าน สำหรับการทดสอบที่ตรวจสอบพฤติกรรมการส่งเหตุการณ์ การตรวจ composedPath() แทน target จะให้ภาพที่ถูกต้อง
เหตุการณ์ที่กำหนดเองมีปัญหามากกว่า เพราะมีค่าเริ่มต้นเป็น composed: false จึงไม่ข้ามขอบเขต Shadow DOM เลย หากคอมโพเนนต์ส่งเหตุการณ์ที่กำหนดเองภายในโดยไม่ได้ตั้งค่า composed: true ไว้อย่างชัดเจน จะไม่สามารถสังเกตเหตุการณ์นั้นจากภายนอกองค์ประกอบเจ้าบ้านได้:
// Inside the component — this event will NOT reach outside listeners
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
bubbles: true,
composed: false, // default — event stays inside shadow root
detail: { value: selectedItem },
}));
// Outside the component — this listener never fires
host.addEventListener('katal-select', handler); // never called
เมื่อคอมโพเนนต์ไม่ส่งเหตุการณ์ออกมายัง light DOM จะไม่สามารถทดสอบการส่งเหตุการณ์จากภายนอกด้วยวิธีมาตรฐานได้ ทางเลือกที่ใช้ได้จริงคือใช้ page.evaluate เพิ่มตัวสังเกตภายใน shadow root ก่อนดำเนินการ หรือทดสอบการเปลี่ยนสถานะที่สังเกตได้ซึ่งควรเกิดจากเหตุการณ์นั้น แทนการทดสอบตัวเหตุการณ์เอง โดยทั่วไปวิธีหลังมีความหมายมากกว่าอยู่แล้ว การทดสอบว่าค่าที่เลือกสะท้อนอยู่ในแอตทริบิวต์สาธารณะของคอมโพเนนต์ทนต่อการเปลี่ยนแปลงได้ดีกว่าการทดสอบว่ามีเหตุการณ์เกิดขึ้น
วิธีรับมือกับ shadow root แบบ closed
สำหรับ shadow root แบบ closed วิธีที่อาศัยตัวเลือกทั้งหมดใช้ไม่ได้ คุณสมบัติ shadowRoot คืนค่า null ตัวเลือกที่ค้นหาข้ามขอบเขต Shadow DOM ข้ามขอบเขตแบบ closed ไม่ได้ และการไล่ค้นหาด้วย evaluateHandle ก็ล้มเหลวที่จุดเดียวกัน แนวทางที่จะใช้ต่อจึงต่างออกไปโดยพื้นฐาน
ใช้ API สาธารณะ เว็บคอมโพเนนต์ที่ออกแบบมาดีจะเปิดให้ใช้แอตทริบิวต์ คุณสมบัติ หรือ slot เพื่อควบคุมสถานะโดยไม่ต้องเข้าถึงส่วนภายใน หากคอมโพเนนต์รองรับ aria-checked การคลิกแล้วตรวจสอบแอตทริบิวต์ aria จะทั้งเหมาะกับการทำงานอัตโนมัติและถูกต้องตามความหมาย การทดสอบสิ่งที่คอมโพเนนต์เปิดให้เข้าถึงดูแลรักษาได้ง่ายกว่าการทดสอบสิ่งที่ซ่อนไว้
ทดสอบในระดับคอมโพเนนต์ คอมโพเนนต์ Shadow DOM มักทดสอบแยกได้ด้วยเครื่องมืออย่าง @web/test-runner หรือเครื่องมือทดสอบของ Lit ซึ่งให้สภาพแวดล้อมที่ควบคุมได้สำหรับทดสอบคอมโพเนนต์โดยตรงผ่าน API ของคอมโพเนนต์เอง เครื่องมือเหล่านี้ไม่ได้เปิด root แบบ closed ให้เอง แต่ช่วยให้ทดสอบ API สาธารณะแยกต่างหากได้ง่าย การทดสอบแบบ end-to-end ในระดับหน้าเว็บควรตรวจสอบพฤติกรรมการทำงานร่วมกัน ส่วนการทดสอบระดับคอมโพเนนต์ตรวจสอบความถูกต้องภายในได้
หารือกับผู้พัฒนาคอมโพเนนต์ การใช้ shadow root แบบ closed เป็นการตัดสินใจโดยตั้งใจ หากไลบรารีคอมโพเนนต์ขัดขวางการทดสอบอัตโนมัติที่สมเหตุสมผลโดยไม่มีส่วนติดต่อสาธารณะที่ทดสอบได้ให้ใช้ นั่นเป็นประเด็นด้านคุณภาพที่ควรยกขึ้นหารือ การใช้โหมด open พร้อมเอกสารที่ระบุชัดเจนว่าส่วนภายในใดมีความเสถียรเป็นทางสายกลางที่สมเหตุสมผล
รวมตัวระบุตำแหน่งองค์ประกอบใน Shadow DOM ไว้ที่เดียว
รูปแบบที่ทำงานได้อย่างเชื่อถือได้ในชุดทดสอบสำหรับระบบใช้งานจริงหลีกเลี่ยงการไล่ค้นหาแบบซับซ้อนที่เขียนแทรกไว้ในแต่ละจุด และใช้ตัวระบุตำแหน่งองค์ประกอบที่กำหนดไว้อย่างชัดเจนในที่เดียวแทน โมดูลตัวช่วยที่รวมเส้นทางค้นหาข้ามขอบเขต Shadow DOM ที่ใช้บ่อยไว้ช่วยให้อ่านการทดสอบได้ง่าย และจำกัดผลกระทบจากการอัปเดตคอมโพเนนต์:
// helpers/katal.ts
import type { Page } from '@playwright/test';
export const katalButton = (page: Page, text: string) =>
page.locator('katal-button >> button').filter({ hasText: text });
export const katalSelect = (page: Page, label: string) =>
page.locator(`katal-select[label="${label}"] >> select`);
export const katalInput = (page: Page, name: string) =>
page.locator(`katal-input[name="${name}"] >> input`);
// In tests
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');
เมื่อ Katal อัปเดตโครงสร้างภายใน คุณแก้ไขเพียงฟังก์ชันเดียวแทนการค้นหาทั่วทุกไฟล์ทดสอบ นี่เป็นหลักการเดียวกับที่ทำให้ Page Object Models มีประโยชน์ แต่นำมาใช้กับชั้นการแยกส่วนที่ Shadow DOM เพิ่มเข้ามาโดยเฉพาะ ขอบเขตของคอมโพเนนต์จึงกลายเป็นขอบเขตที่ตัวช่วยทดสอบใช้ซ่อนรายละเอียดการทำงาน
ปัญหาที่อยู่เบื้องหลัง
ความยากในการทำงานอัตโนมัติกับ Shadow DOM เป็นสัญญาณว่าเครื่องมือทดสอบยังตามรูปแบบคอมโพเนนต์ไม่ทันทั้งหมด เฟรมเวิร์กที่สร้างขึ้นโดยอาศัยการเข้าถึงผ่าน querySelector จากระดับเอกสารตั้งสมมติฐานว่า DOM เป็นโครงสร้างแบนและเข้าถึงได้ ซึ่งตรงข้ามกับสิ่งที่ Shadow DOM ถูกออกแบบมาให้ทำ
แต่การแยกส่วนนี้เป็นแนวทางที่ถูกต้อง คอมโพเนนต์ที่เปิดให้เข้าถึงเฉพาะส่วนติดต่อสาธารณะและซ่อนรายละเอียดการทำงานภายในดูแลรักษา อัปเกรด และนำมาประกอบกันได้ง่ายกว่า ความติดขัดในการทดสอบสะท้อนช่องว่างในวิธีที่เครื่องมือทดสอบมองการเข้าถึง DOM ไม่ใช่ข้อบกพร่องของ Shadow DOM เอง ตัวระบุตำแหน่งองค์ประกอบที่ค้นหาข้ามขอบเขต Shadow DOM ของ Playwright และตัวระบุตำแหน่งองค์ประกอบตามความหมายเป็นความก้าวหน้าไปสู่รูปแบบการทดสอบที่ทำงานร่วมกับการแยกส่วนนี้ได้
ข้อแนะนำที่นำไปใช้ได้จริงคือใช้ตัวเลือกที่ค้นหาข้ามขอบเขต Shadow DOM กับ shadow root แบบ open ทดสอบสถานะที่สังเกตได้แทนโครงสร้างภายในสำหรับแบบ closed และรวมตัวระบุตำแหน่งที่ไล่ค้นหาผ่าน shadow root ไว้ในที่เดียว เพื่อให้การอัปเดตคอมโพเนนต์ต้องแก้ไขเพียงจุดเดียวแทนทั้งชุดทดสอบ Shadow DOM จะยังคงอยู่ต่อไป แนวทางการทำงานอัตโนมัติจึงต้องออกแบบให้รองรับความจริงข้อนี้
บทความที่เกี่ยวข้อง
- ระบบอัตโนมัติบนเบราว์เซอร์ในวงกว้างโดยไม่ถูกบล็อก — ใช้งาน Playwright กับเว็บไซต์จริงอย่างเชื่อถือได้