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

Red, Green, Refactor

แดง เขียว refactor — จังหวะ​สาม​ก้าว​ของ TDD

Red, Green, Refactor คือ​แนว​ปฏิบัติ (practice) ที่​เป็น​หัวใจ​ของ Test-Driven Development มัน​คือ​วงจร​สาม​จังหวะ​ที่​ทำซ้ำ​หลาย​สิบ​รอบ​ต่อ​ชั่วโมง​ระหว่าง​การ​เขียน code: เขียน test ที่​ล้มเหลว​ก่อน (Red) ทำให้​มัน​ผ่าน​ด้วย code ที่​น้อย​ที่สุด (Green) แล้ว​จึง​พิจารณา​ว่า​จะ​ปรับปรุง design ของ code ที่​มี​อยู่​ให้​ดี​ขึ้น​หรือ​ไม่ (Refactor) ก่อน​วน​กลับ​ไป​เริ่ม​รอบ​ใหม่

ชื่อ “Red” และ “Green” มา​จาก​สี​ที่​ตัว​รัน test ส่วน​ใหญ่​ใช้​แสดง​ผล — test ที่​ล้มเหลว​ติด​สี​แดง test ที่​ผ่าน​ติด​สี​เขียว วงจร​นี้​ถูก​เผยแพร่​กว้าง​ขวาง​ผ่าน​หนังสือ Test-Driven Development: By Example ของ Kent Beck และ​ถูก​อธิบาย​ซ้ำ​ใน​หลาย​แหล่ง เช่น​บทความ “Workflows of Refactoring” ของ Martin Fowler ที่​สรุป​สาม​ขั้น​ไว้​ว่า Add a Test, Make it Work, Make it Clean

หลักการ​สำคัญ​ที่​มัก​ถูก​มอง​ข้าม​คือ แต่ละ​ขั้น​ต้องการ​โหมด​ความคิด​ที่​ต่าง​กัน — ตอน​เขียน code ให้ test ผ่าน (Green) ให้​สนใจ​แค่ “ทำงาน​ถูกต้อง” อย่า​ห่วง​เรื่อง design ส่วน​ตอน Refactor ให้​สนใจ​แค่ “design สะอาด” โดย​ห้าม​เปลี่ยน​พฤติกรรม การ​แยก​สอง​โหมด​นี้​ออก​จาก​กัน​อย่าง​เคร่งครัด (นัก​เขียน​บาง​คน​เรียก​ว่า “wear one hat at a time”) คือ​สิ่ง​ที่​ทำให้​วงจร​นี้​ปลอดภัย​และ​เร็ว เพราะ​ไม่​ต้อง debug พร้อม​กับ​ปรับ design ใน​เวลา​เดียวกัน

flowchart LR
    Think[คิดว่าจะ test อะไรต่อ] --> Red[Red: เขียน test ที่ล้มเหลว]
    Red --> Green[Green: เขียน code น้อยที่สุดให้ผ่าน]
    Green --> Decide{ควร Refactor ไหม}
    Decide -->|ใช่| Refactor[Refactor: ปรับ design test ยังเขียว]
    Decide -->|ยังไม่ใช่| Think
    Refactor --> Think

ใน TDD ทุก​อย่าง​เริ่มต้น​ด้วย test ที่​ล้มเหลว คุณ​เขียน test ก่อน เพื่อ​จะ​ได้​รู้​ว่า​เมื่อไร​ควร หยุด เขียน code สำหรับ​ก้าว​เล็ก ๆ ก้าว​นี้​ของ​กระบวนการ​ทั้งหมด — นี่​คือ​ความหมาย​ของ​คำ​ว่า driven (ขับ​เคลื่อน) ใน Test-Driven Development test เป็น​ตัว​ขับ​เคลื่อน​การ​เขียน code โปร​ดัก​ชัน ด้วย​การ​มอบ​เป้าหมาย​เฉพาะ​หน้า​ให้​คุณ

test แต่ละ​ตัวตั้ง สมมติฐาน ว่า​ระบบ​ควร​หรือ​อาจ​ทำงาน​อย่างไร ต่าง​จาก​วิทยาศาสตร์​ที่​มี​โลก​เพียง​ใบ​เดียว​ให้​ทดลอง​ภาย​ใต้​กฎ​ชุด​เดียว test รัน​กับ​ระบบ​ซอฟต์แวร์​ที่​เปลี่ยนแปลง​ได้​ตลอด​เวลา ดังนั้น​สมมติฐาน​ที่​เป็น​เท็จ​ใน​ซอฟต์แวร์ version หนึ่ง อาจ​เป็น​จริง​ใน version อนาคต (และ​อาจ​กลับ​เป็น​เท็จ​อีก​ครั้ง​หาก​เกิด regression!)

ตัว​รัน test ส่วน​ใหญ่​แสดง test ที่​ล้มเหลว​ด้วย​สี​แดง จึง​ใช้​สี​นี้​แทน​สถานะ​ที่​มี test ล้มเหลว และ​สำคัญ​มาก​ที่ test แต่ละ​ตัว​ต้อง​ถูกรัน — และ​เห็น​ว่า​มัน​ล้มเหลว — ก่อน ที่ code ซึ่ง​มัน​ทดสอบ​จะ​ถูก​เขียน​ขึ้น เพราะ​มัน​ง่าย​เกิน​ไป​ที่​จะ​เผลอ​เขียน test ที่​ไม่มี​ทาง​ล้มเหลว หรือ​ที่​ไม่​ได้​ทดสอบ​สิ่ง​ที่​คุณ​คิด​ว่า​มัน​ทดสอบ James Shore เรียก​ขั้น​ก่อนหน้า​นี้ (ก่อน​ลงมือ​เขียน test) ว่า “Think” — เลือก​ว่า​จะ test อะไร​ต่อ​ไป ซึ่ง​เขา​มอง​ว่า​เป็น​ขั้น​ที่​ยาก​ที่สุด​สำหรับ​มือใหม่ เพราะ​ต้อง​ออกแบบ interface ไป​พร้อม​กับ​ตัดสิน​ใจ​ว่า​พฤติกรรม​ถัด​ไป​ที่​ควร​มี​คือ​อะไร

ทำให้ test ผ่าน​อย่าง​ง่าย​ที่สุด​เท่า​ที่​จะ​ทำได้ แล้ว หยุด ต้านทาน​สิ่ง​ล่อ​ใจ​ที่​จะ​เขียน code เพิ่ม เพื่อ​คาด​เดา​ความ​ต้องการ​ใน​อนาคต (ดู YAGNI) หรือ​เพื่อ​ทำให้​ทั่วไป​หรือ optimize เกิน​จำเป็น Fowler อธิบาย​ขั้น​นี้​ว่า​ทำให้​ทำงาน “simply but crudely” ได้ — บาง​ครั้ง​ถึง​ขั้น hardcode คำ​ตอบ​ไป​ก่อน​ก็ได้ ตราบ​ใด​ที่ test ผ่าน​จริง เพราะ​ความ​สวยงาม​ของ design เป็น​หน้าที่​ของ​ขั้น​ถัด​ไป

ประโยชน์​สำคัญ​ข้อ​หนึ่ง​ของ TDD คือ​มัน​ลด​ปริมาณ code ที่​ต้อง​เขียน​เพื่อ​ให้​ระบบ​ทำงาน​ตาม​ข้อ​กำหนด code ที่​เขียน​น้อย​ลง​หมาย​ถึง​เวลา​เขียน code น้อย​ลง รวม​ถึง​เวลา​ใน​การ​ดูแล​และ debug code ส่วนเกิน​นั้น​น้อย​ลง​ด้วย ใน​แง่​นี้ การ​ใช้ TDD จึง​เป็น​วิธี​นำ​หลักการ​แบบ lean มา​ใช้​กับ​ซอฟต์แวร์ เมื่อ​เขียน test ที่​ล้มเหลว​แล้ว​ทำให้​ผ่าน​ได้ ก็​เป็น​จังหวะ​ที่​ดี​ที่​จะ commit อย่าง​น้อย​ก็​ใน​เครื่อง​ของ​ตัวเอง

Refactoring คือ​การ​เปลี่ยน design ของ code โดย​ไม่​เปลี่ยน​พฤติกรรม (ภายนอก) ของ​มัน ดู​รายละเอียด​เพิ่มเติม​ได้ที่ Refactor Mercilessly คุณ​ควร refactor เฉพาะ​เมื่อ test ทั้งหมด​เป็น​สี​เขียว เท่านั้น — วิธี​นี้​จะ​ทำให้​มั่นใจ​ได้​ว่า test ใด​ที่​พัง​หลัง​การ refactor เป็น​เพราะ​คุณ​ทำ​พลาด ไม่ใช่​เพราะ​ระบบ​พัง​อยู่​ก่อน​แล้ว นี่​คือ​กฎ​ที่ Fowler ย้ำ​ว่า “you only refactor with green tests, and any test failing indicates a mistake”

เป็น​ความคิด​ที่​ดี​ที่​จะ commit หลัง​การ refactor แต่ละ​ก้าว โดย​เฉพาะ​ถ้า​ยัง​ไม่​ได้ commit ก่อนหน้า​นั้น แม้​รอบ TDD จะ​ทำงาน​เป็น​ก้าว​เล็ก ๆ ราว 5–10 นาที​ต่อ1 test (Shore ระบุ​ว่า​นัก​พัฒนา​ที่​คล่อง​มัก​วน​รอบ​ได้ 20–40 ครั้ง​ต่อ​ชั่วโมง) ก็​ยัง​คุ้ม​ที่​จะ commit ใน​เครื่อง​ต่อ1 test เผื่อ​วัน​หนึ่ง​พบ​ว่า​เดิน​มา​ผิด​ทาง จะ​ได้​ไม่​เสีย​ความ​คืบ​หน้า​มาก​เกิน​ไป​เวลา​ย้อน​กลับ​ไป​ยัง commit ก่อนหน้า จำ​สุภาษิต​ตุรกี​ที่​ว่า “ไม่​ว่า​คุณ​จะ​เดิน​มา​ผิด​ทาง​ไกล​แค่​ไหน จง​หัน​หลัง​กลับ​เดี๋ยวนี้”

เคล็ด​ลับ: ระหว่าง commit ให้ refactor code test หรือ code system under test อย่าง​ใด​อย่าง​หนึ่ง แต่​อย่า​ทำ​ทั้ง​สอง​พร้อม​กัน วิธี​นี้​ทำให้ commit เล็ก​ลง และ​ทำให้ test คอย​ตรวจสอบ​การ​แก้ SUT ของ​คุณ ขณะ​ที่ SUT ก็​คอย​ตรวจสอบ code test ของ​คุณ​เช่น​กัน

สุดท้าย พึง​จำ​ไว้​ว่า refactoring เป็น​ขั้น ที่​เลือก​ได้ หาก​กำลัง​ไป​ได้​สวย​และ​การ refactor มี​แต่​จะ​ทำให้​ช้า​ลง มัก​เป็น​ความคิด​ที่​ดี​ที่​จะ​เดิน​หน้า​ต่อ​อีก​สัก​หนึ่ง​หรือ2 test แล้ว​ค่อย​หยุด​พินิจ​สิ่ง​ที่​ทำ​ออก​มา​จริง ๆ ว่า​จะ​ทำให้​เรียบ​ง่าย​ขึ้น​หรือ​ดี​ขึ้น​ได้​อีก​หรือ​ไม่

“ไม่​ว่า​คุณ​จะ​เดิน​มา​ผิด​ทาง​ไกล​แค่​ไหน จง​หัน​หลัง​กลับ​เดี๋ยวนี้”

— สุภาษิต​ตุรกี

สมมติ​ต้อง​เขียน class ตะกร้า​สินค้า​อย่าง​ง่าย ทำ TDD หนึ่ง​รอบ​เต็ม​ด้วย xUnit ใน​ภาษา C#

Red — เขียน test ก่อน​ที่ ShoppingCart จะ​มี​อยู่​จริง test นี้ compile ไม่​ผ่าน​หรือ​รัน​แล้ว​ล้มเหลว​แน่นอน

public class ShoppingCartTests
{
[Fact]
public void Total_ควรรวมราคาสินค้าทั้งหมดในตะกร้()
{
var cart = new ShoppingCart();
cart.AddItem("Book", 250m);
cart.AddItem("Pen", 15m);
Assert.Equal(265m, cart.Total());
}
}

Green — เขียน code น้อย​ที่สุด​เท่า​ที่​จำเป็น​ให้ test ผ่าน ยัง​ไม่​ต้อง​สวย ไม่​ต้อง​กัน edge case ที่​ยัง​ไม่มี test คลุม

public class ShoppingCart
{
private readonly List<decimal> _prices = new();
public void AddItem(string name, decimal price) => _prices.Add(price);
// เขียนแบบตรงไปตรงมาที่สุดให้ test ผ่านก่อน ค่อย refactor ทีหลัง
public decimal Total()
{
decimal sum = 0m;
foreach (var p in _prices)
{
sum += p;
}
return sum;
}
}

Refactor — test เขียว​แล้ว จึง​ปรับ Total() ให้​กระชับ​ขึ้น​ด้วย LINQ โดย​พฤติกรรม​ภายนอก​ยัง​เหมือน​เดิม​ทุก​ประการ รัน test ซ้ำ​เพื่อ​ยืนยัน​ว่า​ยัง​ผ่าน

public decimal Total() => _prices.Sum();

จาก​นั้น​วน​กลับ​ไป​ที่ Red อีก​ครั้ง​ด้วย test ถัด​ไป เช่น กรณี​ตะกร้า​ว่าง​ควร​คืน 0m หรือ​กรณี​ใส่​จำนวน (quantity) ของ​สินค้า​แต่ละ​ชิ้น

ประโยชน์

  • ฟีด​แบ็ก​เร็ว​และ​แม่นยำ — เพราะ​แต่ละ​ก้าว​เล็ก​มาก bug ที่​เกิด​ขึ้น​จะ​อยู่​ใน code ไม่​กี่​บรรทัด​ล่าสุด​เสมอ ทำให้​หา​สาเหตุ​ง่าย​กว่า​มาก
  • safety net สำหรับ refactor — มี​ชุด test เขียว​คอย​ยืนยัน​ว่า code ยัง​ทำงาน​ถูกต้อง​หลัง​ปรับ design ลด​ความ​กลัว​ใน​การ​แตะ code เก่า
  • ลด code ส่วนเกิน — เมื่อ​เขียน​แค่​พอ​ให้ test ผ่าน ธรรมชาติ​ของ​วงจร​จะ​ผลักดัน​ให้​ยึด YAGNI และ Keep It Simple โดย​อัตโนมัติ
  • เอกสาร​ที่​รัน​ได้ — test ที่​ผ่าน​คือ​ข้อ​กำหนด (specification) ของ​ระบบ ณ ขณะ​นั้น อ่าน​แล้ว​รู้​ว่า​ระบบ​ควร​ทำงาน​อย่างไร​จริง ๆ

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

  • การ​ข้าม​ขั้น Refactor คือ​ความ​ผิดพลาด​ที่​พบ​บ่อย​ที่สุด Fowler เตือน​ว่า​ถ้า​ละเลย​ขั้น​นี้ ผลลัพธ์​จะ​กลาย​เป็นกอง code ปะติดปะต่อ​ที่​ทำงาน​ได้​แต่​ดูแล​ยาก
  • อย่า refactor ขณะ test แดง การ​ปน​สอง​โหมด (เพิ่ม feature + ปรับ design) พร้อม​กัน​ทำให้ debug ยาก​ขึ้น​มาก เพราะ​ไม่รู้​ว่า​ความ​ล้มเหลว​มา​จาก​ตรรกะ​ใหม่​หรือ​จาก​การ refactor
  • test ที่​ไม่​เคย​เห็น​ล้มเหลว​เป็น test ที่​เชื่อถือ​ไม่​ได้ ถ้า​ข้าม​ขั้น Red ไป​เลย​เขียน production code ก่อน อาจ​ได้ test ที่​ผ่าน​เสมอ​ไม่​ว่า code จะ​ถูก​หรือ​ผิด ดู Poor Tests เป็นกลิ่น​ที่​เกี่ยวข้อง
  • ก้าว​ที่​ใหญ่​เกิน​ไป​ทำลาย​จังหวะ ถ้า1 test ต้อง​ใช้​เวลา​นาน​กว่า​จะ​เขียน production code ให้​ผ่าน มัก​เป็น​สัญญาณ​ว่า test นั้น​ครอบคลุม​มาก​เกิน​ไป​ใน​คราว​เดียว ควร​ตัด​ให้​เล็ก​ลง
  • TDD ไม่ใช่​ตัวแทน​ของ​การ​ออกแบบ​สถาปัตยกรรม​ระดับ​สูง วงจร Red-Green-Refactor ทำงาน​ได้​ดี​ใน​ระดับ​หน่วย​ย่อย (unit) แต่​การ​ตัดสิน​ใจ​เชิง​สถาปัตยกรรม​ยัง​ต้อง​อาศัย​การ​คิด​แยก​ต่างหาก