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

test ที่​ไซต์​นี้​ยก​เป็น​ตัวอย่าง​ที่​ดี จับ​อะไร​ไม่​ได้​บ้าง

โดเมน Order ที่​คอร์ส เขียน test ให้​ระบบ​จริง ใช้​สอน​ตลอด​ทั้ง​แปดบท มี​จุด​ที่​แก้​ให้​ผิด​ได้ 164 จุด

แก้​ที​ละ​จุด รัน​ชุด test ที่​คอร์ส​ยก​ให้​เป็น​ตัวอย่าง​ที่​ดี​ซ้ำ​ทุก​ครั้ง แล้ว​นับ​ว่า​มี​กี่​จุด​ที่ test จับ​ไม่​ได้

เฉพาะ​บน code ที่​คอร์ส พิมพ์​ไว้​จริง มี​ตัว​กลาย​พันธุ์​ที่​คอมไพล์​ผ่าน 53 ตัว และ 11 ตัว​ใน​นั้น​รอด — code ผิด​ไป​แล้ว แต่ test ทุก​ตัว​ยัง​เขียว

ธีสิส​ของ​หน้า​นี้

ไซต์​นี้​เขียน​ไว้​แล้ว​ว่า coverage วัด​การ​รัน ไม่​ได้​วัด​คุณภาพ​ของ assert หน้า​นี้​ไม่​ได้​มา​เถียง​ข้อ​นั้น มัน​มา​วัด

และ​สิ่ง​ที่​การ​วัด​บอก ต่าง​จาก​ภาพ​ที่​บทเรียน​วาง​ไว้​ตรง​จุด​เดียว: บทเรียน​วาง​ไว้​เป็น​สอง​ขั้ว — test ที่​ดี​จับ​ได้ test ที่​แย่​จับ​ไม่​ได้

ของ​จริง​ไม่ใช่​สอง​ขั้ว ชุด​ที่​คอร์ส​ยกย่อง​ก็​ปล่อย​รอด และ​ชุด​ที่​คอร์ส​สั่ง​ให้​ลบ​ทิ้ง​ก็​จับ​ได้​บาง​อย่าง​ที่​ชุด​แรก​จับ​ไม่​ได้

คำถาม​ที่​ใช้งาน​ได้​จึง​ไม่ใช่ “test ชุด​นี้​ดี​ไหม” แต่​คือ “มัน​ปล่อย​ความ​ผิดพลาด​แบบ​ไหน​ผ่าน​ไป​ได้ และ​คุณ​รับ​ข้อ​นั้น​ได้​หรือ​เปล่า”

ไซต์​นี้​เขียน​ข้อ​นี้​ไว้​แล้ว หน้า​นี้​แค่​ไป​นับ

หัวข้อ​ที่​มีชื่อ​ว่า “ไซต์​นี้​เขียน​ข้อ​นี้​ไว้​แล้ว หน้า​นี้​แค่​ไป​นับ”

บท​ที่ 8 ของ​คอร์ส test อธิบาย​กลไก​นี้​ไว้​ครบ​แล้ว และ​อธิบาย​ถูก สิ่ง​ที่​ยัง​ไม่มี​คือ​ตัวเลข

บท​ที่ 8 — อะไร​ที่ “ไม่​ควร” test เขียน​ไว้​ตรง​ตัว​ว่า coverage “วัด​แค่​ว่า​บรรทัด​ไหน​ถูกรัน​ผ่าน​ระหว่าง test ไม่​ได้​วัด​ว่า assert ที่​ตาม​มา​ตรวจ​ผลลัพธ์​ถูกต้อง​จริง​ไหม”

บท​เดียวกัน​ยัง​ยก​ตัวอย่าง​ต่อว่า ถ้า​มี​คน​แก้ guard clause ผิด​จน​สถานะ​ไม่​เปลี่ยน หรือ​ลืม​ปล่อย event ตัว test ที่​ไม่ assert อะไร​เลย​ก็​ยัง​เขียว​เหมือน​เดิม

ประโยค​นั้น​คือ การกลาย​พันธุ์​ที่​ถูก​สมมติ​ขึ้น — จินตนาการ​ว่า​ถ้า​แก้ code ตรง​นั้น​ให้​ผิด​แล้ว​จะ​เกิด​อะไร หน้า​นี้​คือ​การ​ลงมือ​แก้​จริง​แล้ว​นับ​ผล

คลัง​อ้างอิง DevIQ ก็​เตือน​เรื่อง​เดียวกัน​ไว้​ใน​มุม​ของ Goodhart’s Law และ Poorly Written Tests เรียก​อาการ​นี้​ว่า “coverage สูง​แต่​ไม่มี​ความหมาย”

สาม​หน้า​นั้น​เป็น​เจ้าของ ข้อ​โต้แย้ง หน้า​นี้​ไม่​ได้​มา​แย่ง มัน​มา​เติม​สิ่ง​เดียว​ที่​ทั้ง​สาม​หน้า​ไม่มี คือ จำนวน

หนึ่ง​ตัว​กลาย​พันธุ์ คือ​การ​แก้​ผิด​หนึ่ง​ครั้ง

หัวข้อ​ที่​มีชื่อ​ว่า “หนึ่ง​ตัว​กลาย​พันธุ์ คือ​การ​แก้​ผิด​หนึ่ง​ครั้ง”

วิธี​วัด​ทั้งหมด​คือ แก้ code ให้​ผิด​หนึ่ง​จุด รัน​ชุด test ซ้ำ แล้ว​ดู​ว่า​แดง​หรือ​ไม่​แดง

mutation testing (การ​วัด​คุณภาพ​ชุด test ด้วย​การ​จงใจ​แก้ code ให้​ผิด) ทำงาน​ด้วย​คำ​สอง​คำ:

  • killed — มี test อย่าง​น้อยหนึ่ง​ตัว​แดง​เพราะ​การ​แก้​นั้น ชุด test เฝ้า​จุด​นี้​อยู่​จริง
  • survived — test ทุก​ตัว​ยัง​เขียว ทั้ง​ที่ code ผิด​ไป​แล้ว ไม่มี​ใคร​เฝ้า​จุด​นี้

ตัวอย่าง​จริง​จาก​โดเมน​นี้ — Money.Round() ที่ บท​ที่ 2 เขียน​ไว้:

public Money Round() =>
new(Math.Round(Amount, 2, MidpointRounding.AwayFromZero), Currency);

เปลี่ยน AwayFromZero เป็น ToPositiveInfinity แล้ว​รัน​ชุด test ของ​คอร์ส​ซ้ำ

ทุก​ตัว​ยัง​เขียว นั่น​คือ​หนึ่ง survivor

ตัว​กลาย​พันธุ์​ที่ คอมไพล์​ไม่​ผ่าน ไม่​นับ​เข้าตัว​ส่วน เพราะ​มัน​ไม่ใช่​ความ​ผิดพลาด​ที่​หลุด​ขึ้น production ได้ ใน​โดเมน​นี้​มี 5 ตัว เหลือ 159 ตัว​ที่​นับ​จริง

เส้น​แบ่ง​สาม​ระดับ ที่​ต้อง​ขีด​ก่อน​อ่าน​ตัวเลข​ใด ๆ

หัวข้อ​ที่​มีชื่อ​ว่า “เส้น​แบ่ง​สาม​ระดับ ที่​ต้อง​ขีด​ก่อน​อ่าน​ตัวเลข​ใด ๆ”

code ใน​บทเรียน​ไม่ใช่ project ที่​คอมไพล์​ได้ บาง​ส่วน​ต้อง​เติม​เอง และ survivor บน​ส่วน​ที่​เติม​เอง​พูด​ถึง​คอร์ส​ไม่​ได้

คอร์ส​พิมพ์ Order, Money, PriceBreakdown และ​เพื่อน ๆ ไว้​เป็น​ชิ้น ๆ กระจาย​แปดบท บาง​สมาชิก​ให้ body ครบ บาง​ตัว​ให้​แค่ signature กับ​คำ​บรรยาย

ตัว​วัด​จึง​ติด​ป้าย​ที่มา​ไว้​ทุก​บรรทัด แล้ว​ผูก​ป้าย​นั้น​เข้า​กับ​ตัว​กลาย​พันธุ์​ทุก​ตัว:

ป้ายหมายความ​ว่าsurvivor ที่​นั่น​พูด​ถึง​อะไร​ได้
verbatimคอร์ส​พิมพ์ code ก้อน​นี้​ไว้ คัด​มา​ตรง​ตัวพูด​ถึง​คอร์ส​ได้
proseคอร์ส​ระบุ​พฤติกรรม​ไว้​ใน​คำ​บรรยาย แต่​ไม่​ได้​พิมพ์ codeพูด​ถึง test ได้ แต่​พูด​ถึง code ไม่​ได้
reconstructedคอร์ส​ไม่​ได้​ระบุ ตัว​วัด​เลือก​วิธี​เขียน​เองพูด​ถึง​อะไร​ไม่​ได้​เลย

ป้าย​ที่มา​สาม​ระดับ — ทุก​บรรทัด​ใน subject ของ​ตัว​วัด​ถูก​ติด​ป้าย​ไว้​หนึ่ง​ใบ · ตาราง​นี้​อธิบาย​วิธี​อ่าน ไม่ใช่​ผล​การ​วัด

ตัวอย่าง​ที่​ชัด​ที่สุด​ของ​ระดับ​ล่าง​สุด​คือ PriceBreakdown — คอร์ส​เขียน block ตรวจ invariant ไว้​ใน​รูป​ที่ ไม่ใช่ C# ที่​คอมไพล์​ได้ ตัว​วัด​จึง​ต้อง​เลือก​วิธี​เข้า​รหัส​เอง

ใน​รูป​ที่​เลือก การ assign ค่า​ลง property ห้า​บรรทัด​กลาย​เป็น​จุด​กลาย​พันธุ์​ห้า​จุด ส่วน​ใน positional record จริง compiler เขียน​ให้​เอง จึง​ไม่มี​จุด​พวก​นั้น​อยู่​ตั้งแต่​แรก

survivor ห้า​ตัว​นั้น​เป็น​คุณสมบัติ​ของ​วิธี​เขียน​ของ​ตัว​วัด ไม่ใช่​ของ​คอร์ส ตัวเลข​ที่​เหลือ​ของ​หน้า​นี้​จึง​รายงาน​แยก​กัน​เสมอ

ทุก​อัตราส่วน​ใน​หน้า​นี้​คิด​จาก​ตัว​กลาย​พันธุ์​ที่​คอมไพล์​ผ่าน​เท่านั้น และ​แยก​ตาม​ป้าย​ที่มา​เสมอ

ที่มา​ของ codeคอมไพล์​ผ่านชุด​ของ​คอร์ส​ปล่อย​รอดรอด​หลัง​เติม test ที่​หน้า​นี้​เสนอ
verbatim53110
prose771616
reconstructed292020

ตาราง 1 — ผล​แยก​ตาม​ที่มา​ของ code · วัด​บน .NET 10.0.10 · Roslyn 4.14.0 · xunit.assert 2.9.3

รวม​ทั้ง​โดเมน ชุด test ของ​คอร์ส​ฆ่า​ได้ 112 ตัว และ​ปล่อย​รอด 47 ตัว จาก 159 ตัว

อย่า​เอา​สัดส่วน​นี้​ไป​เทียบ​กับ codebase ของ​คุณ โดเมน​ตัวอย่าง​ขนาด​นี้​ให้​เปอร์เซ็นต์​ที่​ไม่​ได้​แทน​อะไร สิ่ง​ที่​หน้า​นี้​อ้าง​ได้​คือ ตัวตน​ของ survivor แต่ละ​ตัว ไม่ใช่​อัตราส่วน

ชุด test ที่​วัด​คือ 13 test case ที่​คอร์ส​ยก​ให้​เป็น​ตัวอย่าง​ที่​ดี เทียบ​กับ 6 case ที่​คอร์ส​ยก​มา​เพื่อ​บอกว่า​อย่า​เขียน​แบบ​นี้

ทั้งหมด​กระจุก​อยู่​สี่​เรื่อง และ​ไม่มี​เรื่อง​ไหน​เป็น​อุบัติเหตุ ทุก​เรื่อง​คือ assert ที่​หาย​ไป​หนึ่ง​ข้อ

DeliveryFeeCalculator จาก บท​ที่ 5 คิด​ค่า​ส่ง​ฟรี​ใน​ระยะ​หนึ่ง​กิโลเมตร:

public Money Calculate(double distanceKm)
{
if (distanceKm <= FreeDeliveryRadiusKm)
return Money.Thb(0);
var billableKm = (decimal)(distanceKm - FreeDeliveryRadiusKm);
return Money.Thb(Math.Round(BaseFee + PerKmRate * billableKm, 2, MidpointRounding.AwayFromZero));
}

test ของ​คอร์ส​ป้อน​ระยะ​สอง​ค่า คือ​ใน​ระยะ​ฟรี​กับ​นอก​ระยะ​ฟรี

เปลี่ยน <= เป็น < แล้ว test ทั้ง​คู่​ยัง​เขียว เพราะ ระยะ​ที่​เท่ากับ​ขอบ​พอดี ไม่​เคย​ถูก​ป้อน​เข้าไป​เลย

ที่​ระยะ​ขอบ​พอดี code เดิม​คืน​ศูนย์ ส่วนตัว​กลาย​พันธุ์​เก็บ​ค่า​ส่ง​ฐาน​เต็ม​จำนวน นี่​คือ​ความ​ต่าง​ที่​ผู้​ใช้​เห็น​บน​ใบเสร็จ

บรรทัด​เดียวกัน​นั้น​ระบุ​การ​ปัดเศษ​ไว้​สอง​อย่าง คือ​ปัด​กี่​ตำแหน่ง และ​ปัด​จุด​กึ่งกลาง​ไป​ทาง​ไหน

ตัว​วัด​แตะ​ทั้ง​สอง​อย่าง รวม 5 ตัว​กลาย​พันธุ์ และ รอด​ทุก​ตัว

เปลี่ยน​อะไรเป็น​อะไรtest ที่​ปิด​มัน​ได้
AwayFromZeroToEvenระยะ 1 แบบ
AwayFromZeroToNegativeInfinityระยะ 2 แบบ
AwayFromZeroToPositiveInfinityระยะ 1 แบบ
AwayFromZeroToZeroระยะ 2 แบบ
จำนวน​ตำแหน่ง 23ระยะ 3 แบบ

ตาราง 2 — การ​ปัดเศษ​ของ DeliveryFeeCalculator ทั้ง​ห้า​แบบ​รอด​จาก​ชุด​ของ​คอร์ส · วัด​บน .NET 10.0.10

สาเหตุ​อยู่​ที่​ข้อมูล​นำ​เข้า ไม่ใช่​ที่ assert — ระยะ​สอง​ค่าที่​คอร์ส​เลือก​ให้​ค่า​ส่ง​ที่​ลงตัว​พอดี​ทั้ง​คู่ จึง​ไม่มี​เศษ​ให้​ปัด​ตั้งแต่​แรก

assert ที่​ถูกต้อง​บน​ข้อมูล​ที่​ไม่​แตะ​เงื่อนไข พิสูจน์​เงื่อนไข​นั้น​ไม่​ได้ และ coverage รายงาน​ว่า​บรรทัด​นี้​ถูกรัน​แล้ว​ทั้ง​สอง​ครั้ง

Money.Round() ตกหลุม​เดียวกัน​แต่​ตื้น​กว่า — ค่า​เดียว​ที่​คอร์ส​ป้อน​คือ​จุด​กึ่งกลาง​พอดี โหมด​ปัด​สาม​ใน​สี่​แบบ​จึง​ตาย​เพราะ​มัน​ตอบ​ต่าง​กัน​ที่​จุด​นั้น เหลือ ToPositiveInfinity ที่​ตอบ​เท่า​กัน​พอดี​แล้ว​รอด​ไป

คอร์ส​เขียน​ข้อความ exception ไว้​เป็น​ภาษา​ไทย​เต็ม​ประโยค​ทุก​ก้อน และ บท​ที่ 2 ก็ assert ข้อความ​นั้น​เต็ม ๆ ใน​กรณี​สถานะ​ผิด​กฎ

แต่​อีก​สอง​ที่ assert แค่ ชนิด ของ exception ล้าง​ข้อความ​ทิ้ง​ทั้ง​ก้อน​แล้ว test ยัง​เขียว

สมาชิกข้อความ​ที่​ถูก​ล้าง​ทิ้ง
Order.Placeตะกร้า​ว่างเปล่า สร้าง​ออเดอร์​ไม่​ได้
PriceBreakdown..ctorPriceBreakdown ไม่​ถูกต้อง — Total ต้อง​เท่ากับ Subtotal หัก​ด้วย Discount บวก DeliveryFee บวก VAT เสมอ

ตาราง 3 — ข้อความ​ที่​ไม่มี test ตัว​ไหน​เฝ้า​อยู่ · วัด​บน .NET 10.0.10

ข้อ​นี้​ไม่ใช่ bug เสมอ​ไป การ​เลือก​ไม่ assert ข้อความ​เป็นการ​ตัดสิน​ใจ​ที่​มี​เหตุผล เพราะ​ข้อความ​เป็น​สิ่ง​ที่​เปลี่ยน​บ่อย​และ test ที่​ผูก​กับ​มัน​จะ​เปราะ

แต่​คอร์ส​เลือก​ไป​แล้ว​ว่า assert — ที่​วัด​ได้​คือ​มัน​เลือก​ไม่​เหมือน​กัน​สาม​ที่ โดย​ไม่มี​อะไร​บอกว่า​ตั้งใจ

สอง​ตัว​สุดท้าย​เป็นการ​ลบ​ทั้ง​บรรทัด​ออก​จาก constructor:

สมาชิกบรรทัด​ที่​ถูกลบ
Order..ctorId = id;
OrderLine..ctorProductId = productId;

ตาราง 4 — การ​ประกอบ​ร่าง​ที่​ไม่มี test ตัว​ไหน​ยืนยัน · วัด​บน .NET 10.0.10

ลบ Id = id; ออก​แล้ว Order ที่​สร้าง​เสร็จ​จะ​ไม่มี id ที่​รับ​มา และ test ทุก​ตัว​ยัง​เขียว เพราะ​ไม่มี​ตัว​ไหน assert order.Id เลย​สัก​ครั้ง

ใน​ระบบ​จริง นั่น​คือ​ออเดอร์​ที่​บันทึก​ลง​ฐาน​ข้อมูล​ด้วย key ที่​ไม่ใช่​ของ​มัน

test ที่​คอร์ส​สั่ง​ให้​ลบ​ทิ้ง คือ​ตัว​เดียว​ที่​จับ​ข้อ​หนึ่ง​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “test ที่​คอร์ส​สั่ง​ให้​ลบ​ทิ้ง คือ​ตัว​เดียว​ที่​จับ​ข้อ​หนึ่ง​ได้”

ตัว​กลาย​พันธุ์ 1 ตัว​ใน​การ​วัด​นี้ ถูก​ฆ่า​โดย​ชุด​ที่​คอร์ส​บอกว่า​ไม่​คุ้ม​เขียน​เท่านั้น

บท​ที่ 8 ยก test คู่​นี้​มา​เป็น​ตัวอย่าง​ของ “ไม่​คุ้ม​เขียน” แล้ว​สรุป​ว่า​คำ​ตอบ​คือ​ลบ​ทิ้ง:

[Fact]
public void OrderLine_ProductId_ReturnsConstructorArgument()
{
var productId = new ProductId(Guid.NewGuid());
var line = new OrderLine(new OrderLineId(Guid.NewGuid()), productId, new Quantity(1), Money.Thb(50));
Assert.Equal(productId, line.ProductId);
}

เหตุผล​ของ​บทเรียน​คือ getter ตัว​นี้ compiler สร้าง​ให้ ไม่มี branch ให้​ผิด

ผล​การ​วัด​บอกว่า test ตัว​นี้​ฆ่า​ตัว​กลาย​พันธุ์ ProductId = productId; ได้ และ​เป็น​ตัว​เดียว​ใน​ทั้ง 13 case ของ​ชุด​ที่​คอร์ส​ยกย่อง​ที่​ทำได้

บทเรียน​ไม่​ได้​ผิด แต่​มัน​พูด​ถึง​คนละ​อย่าง​กับ​ที่ test ตัว​นั้น​ทำ​จริง — getter ไม่มี​อะไร​ให้​ผิด​ก็​จริง ส่วน​บรรทัด​ที่ constructor ใช้​ประกอบ​ค่า​เข้า property มี​ให้​ผิด​ได้

รวม​ทั้งหมด ชุด​ที่​คอร์ส​สั่ง​ให้​ลบ​ทิ้ง​ฆ่า​ได้ 31 ตัว ซึ่ง​ไม่ใช่​ศูนย์ แต่​เกือบ​ทั้งหมด​ซ้ำ​กับ​ที่​ชุด​หลัก​ฆ่า​ได้​อยู่​แล้ว

ทางออก​ไม่ใช่​การ​เก็บ test getter ไว้ วิธี​ปิด​ช่องว่าง​นี้​ที่​ไม่​ขัด​คำ​แนะนำ​ของ​บทเรียน​คือ assert ผ่าน​สัญญา​สาธารณะ​ของ aggregate แทน:

[Fact]
public void Place_CarriesProductIdOntoTheOrderLines()
{
var productId = new ProductId(Guid.NewGuid());
var line = new OrderLine(new OrderLineId(Guid.NewGuid()), productId, new Quantity(1), Money.Thb(50m));
var order = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine> { line });
Assert.Equal(productId, order.Lines.Single().ProductId);
}

test ตัว​นี้​ฆ่า​ตัว​กลาย​พันธุ์​เดียวกัน​ได้ โดย​ไม่​แตะ getter ตรง ๆ และ​ไม่​ผูก​กับ​โครงสร้าง​ภายใน​ของ OrderLine เลย

survivor ทุก​ตัว​บน code ที่​คอร์ส​พิมพ์​ไว้ ปิด​ได้​จริง และ​ตัว​วัด​ยืนยัน​ด้วย​การ​รัน​ซ้ำ ไม่ใช่​ด้วย​การ​อ้าง

นี่​คือ​ขั้น​ที่​แยก “ช่องว่าง​จริง” ออก​จาก “ข้อ​จำกัด​ของ​วิธี​วัด” — เขียน test เพิ่ม​แล้ว​วัด​ใหม่ ถ้า survivor หาย​ไป แปล​ว่า​มัน​เป็น​ช่องว่าง​จริง

ผล​คือ 11 → 0

test ที่​เพิ่ม​ไม่มี​อะไร​ฉลาด​เลย​สัก​ตัว ส่วน​ใหญ่​คือ​การ​เติม​ข้อมูล​นำ​เข้า​อีก​ไม่​กี่​แถว​เข้าไป​ใน [Theory] ที่​มี​อยู่​แล้ว:

[Theory]
[InlineData(1.0, 0)] // ระยะเท่าขอบพอดี
[InlineData(1.333, 11.67)] // ค่าลงท้าย .665
[InlineData(1.3322, 11.66)] // ค่าลงท้าย .661
[InlineData(1.1111, 10.56)] // ค่าลงท้าย .556
public void Calculate_PinsBoundaryAndRounding(double distanceKm, decimal expectedFee)
{
var calculator = new DeliveryFeeCalculator();
var fee = calculator.Calculate(distanceKm);
Assert.Equal(expectedFee, fee.Amount);
}

สี่​แถว​นี้​ปิด survivor ของ DeliveryFeeCalculator ได้​ครบ​ทั้ง​หก ตัวเลข​ที่​คาด​หวัง​ทุก​ตัว​มา​จาก​การ​รัน code จริง​แล้ว​อ่าน​ผลออก​มา ไม่​ได้​คำนวณ​ด้วย​มือ

ข้อสังเกต​ที่​สำคัญ​กว่า​ตัว test คือ​ขนาด​ของ​งาน — ช่องว่าง​ที่​ใหญ่​ที่สุด​ใน​การ​วัด​นี้​ปิด​ด้วย​ข้อมูล​นำ​เข้า​สี่​แถว ไม่ใช่​ด้วย​การ​รื้อ​ชุด test

ตัว​กลาย​พันธุ์​บาง​ตัว​ไม่มี test ไหน​ใน​โลก​ฆ่า​ได้ เพราะ​มัน​ให้​ผลลัพธ์​เหมือน code เดิม​ทุก​อินพุต

ใน​โดเมน​นี้ ตัว​ที่​ชัด​ที่สุด​อยู่​ใน PromotionEngine ที่​ครอบ​เพดาน​ส่วนลด — เปลี่ยน​เงื่อนไข​จาก “มากกว่า​เพดาน” เป็น “ไม่​น้อย​กว่า​เพดาน”

ที่​ค่า​เท่า​เพดาน​พอดี ทั้ง​สอง​รูป​คืน​เพดาน​เหมือน​กัน ที่​ค่า​อื่น​ทั้ง​สอง​รูป​เหมือน​กัน​อยู่​แล้ว มัน​คือ code เดียวกัน​ที่​เขียน​คนละ​แบบ

ใน​วรรณกรรม​เรียกตัว​แบบ​นี้​ว่า equivalent mutant (ตัว​กลาย​พันธุ์​ที่​สมมูล​กับ​ต้นฉบับ) และ​มัน​คือ​เหตุผล​ที่​ตัวเลข survivor อ่าน​เอง​ไม่​ได้ ต้อง​มี​คน​อ่าน

ข้อ​ที่​หน้า​นี้​เคย​เข้าใจ​ผิด และ​แก้​ด้วย​การ​รัน

ตอน​อ่าน survivor ของ Money.Round() ครั้ง​แรก ข้อ​สรุป​ที่​ดู​สม​เหตุ​สม​ผล​คือ มัน​เป็น equivalent mutant เพราะ Money มี guard ห้าม​ค่า​ติดลบ​อยู่​แล้ว โหมด​ปัด​สอง​แบบ​นั้น​จึง​น่า​จะ​ให้​ผล​เท่า​กัน​เสมอ

ข้อ​สรุป​นั้น​ผิด MidpointRounding.ToPositiveInfinity ไม่​ได้​ทำงาน​เฉพาะ​ที่​จุด​กึ่งกลาง มัน​ปัด​ขึ้น​ทุก​กรณี ค่าที่​ไม่​ติดลบ​อย่าง 0.001 จึง​ให้ 0.00 กับ 0.01 คนละ​คำ​ตอบ

ที่​จับ​ได้​ไม่ใช่​เพราะ​อ่าน​ซ้ำ​แล้ว​คิด​ออก แต่​เพราะ​เขียน test ตาม​ข้อ​สรุป​นั้น​แล้ว มัน​ฆ่า​ตัว​กลาย​พันธุ์​ได้​จริง ซึ่ง​เป็น​ไป​ไม่​ได้​ถ้า​ข้อ​สรุป​ถูก

การ​อ้าง​ว่า survivor ตัว​ไหน​เป็น equivalent mutant ต้อง​พิสูจน์​ด้วย​การ​หา​อินพุต​ที่​แยก​มัน​ออก​ไม่​ได้ ไม่ใช่​ด้วย​การ​ให้​เหตุผล​ว่า​น่า​จะ​แยก​ไม่​ได้

ตัว​วัด​ตัว​ไหน​ไม่​แตะ​จุด​หนึ่ง จุด​นั้น​ก็​จะ​รายงาน​ว่า​ปลอดภัย​เสมอ ไม่​ว่า​ชุด test จะ​อ่อน​แค่​ไหน

ตัว​วัด​ของ​หน้า​นี้​แก้ code เจ็ด​แบบ และ​ผล​ของ​แต่ละ​แบบ​ต่าง​กัน​มาก

ชนิด​ของ​การ​แก้​ผิดคอมไพล์​ผ่านชุด​ของ​คอร์ส​ปล่อย​รอด
arithmetic50
enum-member8623
logical20
number-literal131
relational214
statement-removal188
string-literal1411

ตาราง 5 — ผล​แยก​ตาม​ชนิด​ของ​การ​แก้​ผิด · วัด​บน .NET 10.0.10 · Roslyn 4.14.0

การ​แก้ตัว​ดำเนิน​การ​เลขคณิต​ตาย​เกือบ​หมด (0 ตัว​รอด​จาก 5 ตัว) เพราะ​มัน​เปลี่ยน​ยอด​เงิน และ​ยอด​เงิน​คือ​สิ่ง​ที่​ทุก test ใน​ชุด​นี้ assert

ส่วน​การ​ล้าง​ข้อความ exception รอด 11 จาก 14 ตัว

นี่​คือ​เหตุผล​ที่​ชุด​ของ​การ​แก้​ผิด​สำคัญ​พอ ๆ กับ​ตัวเลข​ที่​ได้ เครื่องมือ​สำเร็จรูป​ของ .NET ที่​นิยม​ที่สุด​ไม่​แตะ​ค่า​คงที่​ตัวเลข​และ​สมาชิก​ของ enum เลย ทั้ง​สอง​อย่าง​ที่​ทำให้​เจอ survivor เรื่อง​การ​ปัดเศษ

การ​อ่าน​ว่า​เครื่อง​มือหนึ่ง​ให้​คะแนน​เท่าไร จึง​ต้อง​อ่าน​คู่​กับ​ว่า​มัน​แก้ code แบบ​ไหน​ได้​บ้าง และ​ตัด​อะไร​ทิ้ง​ก่อน​นับ

ของ​แถม​ที่​เจอ​ตั้งแต่​ยัง​ไม่​เริ่ม​วัด และ​เจอ​เพราะ​ต้อง​พิมพ์ code ใน​บทเรียน​ลง​ไป​ให้​คอมไพล์​ผ่าน

บท​ที่ 6 พิมพ์ Order.Place(...) ไว้​เต็ม​รูป และ​ใน​นั้น Place ปล่อย domain event OrderPlaced ทุก​ครั้ง

บท​ที่ 8 มี test ที่​ยก​ให้​เป็น​ตัวอย่าง​ที่​ดี เขียน​ว่า Assert.Single(order.DomainEvents) หลัง​เรียก Place แล้ว​ตาม​ด้วย Confirm

สอง​อย่าง​นี้​เป็น​จริง​พร้อม​กัน​ไม่​ได้ ถ้า Place ปล่อย event ด้วย หลัง Confirm จะ​มี​สอง event และ Assert.Single จะ​แดง

ตัว​วัด​ยืนยัน​ข้อ​นี้​ด้วย​การ​รัน ไม่ใช่​ด้วย​การ​อ่าน — ใส่​บรรทัด​ของ​บท​ที่ 6 กลับ​เข้าไป​แล้ว baseline แดง​ทันที​สอง​ตัว ก่อน​ที่​จะ​มี​การกลาย​พันธุ์​ใด ๆ เกิด​ขึ้น

ตัว​วัด​จึง​หยุด​ทำงาน​ทันที​ถ้า baseline ไม่​เขียว​ครบ เพราะ test ที่​แดง​อยู่​แล้ว​จะ​ถูก​นับ​ว่า “ฆ่า” ทุก​ตัว​กลาย​พันธุ์ แล้ว​รายงาน​อัตรา​การ​ฆ่า​เต็ม​ร้อย​ที่​ไม่มี​ความหมาย​อะไร​เลย

ขอบเขต​ของ​การ​วัด​แคบกว่าที่​หัวข้อ​อาจ​ทำให้​เข้าใจ และ​เส้น​พวก​นี้​ต้อง​พูด ไม่ใช่​ซ่อน

  • สัดส่วน​ใช้​เทียบ​ข้าม​โดเมน​ไม่​ได้ โดเมน​นี้​เล็ก​และ​เป็น domain บริสุทธิ์​ล้วน ตัวเลข​ที่​อ้าง​ได้​คือ​ตัวตน​ของ survivor ไม่ใช่​อัตราส่วน
  • ชุด test ที่​วัด​คือ​ชั้น​ล่าง​สุด​ของ​พีระมิด​เท่านั้น บท​ที่ 6 กับ บท​ที่ 7 ต้อง​ใช้​ฐาน​ข้อมูล​จริง​และ HTTP จริง จึง​อยู่​นอก​การ​วัด​นี้​ทั้ง​คู่
  • survivor บน​ป้าย prose และ reconstructed ไม่ใช่​ข้อ​ค้น​พบ​เรื่อง​คอร์ส รวม​กัน 36 ตัว และ​หน้า​นี้​ไม่​ได้​อ้าง​อะไร​จาก​มัน​เลย
  • ตัว​กลาย​พันธุ์​ที่​คอมไพล์​ไม่​ผ่าน 5 ตัว​ถูก​ตัด​ออก​จาก​ตัว​ส่วน ตาม​ธรรมเนียม​ของ​วิธี​นี้ แต่​ยัง​ถูก​รายงาน​ไว้​ครบ
  • การ​วัด​นี้​ไม่​รายงาน​เวลา​เลย​สัก​ที่ ตัว​กัน​ค้าง​ถูก​ตั้ง​ไว้​เป็น​ตาข่าย ไม่ใช่​เกณฑ์​ตัดสิน และ​ต้อง​ไม่​เคย​ทำงาน — ค่าที่​วัด​ได้​คือ 0

ตัวเลข​ทุก​ตัว​ใน​หน้า​นี้​มา​จาก​คำ​สั่ง​เดียว​ใน repo ของ​ไซต์​นี้ และ​รัน​ซ้ำ​แล้ว​ได้ file เดิมที​ละ byte

Terminal window
npm run gen:mutation-audit

ตัว​วัด​คอมไพล์​โดเมน​ใหม่​ที​ละ​ตัว​กลาย​พันธุ์​ใน​หน่วย​ความ​จำ​ด้วย Roslyn 4.14.0 แล้ว​เรียก test ผ่าน reflection โดย​ใช้ assertion จริง​จาก xunit.assert 2.9.3 ไม่มี dotnet test ไม่มี process ลูก

เพราะ​ไม่มี​อะไร​ใน​ผลลัพธ์​ที่​ขึ้น​กับ​ความเร็ว​เครื่อง รัน​กี่​รอบ​ก็ได้ file ข้อมูล​เดียวกัน​ที​ละ byte ยกเว้น​บรรทัด​ป้าย .NET 10.0.10 ที่​เป็น​ของ​เครื่อง​ที่​รัน

ถ้า​จะ​ทำ​กับ codebase ของ​คุณ​เอง

ลำดับ​ที่​ให้​ผล​ตอบแทน​สูงสุด​ตาม​ที่​วัด​ได้​ใน​หน้า​นี้ ไม่ใช่​การ​ไล่​ให้ survivor เป็น​ศูนย์:

  1. เริ่ม​จาก code ที่​คำนวณ​เงิน​หรือ​ตัดสิน​สิทธิ์ ไม่ใช่​ทั้ง repo — survivor ที่​มี​ค่าที่สุด​ใน​หน้า​นี้​อยู่​ใน​สอง​สมาชิก​ที่​คิด​เงิน
  2. อ่าน survivor ที​ละ​ตัว แล้ว​ถาม​ว่า​มัน​เป็น​ความ​ผิดพลาด​ที่​หลุด production ได้​ไหม ถ้า​ไม่ ปล่อย​ไว้​แล้ว​จด​เหตุผล
  3. ปิด​ด้วย​ข้อมูล​นำ​เข้า​เพิ่ม​ก่อน​เสมอ แถว​ใหม่​ใน [Theory] ถูก​กว่า​การ​เขียน test ใหม่​ทั้ง​ตัว​มาก
  4. อย่า​ตั้ง​เป้า​เป็น​เปอร์เซ็นต์ ไม่​งั้น​จะ​ได้ Goodhart’s Law รอบ​สอง ต่อ​จากรอบ​ที่ coverage เคย​สร้าง​ไว้​แล้ว

อ่าน​ต่อ​บน​ไซต์​นี้