สวัสดีครับ เมื่อวันที่ 25 – 26 เมษายน 2562 ที่ผ่านมา ผมได้มีโอกาสเข้าร่วมอบรมหลักสูตร Effective Android Testing ที่ Software Park มาครับ
สำหรับหลักสูตรนี้สอนโดย อ.สมเกียรติ ปุ๋ยสูงเนิน เจ้าของเว็บไซต์ Somkiat.cc ผู้โด่งดัง ที่นักพัฒนาแอนดรอยด์ในประเทศไทยน้อยคนนักจะไม่รู้จัก โดยเฉพาะเรื่อง Testing บน Android ต้องคนนี้เท่านั้น
เริ่มเลยดีกว่า (อาจจะดูงง ๆ ไม่เป็นหัวข้อเท่าไหร่นะครับ เพราะบางอันผมก็จดโน้ตทิปส์ที่เขาบอกมาระหว่างสอนอีกที)
การทดสอบโปรแกรมใช้เพื่อแสดงการมีอยู่ของบั๊ก แต่ไม่ได้แสดงว่ามันไม่มีบั๊ก
Edsger Dykstra, 1970, Notes on Structured Programming
ทำไมเราถึงต้องการการทดสอบ ?
- ช่วยในการหาบั๊ก
- ทำให้การพัฒนาเร็วขึ้น
- บังคับให้เขียนเป็น Module สำหรับข้อนี้ คนที่ไม่เคยเขียน Automate Testing จะไม่เห็นภาพเท่าไหร่ ต้องลองมาเจ็บด้วยตัวเอง แล้วจะรู้ว่าทำไม…(โค้ดส่วนไหนที่ไม่ค่อยต้องไปเปลี่ยนแปลงบ่อย ก็ทำเป็น Library แยกไปเลย)
แต่เรื่องพวกนี้ มันต้องใช้เวลา ทั้งเวลาในการเรียนรู้ และเวลาในการฝึกฝน
รู้หรือไม่ว่าโปรเจคของเราใหญ่ขนาดไหนแล้ว…มีเมธอดทั้งหมดเท่าไหร่ แล้วโค้ดส่วนไหนที่มีจำนวนเมธอดเยอะสุด ????
Gradle มีปลั๊กอินตัวนึง ชื่อว่า Dexcount พัฒนาโดย KeepSafe สำหรับรายละเอียดต่าง ๆ ไม่ว่าจะเป็นการติดตั้ง วิธีใช้งาน ก็ตามลิงค์นี้เลยครับ https://github.com/KeepSafe/dexcount-gradle-plugin
ยิ่งโปรเจค ๆ นึง ผ่านการพัฒนา ผ่านร้อนผ่านหนาวมานาน จำนวนฟีเจอร์ หรือจำนวนเมธอดก็จะเพิ่มขึ้น การจะเพิ่ม หรือ แก้ไขฟีเจอร์ จะยิ่งใช้เวลานาน เพราะว่า เมื่อมีการเปลี่ยนแปลง ย่อมมีส่วนที่ได้รับผลกระทบ ไม่มากก็น้อย ยิ่งถ้าออกแบบไว้แต่แรกไม่ดี ยิ่งมีโอกาสพังสูง
เมื่อเพิ่มหรือแก้ไขฟีเจอร์แล้ว…ไม่ใช่ว่าจะทดสอบฟีเจอร์ดังกล่าวว่าผ่านแล้วก็จบ ต้องทดสอบฟีเจอร์ที่เหลือทั้งหมดด้วย เพราะเราจะมั่นใจได้ยังไงว่าฟีเจอร์ดังกล่าว มันไม่ไปกระทบกับส่วนที่เราอาจไม่คาดคิด
แต่ฟีเจอร์ที่มีอยู่ มันเยอะมากเลยนะ…จะมาทดสอบทั้งหมด ก็ส่งงานไม่ทันสิ
เพราะงี้ไง เราเลยต้องมีสิ่งที่เรียกว่า Automate Testing
มี 2 ฟีเจอร์ (หรือโมดูล) ที่ใช้ Model ที่ชื่อว่า User เหมือนกัน ควรใช้คลาสเดียวกันเลยมั้ย ???
ถ้าหากว่าจะใช้คลาสนั้น จริง ๆ ต้องมั่นใจว่ามันคือคลาสเดียวกันจริง ๆ หรือถ้ามีการเปลี่ยนแปลงที่หนึ่ง ที่อื่นที่เรียกใช้คลาสนี้ ก็ต้องเปลี่ยนแปลงด้วย
แต่จริง ๆ แล้วควรแยกคลาสกัน สมมติว่า ฟีเจอร์นึง มอง User ว่าเป็น Customer แต่อีกฟีเจอร์มอง User ว่าเป็น Employee ทั้ง ๆ ที่มันก็เป็น User เหมือนกัน แต่ข้อมูลรายละเอียดภายในไม่เหมือนกัน
เราควรคิดถึงการทำการทดสอบตอนไหน ?
เราต้องคิดถึงการทำการทดสอบตั้งแต่เริ่มต้นโปรเจค ต้องตกลง Input / Output ของแต่ละฟีเจอร์/เมธอด ก่อนลงมือ
ควรมี UI Flow และ Case ต่าง ๆ ที่คาดว่าจะเกิดขึ้น
ที่สำคัญ ต้องมีการทดสอบครอบคลุมทุกขั้นตอน
การทดสอบ ไม่ใช่แค่เฟสหนึ่ง แต่เป็นกิจกรรมที่ต้องทำอยู่ตลอดเวลา
แล้วแต่ละขั้นตอน ควรมีจำนวนของเทสเคส เท่าไหร่ ?
ปกติ แม้ว่าเราจะมีเครื่องมือในการทดสอบอัตโนมัติแล้ว แต่เราก็ยังเน้นที่การทดสอบด้วยมือผ่าน UI อยู่ดี ซึ่งเป็นการทดสอบที่ทั้งใช้เวลามาก และเปลืองค่าใช้จ่าย
เราควรเน้นไปที่ Unit Test เพราะใช้เวลาในการทดสอบแต่ละกรณีใช้เวลาน้อยมาก ส่วนใหญ่อยู่ในระดับเสี้ยววินาที และขั้นที่สูงขึ้นมาก็ลดจำนวนของการทดสอบลง เหลือทดสอบด้วยมือเฉพาะกรณีที่สำคัญ ๆ เท่านั้น
เหตุผลที่ไม่ทำ Automate Testing
จากการสำรวจ พบว่า
- ไม่มีเวลา
- การทดสอบมันช้า และยากต่อการพัฒนา
- ไม่มีความรู้
- ขาดความเชื่อมันในผลลัพธ์ (ไม่เชื่อว่าทดสอบถูกวิธี)
- คิดว่าการทำ Unit Test เพียงพอแล้ว
พีระมิดของการทดสอบ
การทดสอบในอุดมคติ ทุก ๆ ฝ่ายจะช่วยกันทดสอบอยู่ตลอดเวลา และวนเวียนอยู่อย่างนั้นไปเรื่อย ๆ ต่างกับอีกรูปแบบหนึ่ง ที่เรียกว่า Cupcake Testing ที่ต่างฝ่ายต่างทดสอบของใครของมัน ทดสอบ ๆ ในกรณีเดิมซ้ำ ๆ ดังรูปข้างล่างนี้
แต่ก็มีคนเสนอการทดสอบในอีกรูปแบบหนึ่ง เรียกว่า Trophy Testing โดยแบ่งเป็น 4 ส่วน ดังนี้
- Static Testing ใช้เครื่องมือที่เรียกว่า Lint ช่วยในการตรวจสอบโค้ดของเราในขณะที่เรากำลังเขียนเลย
- Unit Testing ทดสอบการทำงานแต่ละเมธอด
- Integration Testing เป็นส่วนที่ต้องเน้นมากที่สุด เพราะการทำแค่ Unit Testing ไม่เพียงพอต่อการที่จะมั่นใจว่าเมื่อแต่ละเมธอด หรือ แต่ละฟีเจอร์ทำงานร่วมกันแล้วจะไม่ผิดพลาด
- End to End Testing ทดสอบการทำงานของทั้งระบบ
Android Testing
เข้าสู่หัวข้อหลักสักที หลังจากเกริ่นมานาน
ในโลกของการเขียนโค้ด Android (Native) นั้น จะแบ่งโค้ดออกเป็น 2 ส่วน นั่นคือส่วนที่เป็น JAVA (หรือ Kotlin) เพียว ๆ ซึ่งใช้ JVM (Java Virtual Machine) ในการรัน กับโค้ดที่เป็นส่วนของ Android ซึ่งใช้เครื่องจริงหรือ Emulator ในการรัน ดังภาพข้างล่างนี้
การสร้างคลาสทดสอบบน Android
คลาสสำหรับทดสอบโค้ดที่รันบน JVM เราจะวางไว้ที่ /src/test คลาสเหล่านี้เรียกว่า JVM Unit Test
ส่วนคลาสสำหรับทดสอบโค้ดที่รันเครื่องจริงจะวางไว้ที่ /src/androidTest คลาสเหล่านี้เรียกว่า Instrumentation Unit Test
สำหรับ JVM Unit Test เราจะทดสอบโค้ดที่เป็น Business Logic ที่เขียนด้วย JAVA เท่านั้น โดยใช้ JUnit และ Mockito เป็นตัวช่วยในการทดสอบ
ส่วน Instrumentation Unit Test จะทดสอบโค้ดของ Android ต่าง ๆ เช่น AssetManager หรือ SharedPeference เป็นต้น
สำหรับ Design Pattern แบบ MVP แต่ละส่วนเราจะทดสอบยังไง ?
Model – JVM Unit Test
Presenter – JVM Unit Test
View – Instrumentation Unit Test
UI vs Non-UI
JVM | Device | |
Non-UI | JVM Unit Test | Instrumentation Unit Test |
UI | Robolectric | Instrumentation UI Test |
src/test | src/androidTest |
การทดสอบกรณีไหนก็ตาม ที่ต้องใช้ Context ให้ใช้ Instrumentation Unit Test มันจะทดสอบเร็วมาก โดยขั้นตอนจะมีแค่ติดตั้ง APK (สำหรับทดสอบ), ทดสอบ แล้วก็ลบทิ้ง ซึ่งจะเหมือนการติดตั้งแอปพลิเคชันใหม่ทุกครั้งที่ทดสอบ
Ui Testing จะเน้นทดสอบฟังก์ชันหลักในมุมมองของผู้ใช้งาน เช่น ฟังก์ชันโอนเงิน ก็จะทดสอบแค่ว่าโอนเงินได้หรือไม่ ที่เหลือค่อยไปทดสอบในระดับต่ำลงมา
Robolectric เป็นเครื่องมือสำหรับทดสอบ UI ด้วย JVM เดี๋ยวจะลงรายละเอียดในภายหลัง
Instrumentation Test มันช้า ดังนั้นเราต้องพยายามแยกโค้ดส่วนที่เป็น Android ออกจาก JVM เพื่อการทดสอบที่เร็วขึ้น
Test Case
ชื่อของเทสเคสควรบอกว่าจะทำอะไร แล้วได้ผลลัพธ์เป็นอะไร (ควรได้ตั้งแต่ตอนคุยกันตอนแรก)
โครงสร้างของเทสเคส
- Arrange ก่อนจะทำการทดสอบเตรียมอะไรบ้าง
- Act ทำอะไร
- Assertion ตรวจสอบ
ใช้คำสั่ง ./gradlew tetDebugUnitTest หรือ ./gradlew tDUT ในการรัน
Code Coverage
Code Coverage สามารถแสดงส่วนที่มีความเสี่ยงสูงในโปรแกรม แต่ไม่สามารถทำให้ความเสี่ยงหมดไปเลย
Paul Reilly, 2018, Kotlin TDD with Code Coverage
การทำ Code Coverage ทำให้มั่นใจว่าการทดสอบของเราครอบคลุมโค้ดทุกส่วน
ใน Android Studio สามารถกดที่เมนู “Run ‘Test’ with Coverage” ก็จะแสดงส่วนที่เทสเคสครอบคลุมพร้อมกับได้ไฟล์ Report มาเลย
แต่ถ้าจะใช้ Command Line ในการสร้าง Report ต้องใช้เครื่องมือที่เรียกว่า Jacoco
สำหรับวิธีใช้ก็ง่าย ๆ เข้าไปที่ /app/build.gradle แล้วเพิ่มโค้ดข้างล่างนี้ที่ buildTypes
debug { testCoverageEnabled true }
และ
apply plugin: 'jacoco' jacoco{ toolVersion = "0.8.1" }
กด sync และรันคำสั่ง ./gradlew createDebugCoverageReport หรือแบบย่อ ๆ ./gradlew cDCR ไฟล์ report จะอยู่ที่ /app/build/reports/coverage/debug/index.html
ปล. Jacoco ทำได้แค่ Code Coverage ที่เป็น JVM Unit Test เท่านั้นนะครับ
การทดสอบแบ่งเป็น 2 ประเภท
- External Testing – ต้องสร้างไฟล์ APK ก่อนแล้วเอาไปติดตั้งเพื่อทดสอบ
- Internal Testing – สามารถทดสอบด้วยตัวเองได้เลย
UI Testing
ในที่นี้จะใช้ Espresso เป็นเครื่องในการทดสอบ ซึ่ง Espresso นี้เป็น API ที่พัฒนาโดยทีมกูเกิ้ลเอง ข้อดีคือ มีขนาดเล็ก, เรียนรู้และใช้งานง่าย, ไม่ต้องเขียน Wait หรือ Sleep และรองรับ JUnit4
พื้นฐานการใช้งาน Espresso
- onView(ID/Text) – จะดูอะไร (เน้นดูจาก ID มากกว่า Text)
- Perform – ทำอะไร (Click, Input Data, Swipe)
- Check/Match – ได้ผลอย่างไร
สามารถดูรายละเอียดคำสั่งต่าง ๆ ได้ที่ Espresso Cheat Sheet
บน Android Studio มีเครื่องมือตัวหนึ่งที่เรียกว่า Record Espresso Test อยู่ในเมนู Run ช่วยในการสร้างเทสเคสให้ แต่จากการทดลองใช้ พบว่าเทสเคสที่มันสร้างให้ซับซ้อนเกินความจำเป็น แต่เราสามารถใช้มันกับกรณียาก ๆ ที่เราไม่รู้จะเขียนเทสเคสยังไง โดยเฉพาะกับ Custom View ต่าง ๆ
วิธีใช้ ActivityTestRule แบบไม่ให้มันเปิด Activity อัตโนมัติ
ใช้ในกรณีที่ต้องเซ็ตค่าอะไรบางอย่างก่อนที่จะเปิด Activity (Put ค่าผ่าน Intent) จะใช้คำสั่งข้างล่างนี้ ตอนที่สร้าง ActivityTestRule
@Rule public ActivityTestRule<ResultActivity> activityTestRule = new ActivityTestRule<>(ResultActivity.class, false, false);
สำคัญที่พารามิเตอร์ตัวที่ 3 ที่เป็นพารามิเตอร์สำหรับ launchActivity
ตัวอย่างการออกแบบ Test Case
ในที่นี้จะทำการทดสอบกับหน้าล็อกอิน
ถ้าจะให้ดี ควรใช้ข้อมูลจริงมาใช้ทดสอบ
กรณีที่ Success
- Username และ Password ถูกต้อง
กรณีที่ Fail
- Username ผิด
- Password ผิด
- ไม่ใส่ Password
- ไม่ใส่ Username
- ไม่ใส่ทั้ง Username และ Password
- Login เกิน n ครั้ง
- Format ผิด
- ฯลฯ
จะเห็นได้ว่ากรณีที่ Fail มีมากกว่ากรณีที่ Success ดังนั้นต้องมานั่งคิดและคุยกรณีที่ Fail ให้ครบถ้วนและครอบคลุม อาจจะทำเป็นตาราง ดังรูปข้างล่าง
ควรแยกคลาสของเทสเคส กรณีที่ Success กับ กรณีที่ Fail เพราะมันเป็นคนละเรื่องกัน ไม่ควรเอามารวมกัน
ในเทสไม่ควรมี if
Data-Driven Testing
ใช้ในกรณีต้องทดสอบอะไรซ้ำ ๆ แต่มีการกรอกค่าอะไรบางอย่างต่างกันนิดหน่อย เช่น การกรอก Username และ Password ให้ครอบคลุมทุกกรณี ในที่นี้จะใช้ JUnit Parameterized
ถ้าใน 1 Flow มีหลายหน้า และมีหลายคนทำงานร่วมกัน ควรทำยังไง ?
แบ่งกันทำคนละหน้า และต้องคุยกันก่อนตั้งแต่แรกว่า อะไรอยู่ตรงไหน และแต่ละหน้าติดต่อกันยังไง
ประเภทของการทดสอบ
Coverage Test แค่ทำให้รู้ว่าเทสเคสของเราครอบคลุมซอร์สโค้ดของเราหรือไม่ ไม่ได้รับประกันว่างานเราไม่มีบั๊ก เพราะมันจะมีบางกรณีที่เราไม่ได้เขียนโค้ด หรือยังไม่ได้ทดสอบอยู่อีก
Monkey Test (Stress Test) ทดสอบความแข็งแกร่งของแอปพลิเคชันของเรา ประหนึ่งว่ามีลิงมากดมือถือมั่ว ๆ นอกจากนี้เรายังสามารถใช้ Profiler เพื่อจำกัด Resource ต่าง ๆ เพื่อดูขีดความสามารถของแอปพลิเคชันของเราเมื่อนำไปใช้บนเครื่องจริงที่แตกต่างกันได้
Unit Test ต้องแยก Business Logic ออกมาให้แยกขาดจากส่วนแสดงผล (ใช้ Design Pattern)
Design Pattern
มีมากมายหลายแบบ แต่ในที่นี้พูดถึงแค่ 2 แบบ
MVP – Model View Presenter
VIPER – View Interactor Presenter Entity(Model) Router(Flow)
View ควรมี Logic น้อยที่สุด เพราะถ้าจะทดสอบ ต้องใช้ UI Test ซึ่งเสียเวลา
แยก View ในแต่ละหน้าให้เป็น Component ย่อย ๆ เพื่อสะดวกต่อการ Reuse
Model ที่ใช้ใน View และ Presenter ควรแยกกัน เพราะทำหน้าที่และใช้งานต่างกัน ตัวอย่างเช่น ข้อมูลของ User ที่เราใช้แสดงผลบน UI น้อยกว่าที่ได้รับมาจากการเรียกผ่าน API
1 View อาจจะมีหลาย Presenter ก็ได้ เพราะใน 1 หน้าอาจจะมีหลายงาน ถ้ามี Presenter 1 อันต่อ 1 หน้า อาจจะทำให้ Presenter บวมได้
Test Double
คือการจำลองการใช้งานคลาสอื่น ๆ ที่เกี่ยวข้องกับคลาสที่เราจะทำ Unit Testing แบ่งเป็น 5 ประเภท
- Dummy ขอแค่มีให้ Compile ผ่าน อาจจะเป็น Null เลยก็ยังได้
- Stub สามารถควบคุมสิ่งที่ไม่สามารถควบคุมได้ (RESTFul API, เวลา) ให้ Return ผลลัพท์ที่เราต้องการ
- Spy สร้างและส่ง Object เข้าไปแล้วดูว่าเมธอดที่ต้องการโดนเรียกหรือไม่ ดูพฤติกรรมปลายทาง (ใช้ทดสอบไลบรารี่ที่เราไม่รู้ว่าโค้ดข้างในเป็นยังไง แต่รู้ว่าเมธอดไหนโดนเรียก)
- Mock เอาความสามารถของทั้ง 3 ข้อข้างบนรวมกัน และมี Logic มากขึ้น
- Fake จำลองขึ้นมาให้เหมือนจริง
Direct vs Indirect Test
สมมติว่าเราจะทดสอบเมธอดเกี่ยวการล็อกอิน
ถ้าเราดูที่ผลลัพธ์ที่ได้จากเมธอด Login เลย จะเป็น Direct Test
แต่ถ้าเราไปตรวจสอบดูว่าเมธอด View.Failure() ถูกเรียกหรือไม่ผ่านการ Spy จะเป็น Indirect Test
เราต้องเรียนรู้การใช้งานพื้นฐานให้เข้าใจคอนเซปต์ก่อน ค่อยไปหาไลบรารี่ช่วย เพราะถ้าเรายังไม่แม่นพื้นฐาน แล้วไปใช้ไลบรารี่เลย มันจะเป็นการเรียนรู้การใช้ไลบรารี่แทน
ถ้าตอนทดสอบต้อง Mock เยอะ แสดงว่าเรามาผิดทาง เพราะ Dependencies ของคลาสนั้นเยอะเกินไป
ถ้าเขียนคลาส Spy เอง จะใช้เวลาในการทดสอบน้อยกว่า เพราะ Mockito จะเสียเวลาตรงที่ต้องโหลดทั้งโปรเจคก่อนเริ่มทดสอบ
ดูเรื่อง Security ที่ OWASP ด้วย
ทำ Screenshot ระหว่างทำ UI Testing ด้วย Screengrab บน Fastlane
ใช้ Chrome Extension ชื่อว่า OctoTree เพื่อแสดง Project Tree ที่ Sidebar เวลาเข้าเว็บ Github เพื่อความสะดวกในการเข้าถึงไฟล์
Page Object / Robot Pattern
แนวคิดนี้เกิดมาจากฝั่งเว็บแอปพลิเคชัน โดยจะสร้างคลาสที่รวมเมธอดที่เป็น Action ต่าง ๆ ที่จะเกิดขึ้นในหน้านั้น ๆ เช่น ถ้าเป็นหน้าล็อกอิน ก็จะมีเมธอด กรอก Username, กรอก Password และ กดปุ่มล็อกอิน
เมื่อเขียนเทสเคสก็จะเป็นการร้อยเรียงเมธอดที่เขียนไว้ในคลาสข้างต้น
ข้อดีคือ ลดการเขียนโค้ด(สำหรับทดสอบ)ที่ซ้ำซ้อน และเมื่อมีการเปลี่ยนแปลงก็ไม่ต้องแก้หลายที่ (เช่นเปลี่ยน ID ของ EditText ที่ใช้กรอก Username)
สามารถใช้เครื่องมือที่เรียกว่า Kakao เพื่อช่วยให้การเขียนโค้ดสะอาดขึ้น
Robolectric
เป็นเครื่องมือสำหรับทำ UI Testing ด้วย JVM ใช้การ Simulation แทนการรันบน Emulator / Device
ข้อดีคือ เร็วกว่าทดสอบด้วย Espresso
ข้อจำกัดคือ เป็นการทำการทดสอบใน 1 หน้า Activity ไม่สามารถไป Activity อื่นได้ แต่ใช้การตรวจสอบ Intent ที่ส่งต่อแทน
คลาสสำหรับการทดสอบอยู่ใน src/test ไม่ใช่ src/androidTest
ตอนนี้เป็นส่วนหนึ่งใน AndroidX แล้ว
Networking Testing
เครื่องมือสำหรับทดสอบ
ภายนอก
- WireMock (JSON)
- Stubby4J (YAML)
- JSONServer (Node.JS)
ภายใน
- OkHTTP Mock Webserver (ใช้ได้ทั้ง Unit Test และ UI Test)
หัวใจของการทำการทดสอบคือ…สามารถทำซ้ำได้
วิธีตั้งค่า URL สำหรับการทดสอบในแอปพลิเคชันของเรา
- Static Variable
- Build Config + Flavor
- Custom TestRule -> Custom Application
- Config กลาง
- ทำ Activity Setting โดยเฉพาะสำหรับ Flavor Dev
- Share Code (Class เดียวกัน ต่าง Flavor ต่าง Code)
จริง ๆ ก็มีการทำ Workshop ด้วยการพาเขียนโค้ด แต่ผมขอไม่ลงรายละเอียดแล้วกันนะครับ เดี๋ยวมันจะยืดยาวไปมากกว่านี้