มาเขียนเทสกัน กับงาน Android Codelabs Together ตอน Testing Basics
ฝั่ง Firebase เขาก็มีกันไปแล้ว เป็น Codelab เขียนเว็บ แล้วฝั่ง Android จะยอมน้อยหน้ากันได้อย่างไรจริงม่ะ
งานจะเริ่มในวันอาทิตย์ที่ 21 มิถุนายน เวลาสองทุ่มถึงสี่ทุ่ม มีความเริ่มดึกๆหล่ะ แต่เข้าใจได้ว่าทำไมเป็นเวลานี้ 5555555555 โดยพบกับคนคุ้นเคย คือ คุณเอก Android GDE และน้องเบน นั่นเองงงงงงง
จริงๆในเพจ Google Developer Group Thailand ก็ได้เฉลยแล้วว่า Codelab ที่เราจะมาทำไปพร้อมๆกันในงาน Android Codelabs Together นั่นก็คือ Testing Basics นั่นเอง
ช่องทางการรับชมจะอยู่ที่นี่จ้า ชมย้อนหลังได้เลย
หรือดูย้อนหลังใน YouTube ก็ย่อมได้
ก่อนรับชมอย่าลืมอัพเดต Android Studio เป็น version ล่าสุดก่อนนะ ซึ่งก็น่าจะเป็น version 4.0 แหละ
ถึงเวลาแล้ว มาปักรอดูกัน เอ่อออออออ ดูไลฟ์ยังไงนะ ฮืออออออ
และในที่สุดก็หาไลฟ์เจอในเพจ GDG Thailand จ้า
ช่วงแรกจะมีพี่โอ๋มาเปิดงาน พร้อมข่าวอัพเดตเล็กๆน้อยๆจ้า
- event ของ Google จะ focus ที่ online event เป็นหลัก ทำให้ทุกคนได้เข้าพร้อมกันทั่วโลกแบบไม่ต้องเดินทางไปไหน เช่น Android 11
- event ถัดไปก็จะเป็น web.dev LIVE นั่นเอง สำหรับสายเว็บโดยเฉพาะ เป็นงาน Global เนอะ เราทิ้งวาปไว้ที่นี่จ้า เข้าไปในเว็บเราจะเห็น Agenda ของแต่ละวัน โดยงานจะจัดวันที่ 30 มิถุนายน - 2 กรกฏาคม จ้า
- วันที่ 14 กรกฏาคม - 8 สิงหาคม จะมีงาน Google Cloud Next OnAir จ้า เปิดตัว speaker แล้วแต่ยังไม่ได้เปิด agenda เนอะ
- เห็นพี่ตี๋มา comment ว่ามีงาน Firebase Live นะ เริ่มวันที่ 23 มิถุนายนนี้แล้ว จัดเป็น week ๆ เข้าไปส่องตารางได้ด้านล่างจ้า
มาเริ่มทำ Codelab~ กันเลย
ทำไมเลือกอันนี้หล่ะ Automate testing—the basics เพราะอยากให้ Android Developer เขียน Test กันมากขึ้นนั่นเอง ซึ่งทางเราได้เดาถูกแล้วแหละ ในที่น้ีจะเป็นการทำ Unit Test นั่นเอง
แต่ทำเสร็จวันนี้ไม่ได้ badge นะ เพราะมันอยู่ใน Advanced Android in Kotlin นั่นเอง ในนั้นมีทั้งหมด 6 บทด้วยกัน แต่ทางเราไปเจอใน Add advanced features to Android apps in Kotlin ซึ่งเป็นชุดสำหรับเรียนรู้การเป็น Android Kotlin Developer นั่นเอง
Welcome
มาเล่าเรื่องกันสักหน่อยเนอะ การเขียนเทสนั้น แก้ปัญหาโค้ดที่เคยเขียนแล้วพออกลับมาดูแล้วก็งงๆ ว่าโค้ดนี้ทำงานอะไรนะ แล้วก็ทำให้เราทำ CI/CD ได้ง่ายด้วยนะ และการเขียนโค้ดด้วยเทสนั้น ทำให้ทำของใหม่ๆได้เร็วกว่า เพราะการทำ manual test กับให้ tester ไปเทส อาจจะมีบางเคสที่หลุดๆไปบ้าง
ด้วย test case ที่เราทำไว้ ทำให้เราสามารถตรวจสอบได้ว่าโค้ดที่เราเขียนนั้น ทำงานไม่แย่ไปจากเดิม
ดังนั้นใน codelab นี้จะมีการทำ Unit Test กับ ViewModel
และ LiveData
ด้วย เพราะว่าโปรเจกตัวอย่างนี้นั้น เป็น MVVM เนอะ และตัวโปรเจกใน codelab เราก็สามารถเข้าไปศึกษาดูได้ว่าเขาเขียนกันยังไงนะ
แล้ว TDD คืออะไรกันหล่ะ? TDD ย่อมาจาก Test-Driven-Development จะเป็นการเขียนเทสก่อนเขียนโค้ดใน feature นั้นจริงๆ เช่น เรามี feature A เราจะมาเขียนเทสกันก่อนว่า feature นี้ควรจะมี test case อะไรบ้าง และนำมาเขียนโค้ดให้ test passed และถ้ามีการ refactor code เราจะสามารถตรวจสอบการทำงานของโค้ดที่เรา refactor ไปด้วย test case เหล่านี้ว่าที่เพิ่งแก้ไปยังทำงานถูกต้องเหมือนเดิมหรือไม่
App overview
โปรเจคตัวอย่างนี้ เป็นแอพ Todo ที่สร้าง task ได้ ภายในใส่ title และ detail ลงไปในนั้น อันไหนทำเสร็จแล้วก็ติ๊กครับ และมีความจริงจังถึงขึ้นมี filter ด้วยนะ ว่าอันไหนทำเสร็จยัง อันไหนยังทำไม่เสร็จ และลบ task ที่เราต้องการ หรือลบหมดเลยงี้
Getting Started
เป็นขั้นตอนที่ยากที่สุดเลย555555 หยอกๆจ้า ให้ download zip หรือ clone git ลงมา
จากนั้นเปิดใน Android Studio รอมัน sync gradle เสร็จ ซึ่งกินเวลาตามเครื่องที่เราใช้ ของเราก็ประมาณ 5 นาทีได้ ก่อนหน้านี้เลือก config แอพไม่ได้ต้องแก้อีก ก็ราวๆ 10 นาทีแหน่ะ
Task: Familiarizing yourself with the code
มาทำความรู้จักของโปรเจกนี้กันว่าทำอะไรได้บ้างอ่ะเราอ่ะ
package ของ feature จะมี 4 อัน คือ
addedittask
เพิ่มหรือแก้ taskstatistics
แสดง stat ของ tasktaskdetail
แสดงรายละเอียดของ task นั้นๆtask
แสดง task ทั้งหมดที่มี
แล้วก็มี util
พวก extension ต่างๆ ซึ่งใครๆก็ทำกันอยู่แล้ว เช่น extension ของ snackbar อ่ะเนอะ และ data
จะมีพวก database, network, repository ในนั้น
structure ตัวนี้ จะถูกใช้ใน codelab ตัวอื่นๆด้วย เช่น โปรเจกใน udacity, sunflower
และเป็น Single-page Activity ด้วย (ทางเราจะพยายามเขียนบล็อกเกี่ยวกับเรื่องนี้ต่อไป เนื่องจากแอพอ่านบล็อกเราก็เป็นแนวทางนี้เช่นกัน) เพราะว่าเขาอยากให้ focus กับ core feature ของแอพที่เราทำกันนั่นเอง
มาดูโครงสร้างภายในกัน
- แต่ละ feature นั้น จะประกอบไปด้วยส่วน
View
ที่อาจจะเป็นFragment
และViewModel
- มีเจ้า
Repository
เป็นคนดึงข้อมูลและเก็บข้อมูลให้เรา use case ที่เราพบเห็นได้ประจำคือ ใน Facebook เรากด like โพสนึง ตอนเลื่อนกลับไปดูก็ต้องเห็นว่าเรากด like โพสนี้ไปแล้วเนอะ ซึ่งการทำงานเบื้องหลังคือ ถ้าเรากด like โพสนั้นแล้ว แต่พอดีว่า internat สัญญาณไม่ดี เลยส่ง data ไปยัง server ไม่ได้ จึงเก็บลง local storage ในที่นี้คือRoom
ไปก่อน ถ้า internet กลับมาดีแล้ว จึงจะส่ง data ไปยัง server ผ่านNetwork
หรืออีกเคสนึงคือ หิวข้าวมาก กดสั่งอาหารในแอพ food delivery เราต้องเห็นอาหารที่เราเลือกในตะกร้า ถ้าหายไปคงไม่ค่อยอยากจะใส่เพิ่มใหม่กัน Network
ในที่นี้เป็นการเขียน data sourse จำลองให้เขียนเทสได้ง่ายขึ้น ปกติก็คือส่วนที่เรียก API นั่นแหละ- data binding ทำให้เราสามารถทำ two-way binding ได้ง่ายขึ้น เช่นปกติเราจะ set text เองผ่านโค้ด แต่อันนี้เราสามารถ update ค่าเข้าไปใน view ได้เลย
เพิ่มเติมจากเจ้าของบล็อก ใน C# ที่เป็น MVVM ก็จะมี data binding เป็นปกติกันอยู่แล้วนะ
ในที่นี้จะตัด data binding และ navigation component (เส้นลากต่างๆ เช่น action นึกง่ายๆจะเหมือน Storyboard ของ iOS อ่ะ) ออกไปก่อน
Task: Running tests
เกริ่นกันมายาวนาน มาทำเทสกันเถอะ 555555555555
ในที่นี้ก็จะมี test 2 ประเภท ก็คือ
local test
(test) การทำ Unit Test ปกติจะทำลงช่องนี้กัน รันโดย jvm บนคอมของเรา จะเน้นเทสโค้ด logic จ๋าๆinstrumental test
(androidTest) เป็นการเทสโดยใช้ resource ของ device มาสร้าง environment เพื่อทำการรันเทส จะเน้นเทสกับสิ่งที่เป็น Android จ๋าๆ
ทั้งสองส่วนนี้จะไม่ฝังไปกับ app ด้วย และมี source set ที่เราสามารถไปแยก build varient เช่น staging, production ยิง server คนละตัวกัน โดยให้ gradle build ให้โดยไม่ต้องสลับ endpoint ดังนั้นเราจะต้องใส่ให้ถูกต้องด้วยนะ
การรันเทส กดที่ปุ่ม play เขียวๆ มีแบบ test function เดียว กับ test ทุก function ใน class นั้นๆ
ถ้ามีหลายไฟล์ กดที่ folder แล้วคลิกขวา แล้วเขาจะรันเทสให้เราเนอะ
ถ้าเราเคยเลือกกด run อะไรตรงไหน ทั้ง build หรือ test ก็สามารถกดไปดูได้ และถ้าเยอะเกินไปก็เข้าไปลบได้
ลองทำให้ test failed หน่อยซิ๊ เพิ่มไป 1 บรรทัด แน่นอน fail แบบไม่ต้องสืบ เราจะได้ AssertionError
มาเป็นที่ระทึก
เมื่อเรามีเจ้า test case เยอะๆ แล้วมี test failed น้อยๆ เราสามารถประหยัดเวลาชีวิตได้โดยกดตรง Rerun Failed Tests ที่มีเครื่องหมายตกใจสีแดงๆ
มาลองรัน Instrumental test กันเถอะ ตัวอย่างเราจะทำการ check package name ว่ามันตรงกันไหมนะ แน่นอนมันจะนานหน่อย เพราะมีการ sync gradle เหมือนไปรันแอพในเครื่องจริงๆเลย
และนี่คือผลที่ได้จ้า
Task: Writing your first test
ได้เวลาเขียนเทสกันเสียที555 โดยการจำลอง data ใน class ที่เราจะเทสนั่นเอง
มาสร้างเทสกัน class ที่เราทำกระทำการเทสกันนั่นคือ StatisticsUtils
โดยจะเทส function getActiveAndCompletedStats
สร้างไฟล์เทสขึ้นมาใหม่โดยเลือก Generate -> Test... หรือ key ลัดก็คือ Command + N ที่ชื่อ function ก็ได้นะเออ
จากนั้นจะมี Dialog ขึ้นมา เลือก Testing library เป็น JUnit4 ส่วน Class name สามารถเปลี่ยนได้นะ แล้วยังไม่ต้องติ๊กอะไรนะ เรียบร้อยแล้วกด OK
อันนี้ต้องระวัง เพราะเลือกผิด ชีวิตเปลี่ยนทันที ตั้งสติกันก่อน เราทำ local test เนอะ ดังนั้นเลือก folder ที่เป็น test นะ
ถ้าทำถูกต้องหน้าตาจะเป็นแบบนี้
มาเขียนเทสกันได้ล้าววว ในที่นี้เราอยากเทสว่า ถ้าไม่มี task ไหนที่ทำเสร็จ และมี 1 task ที่ยัง active อยู่ ดังนั้น active tasks เป็น 100% และ complete tasks เป็น 0% โดย function นี้จะชื่อว่า getActiveAndCompletedStats_noCompleted_returnsHundredZero
และใส่ annotation @Test
ลงไป เพื่อให้รู้ว่า อันนี้เป็น function ที่เราจะใช้ทำเทสกันนะ
จากนั้นทำการเพิ่มสิ่งต่างๆ ซึ่งสุดท้ายจะเป็นดังนี้ และก็ควรจะ test passed อะนะ
class StatisticsUtilsTest {
@Test
fun getActiveAndCompletedStats_noCompleted_returnsHundredZero() {
// Create an active task (the false makes this active)
val tasks = listOf<Task>(
Task("title", "desc", isCompleted = false)
)
// Call your function
val result = getActiveAndCompletedStats(tasks)
// Check the result
assertEquals(result.completedTasksPercent, 0f)
assertEquals(result.activeTasksPercent, 100f)
}
}
การเอาของมาเปรียบเทียบกัน มักจะใช้แบบนี้ assertEquals(expect, actual)
เพื่อให้กลับไปอ่าน test case แล้วเข้าใจว่าเราต้องการเทสค่าไหนอยู่งี้ จึงมักเอา expect ไว้หน้า actual กัน
Hamcrest คือ ให้ code ของ test case ของเรา สามารถอ่านได้ง่ายขึ้น เหมือนอ่านประโยคในภาษาอังกฤษ หรือใช้ truth.dev ก็ได้
ก่อนอื่นใส่ library ของ Hamcrest ก่อน และการใส่ source set testImplementation
ก็คือส่วนนี้จะ build เฉพาะตอนเราเทสเท่านั้น
dependencies {
testImplementation "org.hamcrest:hamcrest-all:$hamcrestVersion"
}
โดยตัว source set ของ local test จะเป็น testImplementation
และ instrumental test จะเป็น androidTestImplementation
นะ
มาใช้ Hamcrest กันเถอะ~ เปลี่ยนจากของเดิมกันดีกว่า
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
// REPLACE
assertEquals(result.completedTasksPercent, 0f)
assertEquals(result.activeTasksPercent, 100f)
// WITH
assertThat(result.activeTasksPercent, `is`(100f))
assertThat(result.completedTasksPercent, `is`(0f))
- จาก
assertEquals
ไปเป็นassertThat
เนอะ โดยการใส่ parameter นั้นมาตาม concept เดิมเนอะ - ส่วน `is` จริงๆแล้ว is เป็นคำเฉพาะใน Kotlin อ่ะเนอะ เลยต้องมี ` ครอบคำไว้ มีเพื่อให้อ่านง่ายขึ้นว่าใช่อันนี้ไหม
- การกดเลือก import เลือก
import org.hamcrest.Matchers.is
นะ
ทริคเด็ดๆที่ใน codelab ตอนนี้ได้บอกเราไว้
- structure ในการเขียนเทส ก็คือ given, when, then โดยแต่ละส่วนนั้นมีหน้าที่ดังนี้ given คือ เตรียม object ที่ใช้ในการเทส, when คือ ต้องการ execute คำสั่งที่ต้องการเทสตรงไหน, และ then คือ assert value เพื่อไม่ให้เทสของเราซับซ้อนหรือสับสน
- Test Names มีรูปแบบการตั้งชื่อ คือ
subjectUnderTest_actionOrInput_resultState
มาอธิบายแต่ละส่วนกัน subjectUnderTest คือเราจะเทส function ของเทสชื่ออะไร, actionOrInput มี action หรือ input อะไรของ Test Case นี้ และ returnState ควรได้อะไร เพื่อให้เรากลับไปดูได้ว่า test case ไหน fail - การคิด Test Case นั้น ควรดู Test Case ที่ tester คิดประกอบกันไปด้วย เพื่อให้เราสามารถเขียนเทสครอบคลุมในส่วนนั้นด้วย แต่บางทีอาจจะเป็นแค่ Test Senerio ก็ได้นะ
มีคำถามจากทางบ้านที่คอมเมนต์ในไลฟ์ น่าสนใจมากๆ
Q : Instrumental Test สามารถทำใน CircleCI หรือ Bitrise ได้ไหมครับ (คำถามนี้เราเข้าใจว่าเอาไปทำใน CI ได้ไหมนะ)
A ตอบโดยน้องเบน : ได้ครับผม สามารถต่อ CI เราเข้ากับ Remote Device ผ่านพวก Firebase Test Lab ได้ครับ
Task: Writing more tests
เขียนเทสให้ cover มากขึ้นสิ ในตอนนี้จะเริ่มทำ TDD กันหล่ะ
ว่าแต่ตัว function ทำอะไรบ้างนะ? แล้วมันควรจะมี Test Case อะไรบ้างหล่ะ?
getActiveAndCompletedStats_noCompleted_returnsHundredZero()
getActiveAndCompletedStats_noActive_returnsZeroHundred()
getActiveAndCompletedStats_both_returnsFortySixty()
getActiveAndCompletedStats_error_returnsZeros()
getActiveAndCompletedStats_empty_returnsZeros()
ตามหลัก TDD มันจะต้อง test failed ก่อนรอบนึง
ทางเราเดาว่าน่าจะ bug ตรงหารด้วย 0 เพราะที่ tasks นั้นสามารถรับค่าที่เป็น null เข้ามาได้ด้วย ทำให้เราไม่สามารถหา size ของมันได้ หรือถ้ามันมี size = 0 ทำให้ test case มีอันที่ test failed 1 อัน และ code crash 1 อัน
และก็เป็นจริงอย่างที่เราคิดไว้
หลังจากนั้นแก้บัคสิ ให้ test ที่ failed นั้นเป็น passed ซะ โดยทำการ check ว่า list มัน null ไหม และมันไม่ empty หรือเปล่า ตอนแรกโค้ดจะไม่สวย ก็ค่อยๆ test ไป refactor ไป สุดท้ายจะได้ดังนี้ เทสผ่านแน่นอน
Task: Setting up a ViewModel Test with AndroidX Test
ความจริงคือมันสี่ทุ่มแล้ว เริ่มไม่ไหวหล่ะ -_-zZ
ViewModel
นั้น เป็นศูนย์ควบคุมโควิท เอ้ยย logic ที่เกิดขึ้นในแอพ และเป็นตัวกลางระหว่าง Activity/Fragment (ส่วน View นั่นแหละ) กับ Repository
ดังนั้นในบทนี้จะเริ่มมาทำเทสกับเจ้า ViewModel
กันแล้วจ้าาา โดยจะทำการเทสที่ TasksViewModel
โดยในนี้ทำการ handle logic ในแต่ละ feature ทำให้ตัวไฟล์นี้มันดูเยอะๆนิดนึงเนอะ
ไปดูที่ addNewTask()
ดูเหมือนไม่ได้ถูกเรียกใช้ที่ไหน แต่จริงๆถูกเรียกใช้ที่ data binding
ส่วน Event
นั้นเป็น class ที่ถูกสร้างไว้ในโปรเจกนี้ ออกแบบมาเพื่อใช้ความสามารถของ LiveData
แต่มีปัญหานึงคือไม่ได้สร้างจาก LiveData
แต่ใช้ร่วมกับ LiveData
คือ LiveData
ใช้ส่งข้อมูลในลักษณะคล้ายๆกับการ stream data มีปลายทางเป็น observe data ที่อยู่ใน LiveData
เมื่อข้อมูลเกิดการเปลี่ยนแปลง ก็จะมีอัพเดตไปให้ทันที สิ่งที่ observe ก็จะเปลี่ยนทันที
แต่ถ้าอยากโยน Event เข้าไปหล่ะ เป็น Event ที่มี data ชุดนึง และไม่อยากให้เป็น streaming data ที่อัพเดต data ตลอดเวลา และอยากโยนไปครั้งเดียวจบ เลยอยากให้โยนผ่าน LiveData
เหมือนเดิม เพราะข้อดีของ LiveData
คือมีเรื่อง lifecycle awareness เช่น ดึงข้อมูลบางอย่างจาก service แล้ว user พับจอลงไป แม้จะโหลดข้อมูลมาเสร็จแล้ว จะยังไม่อัพเดตจน user เข้ามา active ในแอพ
getContentIfNotHandled()
ทำหน้าที่คือ เมื่ออัพเดตข้อมูลเสร็จแล้วให้เคลียร์ค่าทิ้งหมดเลย เพื่อให้ได้ข้อมูลเพียงครั้งเดียว peekContent()
ใช้สำหรับ component บางตัวอยาก observe ตัวเดียวกัน แต่ไม่ได้อยากดึงข้อมูลออกไป
ตอนใช้งานจริง ต้องมี EventObserver
ซึ่ง Event
ตัวนี้นั้นไปเขียนครอบจาก Observer
อีกทีนึง เพื่อให้มันดึง content ขึ้นมาโดยอัตโนมัติ
ตัวอย่างการเอาไปใช้ เรียก ViewModel
ขึ้นมา และ observe สิ่งที่เรียกว่า editTaskEvent
โดยตัวมันเองจะเป็น LiveData<Event<Unit>>
การที่เอา Event
ไปใส่ไว้ใน LiveData
ก็เพื่อไว้จำแนกข้อมูลประเภท Event
พอเรียกใช้ ViewModel
ตามมาด้วย LiveData
และหลังจาก observe เสร็จก็จะทำการใส่ lifecycle ลงไป ตามมาด้วย EventObserver
เพื่อดึง content ข้างในให้ทันทีโดยอัตโนมัติโดยไม่ต้องพิมพ์เอง
//TaskDetailViewModel.kt
private val _editTaskEvent = MutableLiveData<Event<Unit>>()
val editTaskEvent: LiveData<Event<Unit>> = _editTaskEvent
//TaskDetailFragment.kt
viewModel.editTaskEvent.observe(this, EventObserver {
val action = TaskDetailFragmentDirections
.actionTaskDetailFragmentToAddEditTaskFragment(
args.taskId,
resources.getString(R.string.edit_task)
)
findNavController().navigate(action)
})
ทำไมถึงต้องทำ EventObserver ให้เราหล่ะ? เพราะปกติการเรียกใช้จะเป็น Observer เนอะ เมื่อได้ content ข้างในแล้ว จะต้องทำการ it.getContentIfNotHandled()
ทุกครั้งนั่นเอง
viewModel.editTaskEvent.observe(this, Observer {
it.getContentIfNotHandled()
})
จากนั้นมาเริ่มเขียนเทสเจ้า ViewModel แล้วนะ ให้สร้างเป็น Local Test นะ
แล้วทำไมเขียนเทส ViewModel
ใน local test หล่ะ? เพราะว่าการทำ Instumental Test นั้นใช้เวลารันนาน และกว่าจะเทสครบก็นาน เพราะต้อง connect device เพื่อดึง resource มาใช้ เลยให้ใส่ใน local เพื่อความไว
การเรียก ViewModel
ขึ้นมา ต้องการ Application
นะ เอ๊ะจะยังไงต่อดีนะ มันเป็น class ใน Android นี่นา
ทางเขาก็เลยมี AndroidX Test libraries ช่วยแก้ปัญหาในจุดนี้ โดยการจำลอง class ที่อยู่ใน Android framework ให้เราได้ โดยเราไม่ต้องทำ Instrumental Test ในกรณีนี้อีกต่อไป และเพิ่ม Robolectric Testing library ช่วย run Android framework ใน local test ได้
ไปเพิ่ม dependency กันเถอะ build หนึ่งที
// AndroidX Test - JVM testing
testImplementation "androidx.test.ext:junit-ktx:$androidXTestExtKotlinRunnerVersion"
testImplementation "androidx.test:core-ktx:$androidXTestCoreVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"
testImplementation "androidx.arch.core:core-testing:$archTestingVersion"
จากนั้นใส่ runner @RunWith(AndroidJUnit4::class)
ด้วยตรงบน class โดย runner นั้นจะมากับการทำ Instrumental Test เพราะเรารันบนเครื่องอื่น และต้องมีคบควบคุมว่าจะไป run ให้ใคร ด้วยวิธีไหน เช่นตัวนี้ จะทำให้เรา run Android Test ที่อยู่ข้างใน junit local test ได้ ถ้าไม่ใส่จะ run บน jvm บนเครื่องเราเป็น default
@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {
// Test code
}
และเราก็ใส่ ApplicationProvider
เพื่อทำให้เรา get context มาได้
val tasksViewModel = TasksViewModel(ApplicationProvider.getApplicationContext()
และทำการอัญเชิญ tasksViewModel.addNewTask()
มา จากนั้นทำการ run สิ่งที่พบก็คือ โหลดของอื่นๆมาให้เราตอน run test เนอะ
สิ่งที่น่ามหัศจรรย์ก็คือ warning นั่นเองงง แต่ก็ไม่น่าขึ้น เพราะโปรเจกเป็น Android SDK 28 อ่ะ ถ้าเราใช้เป็น Android SDK 29 ต้องไปใช้ Java 9 นั่นเอง
แก้โดยไปใส่สิ่งในข้างใน Android scope อีกที เพื่อให้เข้าถึง Android Resource ในตอนทำ Unit Testing นั่นเอง
testOptions.unitTests {
includeAndroidResources = true
}
Task: Writing Assertions for LiveData
กลับมาเขียน assertion ของ LiveData ต่อกันเตอะ (แต่ด้วยความง่วงเลยไม่ได้จด เลยจดใหม่เลย)
ปัญหาของการทำเทสคือ อย่าเขียนเทสแบบ asynchronous เพราะว่าเทสจะรันแบบ synchronous นะจ๊ะ เลยจะเกิดปัญหาคือ test passed โดยที่ยังไม่ทันจะไปถึง assertion เลย
และ Unit Test ก็คือเทสใน scope ภายในนั้น ไม่ควรทำให้มันช้า โดยการไปเรียก dependency อื่นๆมางี้ ตัวอย่างที่เรานึกได้ไวๆก็คือ เทสว่าถ้า response code ได้ 200 ควรได้อะไรกลับมา ดังนั้นจึง mock service ไว้
มีสิ่งที่เรียกว่า InstantTaskExecutorRule
มาคอยช่วยเหลือเรา หน้าที่ก็ตรงตัว เป็น Task Executor สำเร็จรูป ทำให้บางอย่างที่ run อยู่ใน background สามารถ execute ได้เลยทันที ใช้เฉพาะตอนเทสเท่านั้น! ตัวนี้จะมา solve ปัญหาว่าเราจะเทส LiveData อย่างไรให้เป็น synchronous
ดังนั้นตอนนี้ import เข้ามาเลยยยย
testImplementation "androidx.arch.core:core-testing:$archTestingVersion"
เมื่อ import เสร็จประกาศ test rule ให้กับ junit ที่เราจะทำการเทส โดยใช้ annotation @get:Rule
เพื่อประกาศตัวแปรของ test class นั้นๆเลย มีชีวิตตั้งแต่เกิดจนตายไป การประกาศ rule นี้ทำให้เรา trigger ของที่อยู่ใน ViewModel ได้จริงๆหล่ะ
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
และมาเขียน assertion กัน มี try-finally ด้วย ความยากคือ import ให้ถูกอันด้วยน้าาา ทางเราแปะอันที่ถูกต้องให้แล้วนะ
จริงๆมีทริคอยู่ว่าอย่าเลือก import ที่มีตัว core นะ
ผลคือควรได้อะไรบางอย่างที่ไม่เป็น null เนอะ แน่นอนมันจะผ่าน
ข้อดีของการทำเทสคือทำได้ไว ได้หลายรอบ และไม่ต้องรันแอพจริงๆ
โค้ดตอนนี้ยังไม่สวยนะ observe บ่อยๆใจมันไม่ไหวเนอะ
ดังนั้นก้อปเจ้า LiveDataTestUtil
เข้ามาด้วยพร้อม import มี getOrAwaitValue()
ข้างในจะ trigger observeForever
ให้เอง และมี handle หน่วงเวลาให้เราด้วย
การนำไปใช้ก็ลบโค้ดยุ่งๆทิ้งไป แล้วใช้เจ้า getOrAwaitValue()
และไม่มีโอกาสส่ง null ถ้าของไม่มีจะรอจนกว่าจะมีของแล้วค่อยทำต่อ เห็นม่ะ โค้ด่านง่ายขึ้นแล้ว อ่ะเทสใหม่มันควรจะผ่านเนอะ
ข้อควรระวัง Observe LiveData
ให้ถูกตัวด้วยนะ
Task: Writing multiple ViewModel tests
เขียนเทสให้มั่นใจว่าชัวร์!
ไปเขียน setFilterAllTasks_tasksAddViewVisible()
มาเพิ่ม เพื่อ test การ filter all tasks เนอะ โดย type ต่างๆจะเป็น enum ที่ชื่อว่า TasksFilterType
นั่นเอง
เริ่มทำการ test โดยใช้ ViewModel
เดิมที่ชื่อว่า TasksViewModel
และเราทำการเทส filter all tasks tasksViewModel.setFiltering(TasksFilterType.ALL_TASKS)
และทำการ assert ของ เป็นการจำลองการเลือก all tasks
assertThat(
tasksViewModel.tasksAddViewVisible.getOrAwaitValue(),
`is`(true)
)
ผลคือเทสผ่านหล่ะ
และถ้ามี function ของ test case เยอะขึ้นเรื่อยๆหล่ะ เราต้องสร้าง ViewModel ใหม่ทุกครั้งเลยนี่นา
ดังนั้นเราจะใช้ annatation ที่ชื่อว่า @Before
เพื่อเตรียมคำสั่งที่เราใช้ในทุก test case โดยที่เราไม่ต้องเสียเวลามา copy and paste ทุกครั้ง
จึงนำเจ้า TasksViewModel
ไปประกาศตัวแปรไว้ด้านบนเป็น lateinit
แล้วไปกำหนดค่าที่ function อันนึง ชื่อว่า ซีตุ๊ป เอ้ยย setupViewModel
เพื่อ init ค่าของเจ้า ViewModel นี้ และบน function นี้ใส่ @Before
ลงไป
// Subject under test
private lateinit var tasksViewModel: TasksViewModel
@Before
fun setupViewModel() {
tasksViewModel = TasksViewModel(ApplicationProvider.getApplicationContext())
}
แต่ปกติใช้ชื่อ setup()
กันมากกว่า
ทั้งหมดจะเป็นดังนี้ เอา ViewModel ที่ประกาศใน test case ออกไป
และต้องเทสผ่านนะจ๊ะ
ข้อควรระวัง มันมากับความสงสัยที่ว่า ทำไมไม่ assign ค่าให้กับเจ้า ViewModel ไปเลยหล่ะ จะมานั่ง lateinit มาใส่ค่าในซีตุ๊ปตะไม? เพราะว่าการทำแบบนั้นเราจะได้ ViewModel ตัวเดิม สำหรับทุกเคส ดังนั้นเราควรสร้าง given ใหม่ทุกครั้ง ไม่งั้นจะมีความกระทบกันได้ ทำให้ test case confilct กัน เพราะบางทีเจ้า ViewModel โดนเขียนอะไรบางอย่างลงไปแล้ว มันจะเพี้ยนเนอะ
และ codelab ที่เราลงมือเทสก็ได้หมดลงแล้วววว
Summary
- เรารันเทสผ่าน Android Studio ได้อย่างไร
- ความแตกต่างระหว่าง local test (
test
) และ instrumentation tests (androidTest
) คือมี source set ที่ต่างกัน - การเขียนเทสโดยการใช้ JUnit และ Hamcrest.
- การเขียนเทสกับ ViewModel กับ AndroidX Test Library.
และเขาก็ได้ทิ้ง Resource ต่างๆให้ด้วย อ่ะไปจิ้มดูกัน
ทำไมจึงรันเทสบน command line ก็เพราะใช้ใน CI นั่นแหละ ใครจะเปิดเครื่องจิ้มๆแบบ auto อ่ะเนอะ เขาจะ generate resource มาให้ โดนไม่ต้องเปิด Android Studio แหละ
การเขียนเทสบน ViewModel สำคัญที่สุด เพราะ ViewModel เป็นคนถือ logic ต่างๆในแอพของเราเนอะ
จบแล้วววววว~~~~~ ทางเราขออนุญาติตัด Q & A ออกไปอีกตามเคย อยากให้ไปฟังเองมากกว่าอ่ะ อิอิ
ถ้าอยากอ่านบล็อกเกี่ยวกับ Unit Test เพิ่มเติม จิ้มอ่านได้ด้านล่างจ้า
ส่วนข้อติติงน้านนนน มีเรื่องเดียวจริงๆ คือเรื่องเวลา ดูจะเป็นปัญหาด้วยแหละ เพราะดันจัดคืนวันอาทิตย์ และวันจันทร์ก็ต้องไปทำงานกัน แล้วลากยาวไป 3 ชั่วโมง เราเลยหนีไปนอนตอนสี่ทุ่มเลย ถ้าแบบจัดสักบ่ายโมง บ่ายสอง จะลากไปห้าหกโมงเย็นก็ยังพอได้อยู่นะ
แต่ข้อดีแน่ๆคือ มีคนสอนเขียนเทสและทำตาม codelab นี่แหละ ทำให้เราเข้าใจมันมากขึ้นเนอะๆ
รอว่าคราวหน้าจะจัดอีกเมื่อไหร่เนอะ เดายากอยู่เช่นกัน
สุดท้าย บล็อกเขียนเสร็จแล้ว ฝากร้านได้