ข้าม​ไป​ยัง​เนื้อหา

Test Driven Development

เขียน test ก่อน แล้ว​ให้ test ขับ​เคลื่อน​การ​เขียน code

Test Driven Development (TDD) หรือ​ที่​รู้จัก​ใน​ชื่อ Test Driven Design เป็น​วินัย​การ​เขียน code ที่​ใช้ test เป็น​ตัว​นิยาม​และ​ยืนยัน​พฤติกรรมของ​ซอฟต์แวร์ ก่อน​ที่​จะ​มี code จริง​เกิด​ขึ้น​เลย​ด้วย​ซ้ำ มัน​มี​ลักษณะ​เด่น​เป็น​วงจร​สั้น ๆ ที่​เรียก​ว่า Red — Green — Refactor ซึ่ง​วน​ซ้ำ​เพื่อ​ค่อย ๆ สร้าง​ซอฟต์แวร์​ที่​ทำงาน​ได้​ขึ้น​มา​ที​ละ​นิด

Kent Beck เป็น​ผู้​ทำให้ TDD เป็น​ที่​รู้จัก​ใน​ช่วง​ปลาย​ทศวรรษ 1990 ใน​ฐานะ​ส่วน​หนึ่ง​ของ Extreme Programming (XP) เขา​เล่า​ว่า​ไอเดีย​นี้​ไม่ใช่​ของ​ใหม่​ทั้งหมด แต่ “ค้น​พบ​ใหม่” จาก​ตำรา​เขียน​โปรแกรม​เก่า​ที่​แนะนำ​ให้​เขียน​กรณี​ทดสอบ​ไว้​ก่อน​เขียน code แล้ว​นำ​มา​ปรับ​ใช้​กับ framework xUnit ที่​เขา​สร้าง​ขึ้น

ประเด็น​สำคัญ​ที่​มัก​ถูก​มอง​ข้าม​คือ TDD ไม่ใช่​แค่ “เทคนิค​การ test” แต่​เป็น เทคนิค​การ​ออกแบบ ด้วย Martin Fowler ชี้​ว่าการ​บังคับ​ให้​เขียน test ก่อน​เขียน code จริง ทำให้​ผู้​พัฒนา​ต้อง​คิดถึง วิธี​ใช้งาน code ก่อน​คิดถึง วิธี​สร้าง มัน ผลลัพธ์​คือ​การ​แยก interface ออก​จาก implementation ได้​ดี​ขึ้น​โดย​ธรรมชาติ — สิ่ง​ที่​นัก​พัฒนา​จำนวน​มาก​ทำได้​ยาก​หาก​ออกแบบ code ก่อน​แล้ว​ค่อย​เขียน test ทีหลัง

ควร​เข้าใจ​ด้วย​ว่า TDD เน้น​ที่​ระดับ unit เป็น​หลัก จึง​ไม่ใช่​กลยุทธ์​การ test ที่​ครบถ้วน​ใน​ตัวเอง ทีม​งาน​ยัง​ต้อง​มี integration test, system test และ acceptance test ควบคู่​กัน​ไป​เพื่อ​ครอบคลุม​พฤติกรรม​ระดับ​ที่​กว้าง​กว่า​หน่วย code เดี่ยว ๆ — แนวคิด​นี้​ต่อยอด​ไป​สู่ Behavior-Driven Development ซึ่ง​ขยับ test ให้​ใกล้​ภาษา​ของ domain และ​ผู้​ใช้งาน​มาก​ขึ้น

ก่อน​เริ่ม​วงจร Red-Green-Refactor แรก Fowler แนะนำ​ให้​ทำ test list เสีย​ก่อน คือ​ร่าง​รายการ​กรณี​ทดสอบ​ที่​คาด​ว่า​จะ​ต้อง​มี​ทั้งหมด​คร่าว ๆ แล้ว​เลือก​มา​ทำที​ละ​ข้อ การ​จัด​ลำดับ​ว่า​จะ​ทดสอบ​อะไร​ก่อน​หลัง​เป็น​ทักษะ​สำคัญ เพราะ test ที่​เลือก​มา​ก่อน​ควร​พา​ไป​สู่​จุด​ออกแบบ​ที่​สำคัญ​ที่สุด​ได้​เร็ว​ที่สุด

เขียน test ที่ ล้มเหลว สำหรับ feature หรือ​พฤติกรรม​ใหม่​หนึ่ง​อย่าง แล้ว​รัน​มัน​เพื่อ​ยืนยัน​ว่า​มัน​ล้มเหลว​จริง (ไม่ใช่​ล้มเหลว​เพราะ test เขียน​ผิด) ตัว​รัน test (test runner) ควร​แสดง​สัญญาณ​สี แดง (RED) test ที่​ดี​มัก​เขียน​ตาม​รูปแบบ Arrange-Act-Assert (AAA): จัด​เตรียม​ข้อมูล/สถานะ (Arrange) เรียก​พฤติกรรม​ที่​ต้องการ​ทดสอบ (Act) แล้ว​ตรวจ​ผลลัพธ์ (Assert) — รูปแบบ​นี้​ทำให้ test อ่าน​ง่าย สื่อ​ความหมาย​ใน​ตัวเอง และ​ตรวจสอบ​เพียง​เรื่อง​เดียว​ต่อ1 test

แก้ไข system under test ให้ น้อย​ที่สุด เพื่อ​ให้ test ผ่าน โดย​เฉพาะ​เมื่อ​ยัง​ใหม่​กับ TDD ขั้น​นี้​ควร​เป็น “การ​เปลี่ยนแปลง​ที่​ง่าย​ที่สุด​ที่​ทำให้ test ผ่าน” อย่าง​แท้จริง Kent Beck อธิบาย​เทคนิค​ย่อย​ไว้​สาม​แบบ​ที่​ใช้​สลับ​กัน​ได้:

  • Fake It — hard-code ค่าที่​คาด​หวัง​ลง​ไป​ตรง ๆ เพื่อ​ไป​ให้​ถึง GREEN เร็ว​ที่สุด​ก่อน
  • Obvious Implementation — ถ้า​มั่นใจ​ว่า​รู้​คำ​ตอบ​ที่​ถูกต้อง​อยู่​แล้ว เขียน​มัน​ลง​ไป​ได้​เลย​โดย​ไม่​ต้อง fake
  • Triangulation — เมื่อ Fake It เริ่ม​ดู​ขัดหู​ขัดตา ให้​เพิ่ม test ที่​สอง​ซึ่ง​มี input ต่าง​ออก​ไป บังคับ​ให้ code ต้อง​ถูก generalize จาก​ค่า hard-code เป็น​ตรรกะ​จริง

เมื่อ test ผ่าน​แล้ว ตัว​รัน test ควร​ขึ้น​สัญญาณ เขียว (GREEN)

จากแดง​สู่​เขียว: ทำให้​ผ่าน​อย่าง​ง่าย​ที่สุด​ก่อน (แม้​จะ hard-code)

// RED — test นี้ล้มเหลว เพราะ FullName ยังไม่มี
public void ReturnsFullNameFromParts()
{
var person = new Person("Ada", "Lovelace");
Assert.Equal("Ada Lovelace", person.FullName);
}
// GREEN — Fake It: hard-code ให้ผ่านไปก่อน แล้วค่อยไปทำให้ถูกต้องจริงใน test ถัดไป
public string FullName => "Ada Lovelace";

ขั้น​ที่​สอง​มุ่ง​ไป​ที่​การ​ไป​ให้​ถึง GREEN อย่าง​เร็ว​และ​ง่าย​ที่สุด บาง​ครั้ง​จึง​เหลือ​ความ​ซ้ำซ้อน​หรือ code ที่​ไม่​งดงาม​ซึ่ง​ต้อง​เก็บกวาด มอง​หา code smell หรือ technical debt ที่​เห็น​ได้​ชัด โดย​เฉพาะ​ความ​ซ้ำซ้อน แล้วrefactorใน​ขั้น​นี้ ต้อง​รัน​ชุด test ต่อ​ไป​เรื่อย ๆ เพื่อ​ให้​แน่ใจ​ว่า​มัน​ยัง GREEN อยู่ — การ refactor ไม่​ควร​เปลี่ยน สิ่ง​ที่ code ทำ แต่​เปลี่ยน​เพียง วิธี​ที่ มัน​ทำ และ test คือ​สิ่ง​ที่​คอย​รับประกัน​ข้อ​นี้

Fowler เตือน​ว่า​ข้อ​ผิดพลาด​ที่​พบ​บ่อย​ที่สุด​ของ TDD คือ​การ ข้าม​ขั้น refactor ไป​เลย เพราะ test ผ่าน​แล้ว​ก็​รู้สึก​ว่า “เสร็จ” ทั้ง​ที่ code ยัง​ไม่​ถูก​จัด​ระเบียบ ผล​คือ​ได้ code ที่​มี test คุ้มครอง​ก็​จริง แต่​คุณภาพ​การ​ออกแบบ​ยัง​แย่​อยู่ดี

เมื่อ​พอใจ​กับ code แล้ว ก็​ถึง​เวลา​ไป​ยัง test ถัด​ไป​ใน list ก่อนหน้า​นั้น โดย​เฉพาะ​ถ้า​ใช้​ระบบ​ควบคุม version แบบ​เบา​หรือ​ทำงาน​ใน​เบรนช์ของ​ตัวเอง ลอง​พิจารณา commit หลัง​แต่ละ​ขั้น คุณ​อาจ​คิดถึง Red-Green-Refactor-Commit ว่า​เป็น Red-Green-Refactor แบบ​ใหม่​ของ TDD ก็ได้ ที่​จริง​คุณ​อาจ​อยาก commit ก่อน refactor ด้วย​ซ้ำ โดย​เฉพาะ​เมื่อ​กำลัง​จะ​เริ่ม​การ refactor ครั้ง​ใหญ่

flowchart TD
  Start[เริ่มหยิบ test ถัดไปจาก test list]
  WriteTest[เขียน test ใหม่ที่ยังไม่ผ่าน]
  RunRed[รัน test]
  CheckRed{test ล้มเหลวจริงไหม}
  WriteCode[เขียน code ขั้นต่ำสุดให้ผ่าน]
  RunGreen[รัน test อีกครั้ง]
  CheckGreen{test ผ่านทั้งชุดไหม}
  Refactor[refactor code และ test]
  Commit[Commit การเปลี่ยนแปลง]

  Start --> WriteTest --> RunRed --> CheckRed
  CheckRed -- ไม่ล้มเหลว แก้ test --> WriteTest
  CheckRed -- ล้มเหลวแบบ Red --> WriteCode
  WriteCode --> RunGreen --> CheckGreen
  CheckGreen -- ยังไม่ผ่าน --> WriteCode
  CheckGreen -- ผ่านแบบ Green --> Refactor
  Refactor --> Commit
  Commit --> Start

ตัวอย่าง​ต่อ​ไป​นี้​แสดง​การ​ใช้ Triangulation เพื่อ​บังคับ​ให้ code ถูก generalize จาก​ค่า hard-code ไป​เป็น​ตรรกะ​จริง — แนวทาง​เดียว​กับ​ที่ Microsoft Learn สาธิต​ไว้​ใน​เอกสาร TDD ของ Visual Studio โดย​เริ่ม​จาก stub ว่าง​แล้ว​ค่อย ๆ ขยาย​จนกว่า test ทั้งหมด​จะ​บังคับ​ให้​เกิด​ตรรกะ​ที่​ถูกต้อง

// test ที่ 1 — RED แล้ว GREEN ด้วยการ Fake It
[TestMethod]
public void Add_OnePlusOne_ReturnsTwo()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(1, 1);
// Assert
Assert.AreEqual(2, result);
}
// GREEN แบบ Fake It — ยังไม่ใช่ตรรกะจริง
public int Add(int a, int b) => 2;
// test ที่ 2 — Triangulation บังคับให้ Fake It อยู่ไม่ได้อีกต่อไป
[TestMethod]
public void Add_TwoPlusThree_ReturnsFive()
{
var calculator = new Calculator();
var result = calculator.Add(2, 3);
Assert.AreEqual(5, result);
}
// GREEN ที่แท้จริง — ตอนนี้ต้อง generalize แล้ว
public int Add(int a, int b) => a + b;

เมื่อ​ครบ2 test นี้ ค่า hard-code เดิม 2 ไม่​สามารถ​ทำให้ test ทั้ง​สอง​ผ่าน​พร้อม​กัน​ได้​อีก ทำให้การ generalize เป็น a + b เป็น​ทาง​เลือก​ที่​ง่าย​ที่สุด​ที่​ทำให้​ทั้ง​ชุด​กลับ​มา​เป็น GREEN — นี่​คือ​แก่น​ของ Triangulation: ให้ test เป็น​ตัว​บีบ​บังคับการ​ออกแบบ ไม่ใช่​ให้​ผู้​เขียน code คาด​เดา​ความ​ทั่วไป​ของ solution ไป​เอง​ล่วงหน้า

ประโยชน์

  • ฟีด​แบ็ก​เร็ว — รู้ทันที​ว่า​พฤติกรรม​ใด​พัง​จาก​การ​เปลี่ยนแปลง​ล่าสุด แทนที่​จะ​ไป​เจอ​ตอน QA หรือ production
  • ตาข่าย​นิรภัย​สำหรับ​การ refactor — เมื่อ​มี​ชุด test ที่​ครอบคลุม การ​ปรับ​โครงสร้าง code ทำได้​อย่าง​มั่นใจ​มาก​ขึ้น
  • ผลักดัน​การ​ออกแบบ​ที่​ดี​ขึ้น — code ที่ test ได้​ง่าย​มัก​มี unit เล็ก​ลง coupling หลวม​ขึ้น และ​มี interface ที่​ชัดเจน​ขึ้น​โดย​ธรรมชาติ
  • เอกสาร​ที่​รัน​ได้​จริง — ชุด test ทำ​หน้าที่​เป็น​ตัวอย่าง​การ​ใช้งาน​ที่​เป็น​ปัจจุบัน​เสมอ ต่าง​จาก​เอกสาร​ข้อความ​ที่​ล้าสมัย​ได้​ง่าย

ข้อ​ควร​ระวัง

  • งาน​วิจัย​เชิง meta-analysis (เช่น​งาน​ปี 2013 ที่ Wikipedia อ้างอิง) พบ​ว่า TDD มี​ผล​บวก​เล็กน้อย​ต่อ​คุณภาพ​ภายนอก​ของ code แต่​แทบ​ไม่มี​ผล​ต่อ productivity โดย​รวม — TDD ไม่ใช่​ยา​วิเศษ​ที่​รับประกัน​ผลลัพธ์​เสมอ​ไป
  • การ​ข้าม​ขั้น refactor คือ​กับดัก​ที่​พบ​บ่อย​ที่สุด ทำให้​ได้ code ที่​มี test คุ้มครอง​แต่​โครงสร้าง​ยัง​ไม่​ดี
  • การ​พึ่งพา mock/stub มาก​เกิน​ไป​อาจ​ทำให้ test ผูก​ติด​กับ​รายละเอียด implementation จน​กลาย​เป็น test เปราะ (fragile) ที่​พัง​ทุก​ครั้ง​ที่ refactor แม้​พฤติกรรม​ภายนอก​จะ​ไม่​เปลี่ยน
  • TDD เน้น​ระดับ unit เป็น​หลัก ยัง​ต้อง​มี integration/system/acceptance test เสริม​เพื่อ​ครอบคลุม​พฤติกรรม​ข้าม component, ฐาน​ข้อมูล หรือ​ระบบ​กระจาย
  • การ​ลำดับ test (test list) และ​เลือก​กรณี​ทดสอบ​ที่ “พา​ไป​สู่​จุด​ออกแบบ​สำคัญ” เป็น​ทักษะ​ที่​ต้อง​ฝึก มือใหม่​มัก​รู้สึก​ว่า TDD ทำให้​ช้า​ลง​ใน​ช่วง​แรก ก่อน​ที่​จะ​คุ้น​เคย