แนะนำ 5 Best Practices ในการเขียนเทสด้วย Cypress ที่จะทำให้ Test ของคุณใช้ดีไปตลอดกาล

Traitanit Huangsri
Cypress.io Thailand
4 min readDec 11, 2020

สวัสดีครับ จากประสบการณ์ของการเขียนเทสด้วย Cypress ของผมรวมถึงคำถามและปัญหาที่หลายๆ คนใน Cypress Thailand Community สอบถามกันมา ผมขอรวบรวมมาเขียนเป็นบทความแนะนำ 5 Best Practices ในการเขียนเทสด้วย Cypress ที่อยากให้ทุกคนนำไปลองปรับใช้กันดูครับ

1. Do NOT login your web app via UI

เรื่องนี้เป็นสิ่งที่หลายคนถามกันเข้ามาเยอะครับว่า Web App ของเราจะต้องมีการ Login เพื่อเข้าไปใช้งานก่อนถึงจะเริ่มเทสได้ แล้วจะเขียนออโตเมตเพื่อเทสมันได้ยังไง? ยิ่งบางแอพก็มี Options ให้สามารถ Login ด้วย 3rd Party Authentication System (OAuth) อย่างเช่น Facebook, Google หรือ LINE Login ได้อีก ยิ่งปวดหัวไปใหญ่ว่าจะไป Login ในเว็บเหล่านั้นได้อย่างไร ในเมื่อ Cypress ไม่สามารถ Redirect ไปยัง Domain อื่นที่ไม่ใช่ Web ที่เราต้องการจะเทสได้

สิ่งที่น่าสนใจคือ ถ้าเราไม่ได้จะเทส Login feature บนเว็บของเราแล้ว ทำไมเราจะต้องไป Login มันด้วย UI ล่ะ? เพราะการทำ Login บน UI แล้วต้องการให้มัน Automate แบบ Seamless ได้เหมือนเวลาที่ User ใช้งานนั้นไม่ใช่เรื่องง่ายเลยครับ เพราะว่า Authentication Process นั้นค่อนข้างซับซ้อนและมี Dependency สูงครับ ซึ่งมีผลทำให้เทสของคุณเกิด Flaky Test ได้ง่ายและทำให้ไม่ Stable ในระยะยาวด้วย

Login without UI

ดังนั้นถ้าเราไม่ได้จะเทสตัว Login feature ก็ขอให้ใช้วิธีการสร้าง Internal API ที่สามารถ Generate User Access Token หรือ Authentication Token ที่ Web App ได้มาหลังจากที่ Login กับ Authentication Service เรียบร้อยแล้ว (Developer ผู้ที่จัดการเรื่อง Authentication จะต้องเข้าใจเรื่องนี้เป็นอย่างดี อาจจะขอความช่วยเหลือตรงนี้ได้) แล้วก็ให้ Cypress นำ Token ที่ได้มา save ลงใน internal storage ที่ web app ใช้ในการส่งไปหา server เวลาที่มีการเรียกใช้ API ต่างๆ อีกทีครับ ซึ่งส่วนใหญ่พวก token มักจะนิยมเก็บไว้ใน sessionStorage หรือ Cookies ของ Web Browser ครับ แต่ทั้งนี้และทั้งนั้นก็ต้องดูว่าจริงๆ แล้ว Web App ของเราอ่านค่า token มาจากตรงไหนอีกทีนะครับ

2. Seeding Database with Internal API

หลายๆ Test Scenario อาจจะต้องมีการเตรียมข้อมูลบางอย่างไว้ใน Database ที่ฝั่ง Backend ก่อนถึงจะเริ่มเทสได้ ซึ่งผมเคยเห็นหลายคนเขียนโค้ดใน Test เพื่อไปต่อ Database โดยตรง ซึ่งส่วนตัวผมมองว่ามันไม่ใช่ Practice ที่ดีซักเท่าไรครับ เพราะในโค้ดเทสของเราจะมีส่วนที่ต้องไปติดต่อกับ Database แถมยังต้องคอย Maintain Connection ระหว่างเทสกับ Database อีกต่างหาก ปวดหัวเลย

วิธีที่อยากจะแนะนำคือให้เราสร้าง Internal REST API ขึ้นมาสำหรับ Seeding Database for Test โดยเฉพาะไปเลยครับ ซึ่งเราอาจจะเลือก Tech Stack อะไรก็ได้ที่เราถนัด เช่นถ้าเราถนัดเขียน Javascript ก็อาจจะเขียน Internal API ด้วย Node.js + Express + TypeORM ไปก็ได้ครับ เขียนได้ไม่ยากเลย เดี๋ยวนี้ Document ต่างๆ ก็อ่านและสามารถทำตามได้ไม่ยากเลยครับ

Seeding database with Internal API

โดยในขั้นตอนของการ Set Up Test เราสามารถทำการ Seeding Database ผ่าน Internal API ที่เราสร้างไว้โดยใช้ command cy.request() เพื่อทำการ Seed Data เข้าไปได้ (โดยเราอาจจะทำการ Clear Data เก่าออกก่อน)

3. Setup Deterministic Testing Pattern

ผมเชื่อว่าหลายคนน่าจะเคยเจอสถานการณ์ที่เทสของเราไม่สามารถกำหนด Expected Result ได้อย่างชัดเจนจนกว่าจะรันเทสจริง ยกตัวอย่างเช่นเรากำลังเทสระบบ Payment App ซักอันนึงที่ระบบจะมีการสร้างเลข Transaction Id ให้เราอัตโนมัติเมื่อการซื้อครั้งนั้นสมบูรณ์แล้ว ดังนั้นเวลาเราเทสเราก็อาจจะเช็คแค่ว่ามีเลข Transaction Id แสดงผลอยู่ในหน้าเว็บเรานะ แต่ไม่ได้เช็ค Content ของมันว่ามันเอา Data มาแสดงผลถูกต้องจริงๆ หรือไม่

Deterministic Testing Pattern

วิธีการที่จะทำให้เราสามารถเช็ค Content ของ UI ของ Web App ของเราว่ามันแสดงผลอย่างถูกต้องจริงๆ หรือไม่ สามารถทำได้โดยสร้าง Deterministic Testing Pattern หรือการกำหนดผลลัพธ์ของการแสดงผลโดยให้ Test เป็นคนกำหนดนั่นเอง เพราะฉะนั้น Test ก็จะสามารถ Expect ได้อย่างชัดเจน (Explicitly) ว่า Web App ของเราควรจะแสดงผลค่าเป็นอะไร

ตัวอย่างหน้า Purchase Order History

โดยบน Cypress เราสามารถใช้คำสั่ง cy.intercept() (เริ่มใช้ได้ตั้งแต่ใน Cypress 6.0) ในการ Intercept HTTP Request/Response ที่ถูกส่งออกจาก Web App ของเราได้ โดยเราสามารถกำหนดได้เลยว่าอยากจะให้ Response ที่ได้กลับมาจาก Server หน้าตาเป็นอย่างไร และจากนั้นเราก็ทำการเช็คใน UI ของ Web App ของเราได้เลยว่ามันแสดงผลออกมาตามค่าที่เรากำหนดไว้หรือไม่ ซึ่งจะช่วยให้ Test ของเราสามารถ Control Data ที่ส่งมาจาก Server ได้อย่างสมบูรณ์แบบครับ

ตัวอย่างการใช้งาน cy.intercept()

นอกจากนี้การใช้ cy.intercept() ยังทำให้เราสามารถสร้าง Negative Test Scenario ได้อีกด้วย เช่นจำลองการทำ Error Handling เมื่อ Server return HTTP error กลับมายัง Web App ของเรา เป็นต้น น่าสนใจมากๆ เลยว่าไหมครับ

4. Synchronize Your Tests

หนึ่งในข้อดีของ Cypress ก็คือมันจะ Automatic Wait for elements ให้เราอัตโนมัติ แต่บางครั้ง Web App ของเราก็อาจจะมี Asynchronous Tasks บางอย่างเพิ่มเติม เช่นการทำ Network Request ต่างๆ ซึ่งถ้าเราต้องการจะทำให้ Test ของเรานั้นมีการ Synchronous รอให้ Async Tasks ทำงานเสร็จก่อนแล้วค่อยรัน Test Step ถัดไปก็สามารถทำได้โดยการใช้ command cy.wait() เพื่อให้มันหยุดรอจนกว่า Async Tasks ที่เราสนใจนั้น Fulfill เรียบร้อยแล้ว

แต่เราก็ไม่ควรจะใช้ cy.wait() แบบระบุจำนวนเวลาเข้าไปโดยตรง เช่น cy.wait(5000) เพื่อหยุดรอ หรือเหมือน Sleep 5 วินาที เพราะนั้นคือ Non-Deterministic Testing Pattern ซึ่งเราไม่มีทางรู้ได้เลยว่า Async Task นั้นสามารถทำงานเสร็จใน 5 วินาทีทุกครั้งหรือไม่ เป็น Anti-Pattern ที่ไม่ควรทำอย่างยิ่งเลยนะครับ

สิ่งที่เราควรทำคือ Synchronize by Task โดยผ่านการใช้ Cypress Alias เข้ามาช่วยครับ เช่นเราต้องการรอให้ Network Request นั้นมีการ return response กลับมาก่อนค่อยทำเทสต่อไปแบบนี้

Synchronized Tests

5. Avoid Coupling Tests

Practice สุดท้ายที่อยากจะแนะนำก็คือการทำให้ Test แต่ละข้อนั้นมีความเป็นอิสระต่อกัน (Independent Tests) เพราะถ้าเราอยากจะรันเทสเพียงเฉพาะข้อใดข้อหนึ่งนั้นจะทำไม่ได้เลย เพราะมี Test ที่ต้องรันข้อก่อนหน้ามาก่อนถึงจะรันเทสของตัวเองผ่านอะไรแบบนั้น

มีใครเคยเห็นคนเขียนเทสในลักษณะตามตัวอย่างด้านบนนี้ไหมครับ ? คือเทสข้อหนึ่งมีการกำหนด Data ที่จะนำมาแสดงผลในหน้า Purchase History และเทสข้อต่อมาก็ไปใช้ Condition ที่ถูกสร้างในเทสข้อแรก และเพียงใส่ logic การเช็ค Address เพิ่มเข้าไป เช่นเดียวกันกับเทสข้อที่ 3 ที่เช็คตัว Product Name เพิ่มเติม

เราจะเห็นได้ว่าตัวอย่างเทสข้างบนนี้มี Dependent ต่อกัน กล่าวคือเราจะไม่สามารถรันเทสข้อที่ 2 และ 3 แยกเดี่ยวๆ ได้นั่นเอง

วิธีแก้ง่ายๆ คือการใช้ Set Up ของการเขียนเทสในการกำหนด Condition ต่างๆ ที่ต้องใช้ร่วมกันใน Test Case แต่ละข้อนั่นเอง ไม่ยากเลยใช่มั้ยครับ

เมื่อเราย้าย Condition ที่ต้องใช้ร่วมกันในแต่ละ Test Case ไปไว้ที่ Setup beforeEach() แล้วก็จะทำให้เราสามารถรันเทสแต่ละข้อได้อย่างเป็นอิสระต่อกันแล้วครับ

แถม

ผมยังมีอีก 1 Practice ที่อยากแนะนำให้ทุกคนที่เขียนเทสนำไปใช้กันคือการกำหนด Custom Attribute ที่ใช้ในการเข้าถึง UI Element แทนการใช้พวก CSS Class ต่างๆ ซึ่งมันมีโอกาสเปลี่ยนแปลงได้ตลอดเวลาตามการใช้งานของ User

โดย Custom Attribute คือ Attribute ที่เราใส่เข้าไปเพิ่มใน HTML element ของเรา ซึ่งมันจะไม่มีวันเปลี่ยนแปลงค่าไปเมื่อมี Action จาก User (ยกเว้น element มันจะถูกดึงออกจาก DOM ไปนะครับ) ซึ่งทำให้เรามั่นใจได้ว่า Element ที่เราสนใจนั้นจะต้องค้นหาอย่างไร และจะช่วยประหยัดเวลาในการที่เราจะต้องมา Refactor เพื่อคอยเปลี่ยน Selector ไปเรื่อยๆ

<img src="test.jpg" alt="test" data-testid="testImg" />
// accessed by Cypress
cy.get([’data-testid=["testImg"]’).should(’have.attr’, 'alt’, 'test’);

ซึ่งเราอาจจะตกลงกันภายในทีมว่าเราจะใช้ Custom Attribute เป็นชื่ออะไร เช่นอาจจะเป็น data-testid="image" ก็ได้ครับ เพราะสามารถใช้งานร่วมกับ Testing Library ที่อาจใช้อยู่ในการทำ Unit Test อยู่แล้วก็ได้

สรุป

หวังว่าบทความนี้จะมีประโยชน์ไม่มากก็น้อยสำหรับผู้อ่านทุกท่านนะครับ ซึ่ง Practice หลายๆ อย่างที่ผมได้เขียนไว้ในบทความนี้ก็สามารถนำไปปรับใช้กับการทำเทสด้วย Framework อื่นๆ ได้เช่นเดียวกัน ถ้าใครที่มี Practices อื่นๆ ที่อยากจะแนะนำเพื่อนๆ ก็สามารถเข้าร่วมกลุ่มกับเราที่ Cypress.io Thailand แล้วมาโพสแนะนำกันได้นะครับ Happy Testing!

--

--