โดย Siamak Mahmoudi
TDD หรือ Test-Driven Development เป็นแนวทางการพัฒนาซอฟต์แวร์ที่เขียนทดสอบก่อนที่จะ implement โค้ดจริง
มันต้องการความเข้าใจที่ชัดเจนเกี่ยวกับ "What" และ "How" ในข้อกำหนดของโปรเจกต์/ฟีเจอร์
TDD ช่วยให้เขียนโค้ดน้อยลง แต่เพียงพอ ช่วยป้องกันข้อผิดพลาดทั่วไปในการพัฒนาซอฟต์แวร์ เช่น over-engineering, test coverage มากเกินไป, ข้ามข้อกำหนดหลัก, functions และ classes ใหญ่เกินไป และ code statements ซับซ้อนเกินไป
โดยรวม ช่วยให้มี codebase ที่กระชับ มี unit test ครอบคลุมแล้ว และสะอาด ตามเวลามันยังช่วยประหยัดต้นทุนการพัฒนาและบำรุงรักษาโค้ด
ในบทความนี้ เราจะพูดถึง TDD ในการปฏิบัติจริง
บริบทคือสภาพแวดล้อมการพัฒนา Android ดังนั้นเราจะใช้ Kotlin และ JUnit5 พร้อมโปรเจกต์ตัวอย่างเพื่อสาธิตขั้นตอน
อย่างไรก็ตาม คำแนะนำและเทคนิคที่นี่สามารถปฏิบัติได้ในภาษาโปรแกรมมิ่งอื่นๆ ด้วย
ข้อกำหนดเบื้องต้น
- ความรู้พื้นฐานของ Kotlin
- ความรู้พื้นฐานการเขียน Unit tests
- ความรู้เกี่ยวกับ mocks และ assertions
เราจะใช้ Kotlin เป็นภาษาโปรแกรมมิ่ง และ JUnit5 เพื่อเขียน unit tests
Mockito จะถูกใช้เพื่อทำงานกับ mocks และ spys
เป้าหมายคือนักพัฒนาซอฟต์แวร์จากแพลตฟอร์มใดๆ ที่มองหาบทใหม่ในอาชีพ
แม้บริบทจะเป็น Android เนื้อหาไม่ได้พูดถึง platform-specific properties แทนที่จะโฟกัสที่ techniques, notes และ challenges เมื่อพัฒนาด้วย TDD
หากข้างต้น OK กับคุณ มาเริ่มกันเลย
Test Driven Development ทำงานอย่างไร
TDD Cycle
กระบวนการพัฒนาปฏิบัติตามวัฏจักร:
- เขียนทดสอบที่ล้มเหลว (pink square)
- Implement โค้ดเพื่อทำให้ทดสอบผ่าน (green square)
- Refactor โค้ด (blue square) ตามที่จำเป็น พร้อมรับรองว่าทดสอบยังคงผ่าน (pale green square)
- เขียนทดสอบที่ล้มเหลวใหม่ (เริ่มวัฏจักรใหม่)
การเขียนทดสอบที่ล้มเหลว (Pink Square)
ในขั้นตอนนี้ คุณเริ่มต้นด้วยการอธิบายว่าคุณต้องการให้โค้ดทำอะไร
ลองจินตนาการว่าคุณกำลังให้โค้ดของคุณทดสอบเพื่อเช็คว่ามันพฤติกรรมถูกต้องหรือไม่ ทดสอบนี้เหมือนคำถามที่คุณถามโค้ด เช่น "คุณทำงานนี้ได้ไหม?"
ตอนแรก โค้ดของคุณไม่รู้คำตอบ ดังนั้นคุณเขียนทดสอบที่ควรล้มเหลว เพราะโค้ดยังไม่รู้วิธีทำงานนั้น ทดสอบที่ล้มเหลวนี้เหมือนเครื่องหมายเตือนสีชมพู บอกว่าบางอย่างไม่ถูกต้อง
เมื่อคุณจบขั้นตอนนี้ JUnit5 จะสร้างรายงานครอบคลุมจากทดสอบที่คุณสร้าง ทดสอบเหล่านี้จะยืนยันเป็นตัวแทนของงานของคุณ
ลองจินตนาการว่า project manager ของคุณกำลังอ่าน test cases นี้ เพื่อประเมิน coverage และความแม่นยำของความเข้าใจของคุณเกี่ยวกับฟีเจอร์หรือผลิตภัณฑ์ การยอมมุมมองนี้ให้ความเข้าใจที่ชัดเจนขึ้นเกี่ยวกับความสำคัญของขั้นตอนการพัฒนานี้
เลื่อนโฟกัสจาก technical intricacies ไปที่ behavior ของซอฟต์แวร์เอง
แทนที่จะหลงใหลในรายละเอียดทางเทคนิค ให้โฟกัสที่ซอฟต์แวร์ทำงานและโต้ตอบกับผู้ใช้และคอมโพเนนต์อื่นๆ อย่างไร
การเปลี่ยnmุมมองนี้ช่วยให้คุณ prioritize intended actions และ outcomes ของซอฟต์แวร์ นำไปสู่ทดสอบที่สะท้อนพฤติกรรมจริงได้แม่นยำ
ด้วยการโฟกัสที่พฤติกรรมแทนรายละเอียดทางเทคนิค คุณรับรองว่าทดสอบของคุณสอดคล้องกับวัตถุประสงค์และความคาดหวังของผู้ใช้
ในบางกรณี คุณอาจจบได้เพียงไม่กี่ test cases ต่อ component (ซึ่งเป็นจุดประสงค์: งานน้อยแต่เป้าหมายชัด) และนั้นโอเคเลย ตราบใดที่คุณครอบคลุมข้อกำหนดพฤติกรรมทั้งหมดของโปรเจกต์
เคล็ดลับ
- Be specific: เขียน test cases ที่ชัดเจนและเฉพาะเจาะจง โฟกัสที่หนึ่งด้านของพฤติกรรมโค้ด
- Start simple: เริ่มด้วย test case ที่ง่ายที่สุดครอบคลุม basic functionality ที่คุณต้องการ
@Test fun `a sum is calculated from two input numbers`() {}
- Use meaningful names: ตั้งชื่อทดสอบให้ descriptive เพื่อให้ใครอ่านก็รู้ว่าทดสอบอะไร
@Test fun `Font Ratio is fetched from data source INITIALLY`() {}
ข้อผิดพลาดทั่วไปที่ควรหลีกเลี่ยง
- Testing too much at once: หลีกเลี่ยงการทดสอบหลายสิ่งในทดสอบเดียว นี่ทำให้ยากที่จะระบุว่าอะไรล้มเหลว
// Don't do this
@Test fun `pixelSize fits the standart sizes while fontSize is bigger than minumum supported font size but matches the list of special levels of size`() {}
- Relying on implementation details: อย่าเขียนทดสอบที่ผูกมัดกับการทำงานภายในของโค้ด ทดสอบควรโฟกัสที่พฤติกรรม ไม่ใช่ implementation
// Don't do this
@Test fun `pixelSize is Long and Non-Null and fits the standart sizes then calculated font size is non-null and of type Dimention`() {}
การ Implement โค้ดเพื่อให้ทดสอบผ่าน (Green Square)
ตอนนี้คุณมีทดสอบแล้ว ถึงเวลาสอนโค้ดให้ทำงานได้ถูกต้อง
คุณเขียนโค้ดจริงที่ควรทำให้ทดสอบผ่าน และโค้ดตอบคำถามได้ถูกต้อง
เมื่อโค้ดของคุณผ่านทดสอบ มันเหมือนไฟเขียวบอกว่า "ใช่ ฉันทำงานนี้ได้แล้ว!"
ขั้นตอนนี้เกี่ยวกับการรับรองว่าโค้ดเข้าใจและแก้ปัญหาที่คุณถามได้
เคล็ดลับ
- Write minimal code: เขียนโค้ดที่เรียบง่ายที่สุดที่ทำให้ทดสอบที่ล้มเหลวผ่าน คุณอ่านเพิ่มได้เกี่ยวกับการหลีกเลี่ยง over-engineering ที่นี่
// Test Case
@Test fun `Storage stores font ratio in key-value`() {
// Given
val fontRatio = 2.0f
val mockEditor = mockk<SharedPreferences.Editor>(relaxed = true)
every { mockSharedPreference.edit() } returns mockEditor
every { mockEditor.putFloat(any(), any()) } returns mockEditor
every { mockEditor.apply() } just Runs
// When
storage.saveFontRatio(fontRatio)
// Then
verify(exactly = 1) { mockEditor.apply() }
}
// Correct Implementation - Avoid extra implementation
class SharedPreferenceHelper(
private val sharedPreferences: SharedPreferences
) {
fun saveFontRatio(fontRatio: Float) {
sharedPreferences.edit().putFloat("font-ratio", fontRatio).apply()
}
}
// Wrong Implementation
class SharedPreferenceHelper(
private val sharedPreferences: SharedPreferences
) {
fun saveFontRatio(fontRatio: Float) {
if (fontRatio <= 0.0f) throw IllegalArgumentException("Font ratio must be greater than 0.0f")
storeValue(key = FONT_RATIO_KEY, value = fontRatio)
}
private fun storeValue(key: String, value: Float){
val editor = sharedPreferences.edit()
editor.putFloat(key, value)
editor.apply()
}
fun getFontRatio(): Float {
return sharedPreferences.getFloat("font_ratio", 1.0f)
}
}
- Avoid duplication: อย่าทำซ้ำโค้ด หากคุณพบว่าตัวเองเขียน logic คล้ายกันหลายที่ พิจารณา refactor การปรับปรุงโค้ดหลักเหล่านี้สามารถทำใน phase นี้ได้ แต่หากการแก้ไขอาจมี side-effect ให้ละเว้น
class ... {
override fun getDefaultFontSize(): Float {
val zoomRatio = DEFAULT_SSPEED * DEFAULT_FONT_RATIO / deviceDensity
val fontSize = zoomRatio * standardFontSize
return fontSize
}
override fun getFontSizeBySSpeed(speed: Int): Float {
val zoomRatio = speed * DEFAULT_FONT_RATIO / deviceDensity
val fontSize = zoomRatio * standardFontSize
return fontSize
}
}
class ... {
override fun getDefaultFontSize(): Float = calculate(DEFAULT_AGE)
override fun getFontSizeByAge(age: Int): Float = calculate(age)
private fun calculate(age: Int): Float {
val zoomRatio = age * DEFAULT_FONT_RATIO / deviceDensity
val fontSize = zoomRatio * standardFontSize
return fontSize
}
}
ข้อผิดพลาดทั่วไปที่ควรหลีกเลี่ยง:
- Jumping Ahead: อย่าเขียนโค้ดมากกว่าที่จำเป็นเพื่อให้ทดสอบผ่าน TDD เกี่ยวกับ incremental development TDD ส่งเสริมแนวทาง incremental และ step-by-step ในการพัฒนา เมื่อคุณ jump ahead คุณพยายามแก้ปัญหาที่ยังไม่เกี่ยวข้องโดยตรงกับทดสอบปัจจุบัน เป้าหมายหลักคือโฟกัสที่ immediate task – passing the current test – โดยไม่หลุดไปที่ functionalities ในอนาคต
- Ignoring Test Failures: หากทดสอบไม่ล้มเหลวตั้งแต่แรก คุณอาจพลาด case สำคัญ นี่อาจดูไม่น่าเชื่อตอนแรก แต่หลังพัฒนาบ้างใน test components คุณจะเริ่มเขียนหลายทดสอบสำหรับ single method เพื่อทดสอบด้านต่างๆ ของ logic นี่คือที่ที่คุณไม่ควร high หาก logic ที่ยังไม่ implement ผ่านทดสอบ ในคำพูดง่ายๆ นี่คือวิธีที่คุณจับบั๊กใน development phase ดังนั้น คาดหวัง failure เมื่อมันควรจะเป็น
Refactoring โค้ด (Blue Square) และรับรองความสำเร็จของทดสอบ (Pale Green Square)
เมื่อโค้ดของคุณผ่านทดสอบแล้ว ถึงเวลาทำความสะอาด
คุณอาจเห็นวิธีทำให้โค้ด organized มากขึ้น เข้าใจง่ายขึ้น หรือแม้แต่เร็วขึ้น คิดเหมือนการเก็บห้องหลังเล่นเสร็จ ทำให้ทุกอย่างเรียบร้อยหลังเล่นเสร็จ คุณปรับปรุงโค้ดโดยไม่เปลี่ยนสิ่งที่มันทำ
ขณะทำ คุณรันทดสอบทั้งหมดต่อไปเพื่อให้แน่ใจว่ายังผ่าน หากทดสอบล้มเหลวระหว่างขั้นตอนนี้ มันเหมือนเครื่องหมายเตือนสีเขียวอ่อน บอกว่าบางอย่างที่คุณทำความสะอาดอาจทำให้โค้ดเสียโดยไม่ได้ตั้งใจ
คุณสามารถจัดการส่วนนี้เป็น code maintenance phase แยกต่างหาก
สมมติว่าคุณได้รับมอบหมายทำความสะอาดโค้ดเก่า และรับรองว่ามันปฏิบัติตาม team code quality guidelines และ product requirements
ตลอดกระบวนการนี้ จุดสำคัญคือรักษาสมดุล – refine พร้อมรับรองว่าทดสอบยังคงสำเร็จ
นี่คือบาง ideas และ strategies สำหรับ refactoring phase:
- Code clarity: ลดความซับซ้อน แทนที่ชื่อตัวแปรที่ไม่ชัดเจน เพิ่ม comments ให้โค้ดเข้าใจง่าย
- Modularity: แบ่ง functions ใหญ่เป็นเล็กๆ ที่โฟกัสชัดเจน ทำให้โค้ด modular และ maintain/test ได้ง่าย
- Remove redundancy: ระบุโค้ดซ้ำ และรวมเป็น reusable functions/classes ช่วยกำจัดการซ้ำ และรับรอง consistency
- Optimization: ระบุพื้นที่ที่ประสิทธิภาพสามารถปรับปรุงได้ แต่ optimize เฉพาะเมื่อมี performance goals เฉพาะและมีหลักฐานว่าโค้ดเป็น bottleneck Optimization ที่นี่คือหลีกเลี่ยง resource drain ไม่ใช่ทำให้โค้ด performant
- Consistent formatting: รักษา code style consistent ตาม conventions ของทีม/โปรเจกต์
- Unused code: ลบ variables, functions, imports ที่ไม่ได้ใช้
- Test improvements: ต่างจากการรับรู้ทั่วไปเกี่ยว TDD คุณสามารถเพิ่มทดสอบได้เมื่อมีความจำเป็น Enhance test suite โดยเพิ่ม test cases ใหม่ครอบคลุม scenarios ที่ไม่ได้ address ก่อนหน้านี้ ช่วยรักษา test coverage ที่ครอบคลุม
- Documentation: หากวัตถุประสงค์ของโค้ดไม่ชัดเจนจากโค้ดเอง พิจารณาเพิ่ม/ปรับปรุง documentation อธิบาย intent และ usage หลีกเลี่ยงทำเป็นนิสัย นี่หมายถึงเป็น complementary explanation สำหรับ crucial cases เพื่อหลีกเลี่ยงความสับสน
จำไว้ว่า TDD code ควร self-expressing และอิสระจาก documentation
จำไว้ว่า ขณะ refactoring จำเป็นต้องรันทดสอบทั้งหมดต่อไปเพื่อรับรองว่ายังคงผ่าน
เคล็ดลับ
- Keep tests comprehensive: รับรองว่าทดสอบครอบคลุม scenarios ต่างๆ เพื่อจับ unintended side effects ระหว่าง refactoring
- Refactor gradually: ทำการเปลี่ยนแปลงเล็กๆ ในโค้ด และรันทดสอบบ่อยๆ เพื่อจับ regressions ได้เร็ว
ข้อผิดพลาดทั่วไปที่ควรหลีกเลี่ยง:
- Refactoring without tests: Refactoring โดยไม่มีทดสอบอาจนำไปสู่ unexpected behavior หากมีโอกาสพลาดส่วนของ logic ให้พิจารณาเขียนทดสอบสำหรับมัน
- Large code change: บางครั้งเราเปลี่ยนบรรทัดมากกว่าที่พัฒนาเพื่อให้ทดสอบผ่าน พิจารณา separate refactoring phase แทนการเปลี่ยนมากเกินไปใน development phase เพราะมัน safer & less costly
การเขียนทดสอบที่ล้มเหลวใหม่ (Restarting the Flow)
ตอนนี้ คุณคิดถึงสิ่งต่อไปที่คุณต้องการให้โค้ดทำ
คุณเริ่มด้วยการเขียนทดสอบใหม่ที่ควรล้มเหลว เพราะโค้ดไม่รู้วิธีทำงานใหม่นั้น นี่เหมือนให้โค้ดของคุณ challenge ใหม่ที่จะแก้
จากนั้น คุณทำซ้ำวัฏจักรทั้งหมด: ทำให้ทดสอบผ่านด้วยโค้ด (green square), ทำความสะอาดถ้าจำเป็น (blue square), และทดสอบต่อไปเพื่อให้แน่ใจว่าทุกอย่างทำงาน (pale green square)
ด้วยวิธีนี้ คุณเคลื่อนที่ไปข้างหน้าตลอดเวลา และสร้างโค้ดทีละขั้นตอน
เคล็ดลับ
- Incremental steps: เพิ่มทดสอบใหม่สำหรับ functionality ใหม่ใน increments เล็กๆ เพื่อรักษา development path ที่ชัดเจน แทนที่จะพยายาม implement complex feature ทั้งหมดทีเดียว คุณแยกมันเป็นชิ้นเล็กๆ ที่จัดการได้ และสร้างทดสอบสำหรับแต่ละชิ้น วิธีนี้รักษา development path ที่ชัดเจนและมั่นคง ช่วยให้คุณโฟกัส ลดความเสี่ยง และรับรองว่าแต่ละการเพิ่มเติมถูกทดสอบอย่างละเอียด
- Feedback loop: ใช้ feedback จากการเขียนทดสอบที่ล้มเหลว เพื่อนำทาง implementation Feedback loop เน้น iterative nature ของ TDD เมื่อคุณสร้างทดสอบใหม่และสังเกตว่ามันล้มเหลว คุณได้ insights ที่มีค่าที่นำทาง implementation
นี่คือวิธีที่ feedback loop ทำงาน:
- Expectation setting: เมื่อคุณเขียนทดสอบใหม่ คุณกำหนด expectations ว่าโค้ดควรพฤติกรรมอย่างไร นี่ชี้แจงสิ่งที่คุณมุ่งมั่นจะบรรลุกับฟีเจอร์ใหม่
- Initial failure: ทดสอบล้มเหลวตอนแรก เพราะโค้ดที่สอดคล้องกับ expectations หายไปหรือไม่สมบูรณ์ Initial failure นี้เป็นส่วนธรรมชาติของ TDD process
- Guides your implementation: Feedback จากการล้มเหลวของทดสอบชี้ทางว่าต้องเขียน/แก้โค้ดอะไร มันกลายเป็น roadmap สำหรับการพัฒนา रूपร่าง functionality ใหม่ควรเป็นอย่างไร
- Incremental Progress: เมื่อคุณ implement โค้ดที่จำเป็นเพื่อทำให้ทดสอบผ่าน คุณสร้าง functionality ที่ต้องการทีละขั้น แต่ละขั้นตอนนำทางโดย feedback จากทดสอบที่ล้มเหลว
- Verification: เมื่อ implementation เสร็จ คุณรันทดสอบอีกครั้ง หากผ่าน มัน verify ว่าโค้ดใหม่ satisfy expectations ที่ตั้งไว้ตั้งแต่แรก
Feedback loop รับรองว่าการพัฒนาของคุณสอดคล้องกับเป้าหมายที่ตั้งใจของซอฟต์แวร์
ข้อผิดพลาดทั่วไปที่ควรหลีกเลี่ยง:
- Writing tests after implementation: อย่าเขียนทดสอบหลังจาก implement feature แล้ว TDD คือเขียนทดสอบก่อน แม้ logic ชิ้นเล็กที่เพิ่มก่อน test code ก็หมายถึง possible resource waste/bug ในโค้ด จุดประสงค์ทั้งหมดคือไม่เพิ่ม logic ยกเว้นจะมีความต้องการจาก test suite
- Skipping failing tests: อย่าข้ามขั้นตอนนี้แม้คุณคิดว่ารู้วิธี implement feature
นี่คือเหตุผลว่าทำไมคุณไม่ควรข้าม failing tests step แม้คุณมั่นใจ:
- Clarity of intent: การเขียนทดสอบที่ล้มเหลวชี้แจง intent สำหรับฟีเจอร์ มันบังคับให้คุณพิจารณา exact behavior และ outcomes ที่คุณมุ่งมั่นก่อนลงมือ implement
- Verification of assumptions: แม้คุณคิดว่าเข้าใจฟีเจอร์ การสร้างทดสอบรับรองว่า assumptions ถูกต้อง ความเข้าใจอาจถูกต้อง แตทดสอบ validate มัน
- Safety Net: ด้วยการเขียนทดสอบที่ล้มเหลว คุณสร้าง safety net ป้องกัน regressions ในอนาคต มันทำหน้าที่เป็น specification สำหรับฟีเจอร์ และช่วยจับ unintended side effects
- Incremental development: TDD ส่งเสริม incremental development แต่ละฟีเจอร์ใหม่สร้างทีละขั้น มี progression ชัดเจนจาก failing test ไป working implementation การข้ามขั้นตอนนี้ disrupt progression นั้น
- Documentation: ทดสอบที่ล้มเหลว document expected behavior ของฟีเจอร์ documentation นี้มีค่าสำหรับคุณและทีม โดยเฉพาะเมื่อ revisit โค้ดในอนาคต จำไว้เสมอว่ามีระบบสร้าง reports โดย list out test code ทั้งหมดสำหรับ product managers และ QAs reports เหล่านี้ expose details ที่คุณ spotted ใน product ดังนั้น ลองโน้มน้าวพวกเขาว่าคุณเข้าใจจุดสำคัญอย่างลึกซึ้ง
วิธีพัฒนาด้วย TDD
TDD เน้นความสำคัญของการเขียน automated tests เพื่อขับเคลื่อน design และ development ของซอฟต์แวร์ นี่นำไปสู่โค้ดที่ reliable, maintainable และง่ายต่อการเปลี่ยนแปลงตามเวลา
แต่เราจะนำไปปฏิบัติจริงอย่างไร? ด้วยการลองและคุ้นเคยอย่างค่อยเป็นค่อยไป
ลอง TDD ขณะพัฒนาฟีเจอร์ใหม่ เพื่อสาธิตว่าเราสามารถเริ่มใช้มันใน real world ได้อย่างไร
เราจะ implement font size auto setting feature
เรามี news app และ user สามารถตั้ง auto-scroll-speed สำหรับ news feed
เราอยาก implement feature ที่ปรับ font size บนหน้าจอจาก scroll speed ที่ตั้งใน User Profile page
หาก user ตั้ง scroll speed จาก 0 ถึง 1 font size ควรเพิ่ม 1.3
scroll speed increment ใดๆ ที่เกิน 1 จะทำให้ font size เพิ่ม 1.2
ฟีเจอร์นี้ให้ผู้ใช้ประสบการณ์ดีกว่าขณะอ่านข่าว
ผมแชร์โค้ดที่สำรวจใน GitHub repository นี้
รู้สึกอิสระที่จะ clone และเล่นกับมัน
พยายามปฏิบัติตามขั้นตอนขณะที่เราก้าวหน้าในการพัฒนา นี่จะช่วยให้คุณเข้าใจ techniques และ way of thinking ในบริบทของ TDD อย่างปฏิบัติจริง
ดังนั้น เปิด Android Studio และสร้างโปรเจกต์ใหม่
Font Size Auto Setting Feature Data Flow Chart
DFD of a sample feature in an android app
ด้านบนคือ flowchart ของ data flow ที่ควรเป็นในท้ายที่สุด
นี่คือ outline ของแต่ละ engaging component:
Class AutoScrollSettingsUseCase จะจัดการ logic สำหรับคำนวณและเก็บ FontRatio ตาม Scroll Speed ที่เลือก
Use case นี้จะมี dependency บน UserRepository ที่เก็บค่า FontRatio
ใน UserRepository มีเมธอดเก็บและดึงค่า FontRatio ใช้ mechanism Storage ทุกครั้งที่ FontRatio ใหม่ส่งไป storage observables จะได้รับ emission ด้วยค่าล่าสุด
ใน UserProfileViewModel มี instance ของ AutoScrollSettingsUseCase ที่ถูกเรียกเมื่อ user update Scroll Speed นี้จะทริกเกอร์ recalculation ของ FontRatio และเก็บผ่าน repository
เราจะมี UI components ที่จำเป็นใน User Setting section ให้ user input scroll speed ที่พวกเขาอยากได้ สามารถทำได้ด้วย standard Android UI elements เช่น NumberPicker หรือ custom UI components (เราจะไม่พูดถึงส่วนเหล่านี้)
นี่คือ breakdown สำหรับฟีเจอร์ง่ายๆ และทุกครั้งที่คุณผ่านมัน มันจะชัดเจนขึ้นว่าขั้นตอนและผลลัพธ์สุดท้ายคืออะไร มันสำคัญที่จะทำมันสำหรับการเปลี่ยนแปลงของคุณ
วิธีเขียนทดสอบ
ขั้นตอนแรกคือสร้าง test class เอง ในกรณีนี้ เราจะมีอย่างน้อย test classes ต่อไปนี้:
UserRepositoryTestAutoScrollSettingTestUserSettingsViewModelTest
ผมชอบเริ่มจากส่วน ViewModel
ViewModel เป็น Android Architectural component ที่หนี lifecycle changes (foreground, background, focused) ดังนั้นมันเป็นที่ดีที่จะเก็บ states
มาสร้าง test file ภายใน directory unitTest ของ source code ตาม package path เดียวกับ real feature code
TDD ในทางปฏิบัติแตกต่างจาก legacy work development
กับ TDD เราใช้ IDE เพื่อ boost file และ property creation process แตเราสร้าง Test files ด้วยมือ! หลังจากลองไม่กี่ครั้ง ด้านของ IDE นี้จะกลายเป็น handy
สร้าง code structure (packages) และสร้าง test class โดยคลิกขวาที่ package และเลือก type Class
เลือกชื่อที่ descriptive (อาจควร follow convention หากคุณยังไม่ได้)
เช่น: xxx is tested for โดย xxx คือชื่อ component ที่ทดสอบ
Use IDE To Create Files
ตอนนี้ มาสร้าง empty tests พยายามให้กว้างที่สุดเท่าที่จะเป็นไปได้
ตาม diagram เราจะไม่มี logic มากสำหรับฟีเจอร์นี้
มี 2 strategies หลักสำหรับเขียน unit test functions:
- AAA
- Given/When/Then
@Test fun `strategy A`(){
// Arrange
// Act
// Assert
}
@Test fun `strategy B`(){
// Given
// When
// Then
}
เลือกหนึ่งและปฏิบัติตามสำหรับทุกทดสอบของคุณ
Concept เหมือนกัน: cluster test code ให้ง่ายต่อการอ่านและบำรุงรักษา
นี่คือสิ่งที่ผมมีตอนนี้:
class `UserProfileViewModel is tested for` {
// Unimplemented Class
val viewModel = UserProfileViewModel()
@Test fun `Font Ratio is fetched from data source`(){}
@Test fun `Scroll Speed update is called so fontSize calculations are triggered`() {}
@Test fun `Font Ratio is updated with new emissions from data source`() {}
}
มา run tests!
Failed Test Due To a Missing Test Target
มันล้มเหลว จริงๆ แล้ว build ล้มเหลว - ไม่ใช่ test
ขอแสดงความยินดี! เราเพิ่งไปถึงขั้นตอนแรกใน TDD cycle:
First step in TDD cycle
เพราะ ViewModel ยังไม่มีอยู่ เราเห็นสีแดง
ตอนนี้ มาสร้าง instance ของ ViewModel
ดังนั้น เราใช้ IDE สร้าง missing class หรือ unimplemented code
เพื่อให้ dialog นี้โผล่ ผมเคลื่อน pointer ไปที่ unimplemented part และกด Option + Return (บน macOS)
จากนั้น follow ตัวเลือกที่ให้:
TDD Action: Create Target Files through UnitTest file
Choose The Correct Destination For The New File
และตอนนี้ มา re-run tests (ขั้นตอนสุดท้าย):
Showing the test passing
ใช่! มันผ่าน
สังเกตว่าทดสอบเหล่านี้มี empty body และทดสอบไม่ได้อะไรเลย! นั่นถูกต้องและโอเค
เราควรต่อไปสร้าง test classes ทั้งหมด (ยังคง empty test body) สำหรับทุก component ใน DFD diagram - ผมแชร์เหล่านี้ต้นบทความ
นี่ช่วยให้มีความเข้าใจที่ชัดเจนขึ้นของฟีเจอร์ก่อนที่เราจะ implement
ในที่สุด เราจะมีประมาณ 3-4 test classes ที่มี general scenarios และ Unit tests ที่ต้องครอบคลุม
มันจะเป็นแบบนี้:
Minimal Empty Test Cases
มา implement หนึ่งในนั้นเป็นตัวอย่าง
แต่ ก่อนหน้านั้น เราจะต้องทำงานกับ UI และ Domain data models ของฟีเจอร์นี้
ดังนั้น เพื่อให้ย้ายข้อมูลได้ ให้สร้าง data classes ที่คุณต้องการล่วงหน้า
กลับไปที่ test class ProfileViewModel ของเรา เรามี empty unit test function มา implement นั้น
สังเกตว่า key ที่นี่คืออ่านทดสอบอย่างระมัดระวัง และหลีกเลี่ยง extra implementation ไม่ใช่ assertions
เฉพาะ requirement เท่านั้นที่ได้ implement
ในกรณีนี้ เราต้องการ stream ของข้อมูลที่เชื่อมต่อกับ data source ที่สร้างไว้ก่อนหน้านี้ (UserRepository)
อย่าลืม: เราต้องการ Failing Test ก่อน
Implement Inner Body
สังเกตส่วน unimplemented ภายใน test function body (marked with red font)
ตอนนี้ มา implement โค้ด แล้ว refactor ให้มันแค่ผ่าน
ผมใช้ MockK library สำหรับ mocking classes และ objects และ Turbine เพื่อทดสอบ Flow streams ที่นี่
หากคุณไม่คุ้นเคยกับพวกเขา อย่าตกใจ! แค่ไปเช็ค official webpage และลองใช้
มาสร้าง dependency ก่อน และเพิ่มเข้า ViewModel โดยใช้ Named Arguments Named Argument ช่วยให้เราเวลาสร้าง parameter ผ่าน IDE introduce proper names ผ่าน test code
Create Missing Parameter Using IDE Dialogs
ทำเหมือนกันสำหรับ variable FontRatio ภายใน Repository
ในที่สุด final test code อาจเป็นอย่างโค้ดด้านล่าง:
class `UserProfileViewModel is tested for` {
init {
Dispatchers.setMain(Dispatchers.Unconfined)
}
val mockUserRepository = mockk<UserRepository>()
@Test fun `Font Ratio is fetched from data source`() = runTest {
// Given
val expectedRatio = 2.0f
every { mockUserRepository.fontRatio } returns flowOf(FontRatioUiModel(expectedRatio))
val viewModel = UserProfileViewModel(userRepository = mockUserRepository)
// When
viewModel.fontRatio.test {
val fromDataSource = expectItem()
// Then
assertEquals(/* expected = */ expectedRatio, /* actual = */ fromDataSource.fontRatio)
}
}
...
}
สังเกตว่าเราไม่ implement inner parts ของ ViewModel หรือ Repository ที่นี่
เราแค่สร้าง missing parts เพื่อลบ error จาก test body
เราจะ implement รายละเอียดเหล่านั้นใน iteration ถัดไป
รันทดสอบตอนนี้
แน่นอนมันจะล้มเหลว เพราะเราไม่ได้ implement FontRatio ภายใน ProfileViewModel
ตอนนี้ refactor ViewModel ให้ทดสอบผ่าน (minimal implementation)
ในกรณีนี้ แค่ต้องเชื่อม state flow ไปที่ repository เราเพิ่มมันเป็น dependency ใน iteration ก่อนหน้าแล้ว
นี่คือโค้ดสุดท้าย:
class UserProfileViewModel(
userRepository: UserRepository
) : ViewModel() {
val fontRatio: StateFlow<FontRatioUiModel> = userRepository.fontRatio.stateIn(
initialValue = FontRatioUiModel(DEFAULT_FONT_RATIO),
scope = viewModelScope,
started = SharingStarted.Lazily
)
companion object {
private const val DEFAULT_FONT_RATIO = 1.0f
}
}
รันทดสอบอีกครั้ง และ Boom! มันผ่าน!
Passing Test After Minimal Implementation Of Main Code
ด้วย Unit test นี้ เรา implement ส่วนหลักของ component UserProfileViewModel แค่ส่วนที่จำเป็นเท่านั้น ทำเหมือนกันสำหรับ test cases ที่เหลือ
อย่าจัดการ test cases เหล่านี้เหมือน normal Unit tests (ที่ run และ pass เร็ว)
ใช้เวลาบนมันเพื่อเข้าใจ technical และ product requirements ก่อน แล้ว rollout plan และเริ่ม implement หลังจากลองไม่กี่ครั้ง จะง่ายขึ้นที่จะคิดแบบ TDD
Source Code
Source code และ repository สำหรับโปรเจกต์นี้มีบน GitHub page ของผม
รู้สึกอิสระที่จะ check out และทำต่อขั้นตอนถัดไป ผมแยก iterations ใน branches ต่างๆ ดังนั้นคุณสามารถเปรียบเทียบกับ implementation ของคุณเอง
สรุป
ดังนั้น หลังจากดำดิ่งสู่ Test-Driven Development (TDD) และสำรวจทุกมุมมัน ผมต้องบอกว่า มันคือ game-changer!
มาแยกกันดู:
Key Points:
- TDD คือการเขียนทดสอบก่อนเขียนโค้ดจริง อาจดูแปลกตอนแรก แต่เชื่อเลย มันทำงานได้มหัศจรรย์
- กระบวนการ TDD ปฏิบัติตาม cycle เรียบง่าย: เขียน failing tests, implement โค้ดให้ทดสอบผ่าน, และ refactor ตามต้องการเพื่อให้ทุกอย่างรันราบรื่น
- เน้น automated tests TDD ช่วยให้เรา design และ develop ซอฟต์แวร์ที่ solid, maintainable และ adaptable ตามเวลา
เราเริ่มต้นด้วยการสร้าง test classes และเขียน empty test functions แต่มันคือภารกิจของคุณตอนนี้ที่จะจบมัน (หรือคุณสามารถกระโดดไป repo ที่แชร์ได้เลย :))
อาจดูแปลกนิดหน่อยที่มีทดสอบที่ไม่ทำอะไร แต่มันเป็นส่วนหนึ่งของแผน
ต่อมา เราวางแผนชัดเจนว่าฟีเจอร์ควรทำอะไร จาก data flow chart นี่ช่วยให้เราเข้าใจ requirements ก่อนลงมือ implement
ด้วยแผนในมือ เราเริ่ม implement components ที่จำเป็น (ViewModel ในกรณีนี้) รับรองว่าทดสอบล้มเหลวก่อน นั่นถูกต้อง failing tests คือสิ่งดีใน TDD!
ค่อยเป็นค่อยไป เราเชื่อมชิ้นส่วนต่างๆ เข้าด้วยกัน เช่น ตั้งค่า class UserAutoScrollSettingsUseCase จัดการ font size calculation จาก auto-scroll speed (เช็ค project repo)
เรายังจัดการ UI components ให้ user input scroll speed ที่พวกเขาอยากได้ และรับรองว่า font size ปรับตาม (เช็ค project repo)
ตลอดกระบวนการ เราทำให้โค้ด clean และ simple โฟกัสที่สิ่งที่จำเป็นเพื่อให้ทดสอบผ่าน ไม่มีความซับซ้อนที่ไม่จำเป็น!
ในท้ายที่สุด เรามีฟีเจอร์ "Font Size Auto Setting" ทำงานแล้ว พร้อมทดสอบที่ผ่านสีสันสดใส
จำไว้ TDD ไม่ใช่การเร่งทดสอบหรือโค้ดอย่างบ้าคลั่ง มันคือการเป็น deliberate และ thoughtful ใน development process ซึ่งคุ้มค่ามากในระยะยาว
ดังนั้น หากคุณมองหาการ level up software development game ลอง TDD ดูสิ! มันคือแนวทาง powerful ที่จะทำให้โค้ด solid มากขึ้น ลด bugs และทำให้คุณเป็น developer ที่ดีกว่าโดยรวม
สิ่งที่ผมแชร์คือ วิธีที่ทีมของผมทำงาน และมันใช้ได้กับเรา แต่มันไม่ใช่ perfect solution สำหรับทุกทีม/บริษัท คุณต้องหาว่ามันเป็นของคุณหรือไม่ บอกผมได้หากคิดว่าผมปรับปรุง solution นี้ได้
Happy coding! 🚀
เรียนรู้การเขียนโค้ดฟรี หลักสูตรโอเพนซอร์สของ freeCodeCamp ช่วยให้ผู้คนมากกว่า 40,000 คนได้งานเป็นนักพัฒนา เริ่มต้นเลยวันนี้