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 ที่หน้านี้เสนอ |
|---|---|---|---|
verbatim | 53 | 11 | 0 |
prose | 77 | 16 | 16 |
reconstructed | 29 | 20 | 20 |
ตาราง 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 ที่คอร์สยกมาเพื่อบอกว่าอย่าเขียนแบบนี้
survivor ทั้ง 11 ตัวบน code ที่คอร์สพิมพ์ไว้
หัวข้อที่มีชื่อว่า “survivor ทั้ง ตัวบน code ที่คอร์สพิมพ์ไว้”ทั้งหมดกระจุกอยู่สี่เรื่อง และไม่มีเรื่องไหนเป็นอุบัติเหตุ ทุกเรื่องคือ 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 ที่ปิดมันได้ |
|---|---|---|
AwayFromZero | ToEven | ระยะ 1 แบบ |
AwayFromZero | ToNegativeInfinity | ระยะ 2 แบบ |
AwayFromZero | ToPositiveInfinity | ระยะ 1 แบบ |
AwayFromZero | ToZero | ระยะ 2 แบบ |
จำนวนตำแหน่ง 2 | 3 | ระยะ 3 แบบ |
ตาราง 2 — การปัดเศษของ DeliveryFeeCalculator ทั้งห้าแบบรอดจากชุดของคอร์ส · วัดบน .NET 10.0.10
สาเหตุอยู่ที่ข้อมูลนำเข้า ไม่ใช่ที่ assert — ระยะสองค่าที่คอร์สเลือกให้ค่าส่งที่ลงตัวพอดีทั้งคู่ จึงไม่มีเศษให้ปัดตั้งแต่แรก
assert ที่ถูกต้องบนข้อมูลที่ไม่แตะเงื่อนไข พิสูจน์เงื่อนไขนั้นไม่ได้ และ coverage รายงานว่าบรรทัดนี้ถูกรันแล้วทั้งสองครั้ง
Money.Round() ตกหลุมเดียวกันแต่ตื้นกว่า — ค่าเดียวที่คอร์สป้อนคือจุดกึ่งกลางพอดี โหมดปัดสามในสี่แบบจึงตายเพราะมันตอบต่างกันที่จุดนั้น เหลือ ToPositiveInfinity ที่ตอบเท่ากันพอดีแล้วรอดไป
ข้อความ exception ที่ assert ครึ่งเดียว
หัวข้อที่มีชื่อว่า “ข้อความ exception ที่ assert ครึ่งเดียว”คอร์สเขียนข้อความ exception ไว้เป็นภาษาไทยเต็มประโยคทุกก้อน และ บทที่ 2 ก็ assert ข้อความนั้นเต็ม ๆ ในกรณีสถานะผิดกฎ
แต่อีกสองที่ assert แค่ ชนิด ของ exception ล้างข้อความทิ้งทั้งก้อนแล้ว test ยังเขียว
| สมาชิก | ข้อความที่ถูกล้างทิ้ง |
|---|---|
Order.Place | ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้ |
PriceBreakdown..ctor | PriceBreakdown ไม่ถูกต้อง — Total ต้องเท่ากับ Subtotal หักด้วย Discount บวก DeliveryFee บวก VAT เสมอ |
ตาราง 3 — ข้อความที่ไม่มี test ตัวไหนเฝ้าอยู่ · วัดบน .NET 10.0.10
ข้อนี้ไม่ใช่ bug เสมอไป การเลือกไม่ assert ข้อความเป็นการตัดสินใจที่มีเหตุผล เพราะข้อความเป็นสิ่งที่เปลี่ยนบ่อยและ test ที่ผูกกับมันจะเปราะ
แต่คอร์สเลือกไปแล้วว่า assert — ที่วัดได้คือมันเลือกไม่เหมือนกันสามที่ โดยไม่มีอะไรบอกว่าตั้งใจ
constructor ที่ไม่มีใครยืนยันว่าประกอบครบ
หัวข้อที่มีชื่อว่า “constructor ที่ไม่มีใครยืนยันว่าประกอบครบ”สองตัวสุดท้ายเป็นการลบทั้งบรรทัดออกจาก constructor:
| สมาชิก | บรรทัดที่ถูกลบ |
|---|---|
Order..ctor | Id = id; |
OrderLine..ctor | ProductId = 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 เลย
ปิดได้ทั้งหมดด้วย test 9 case
หัวข้อที่มีชื่อว่า “ปิดได้ทั้งหมดด้วย test case”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)] // ค่าลงท้าย .556public 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 เจ็ดแบบ และผลของแต่ละแบบต่างกันมาก
| ชนิดของการแก้ผิด | คอมไพล์ผ่าน | ชุดของคอร์สปล่อยรอด |
|---|---|---|
arithmetic | 5 | 0 |
enum-member | 86 | 23 |
logical | 2 | 0 |
number-literal | 13 | 1 |
relational | 21 | 4 |
statement-removal | 18 | 8 |
string-literal | 14 | 11 |
ตาราง 5 — ผลแยกตามชนิดของการแก้ผิด · วัดบน .NET 10.0.10 · Roslyn 4.14.0
การแก้ตัวดำเนินการเลขคณิตตายเกือบหมด (0 ตัวรอดจาก 5 ตัว) เพราะมันเปลี่ยนยอดเงิน และยอดเงินคือสิ่งที่ทุก test ในชุดนี้ assert
ส่วนการล้างข้อความ exception รอด 11 จาก 14 ตัว
นี่คือเหตุผลที่ชุดของการแก้ผิดสำคัญพอ ๆ กับตัวเลขที่ได้ เครื่องมือสำเร็จรูปของ .NET ที่นิยมที่สุดไม่แตะค่าคงที่ตัวเลขและสมาชิกของ enum เลย ทั้งสองอย่างที่ทำให้เจอ survivor เรื่องการปัดเศษ
การอ่านว่าเครื่องมือหนึ่งให้คะแนนเท่าไร จึงต้องอ่านคู่กับว่ามันแก้ code แบบไหนได้บ้าง และตัดอะไรทิ้งก่อนนับ
code ในคอร์สนี้ไม่เคยถูกคอมไพล์
หัวข้อที่มีชื่อว่า “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
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 ที่เป็นของเครื่องที่รัน
ลำดับที่ให้ผลตอบแทนสูงสุดตามที่วัดได้ในหน้านี้ ไม่ใช่การไล่ให้ survivor เป็นศูนย์:
- เริ่มจาก code ที่คำนวณเงินหรือตัดสินสิทธิ์ ไม่ใช่ทั้ง repo — survivor ที่มีค่าที่สุดในหน้านี้อยู่ในสองสมาชิกที่คิดเงิน
- อ่าน survivor ทีละตัว แล้วถามว่ามันเป็นความผิดพลาดที่หลุด production ได้ไหม ถ้าไม่ ปล่อยไว้แล้วจดเหตุผล
- ปิดด้วยข้อมูลนำเข้าเพิ่มก่อนเสมอ แถวใหม่ใน
[Theory]ถูกกว่าการเขียน test ใหม่ทั้งตัวมาก - อย่าตั้งเป้าเป็นเปอร์เซ็นต์ ไม่งั้นจะได้ Goodhart’s Law รอบสอง ต่อจากรอบที่ coverage เคยสร้างไว้แล้ว
อ่านต่อบนไซต์นี้
- บทที่ 8 — อะไรที่ “ไม่ควร” test เจ้าของข้อโต้แย้งที่หน้านี้เอาไปวัด
- บทที่ 3 — สี่เสาหลักของ test ที่ดี เกณฑ์ที่ survivor ทุกตัวในหน้านี้แตะเสาแรก คือการกันรีเกรสชัน
- Goodhart’s Law เหตุผลที่ข้อ 4 ของกล่องข้างบนสำคัญกว่าตัวเลขทั้งหน้า