TDD กับ AI: เขียนเทสต์ก่อน แล้วให้ AI ทำให้ผ่าน

ปัญหาใหญ่ของการให้ AI เขียนโค้ดคือ เราจะรู้ได้อย่างไรว่าโค้ดถูก AI บอกว่า "เสร็จแล้ว" ได้เสมอ แต่คำว่าเสร็จของมันอาจไม่ตรงกับของเรา Test-Driven Development (TDD) แก้ปัญหานี้ได้ตรงจุด เพราะเราเขียน "นิยามของคำว่าถูก" เป็นเทสต์ไว้ก่อน แล้วให้ AI มีหน้าที่เดียวคือทำให้เทสต์ผ่าน บทความนี้จะอธิบายว่าทำไมการเขียนเทสต์ก่อนช่วยคุม AI ได้ดี วงจร red-green-refactor เมื่อทำงานร่วมกับ AI ตัวอย่างฟังก์ชันคำนวณราคาและภาษีมูลค่าเพิ่มด้วย PHP และวิธีป้องกันไม่ให้ AI แก้เทสต์เพื่อให้ผ่าน
ทำไมเทสต์ก่อนถึงช่วยคุม AI
เมื่อเราสั่ง AI ด้วยภาษาธรรมชาติ ความต้องการของเราถูกตีความได้หลายแบบ เช่น "คำนวณ VAT" อาจหมายถึง VAT แบบรวมในราคาหรือแยกนอกราคา ปัดเศษต่อบรรทัดหรือต่อใบ แต่เทสต์ตีความได้แบบเดียว ตัวเลขคือตัวเลข
- เทสต์คือ spec ที่รันได้ ไม่มีความกำกวมเรื่องรูปแบบการปัดเศษหรือลำดับการคำนวณ
- AI ได้สัญญาณชัดเจน โดยเฉพาะ agent ที่รันเทสต์เองได้ มันรู้ทันทีว่ายังผิดตรงไหน แล้ววนแก้จนผ่าน
- เราตรวจงานง่ายขึ้น แทนที่จะไล่อ่านตรรกะทุกบรรทัดว่าถูกไหม เราตรวจว่าเทสต์ครอบคลุมพอหรือไม่ ซึ่งง่ายกว่ามาก
- คุมขอบเขต AI ไม่มีเหตุผลต้องเขียนอะไรเกินที่เทสต์ต้องการ
- ปลอดภัยเวลา refactor เมื่อขอให้ AI ปรับโค้ดให้สวยขึ้น เทสต์จะบอกทันทีถ้าพฤติกรรมเปลี่ยน
ส่วนที่คนต้องทำเองคือการตัดสินใจว่า "ถูก" หมายถึงอะไร ซึ่งเป็นงานที่ไม่ควรยกให้ AI อยู่แล้ว
วงจร Red-Green-Refactor เมื่อทำงานกับ AI
| ขั้น | ใครทำ | ผลที่ต้องได้ |
|---|---|---|
| Red: เขียนเทสต์ที่ยังไม่ผ่าน | เราเขียน หรือให้ AI ร่างแล้วเราตรวจทุกตัวเลข | เทสต์แดงด้วยเหตุผลที่ถูก (เช่น class ยังไม่มี หรือค่าไม่ตรง) แล้ว commit |
| Green: เขียนโค้ดให้ผ่าน | AI | เทสต์ทั้งหมดเขียว โดยไฟล์เทสต์ไม่ถูกแก้ |
| Refactor: ปรับโค้ดให้ดีขึ้น | AI เสนอ เราตรวจ | เทสต์ยังเขียว พฤติกรรมไม่เปลี่ยน แล้ว commit |
จุดสำคัญคือ commit เทสต์ไว้ก่อนเริ่มขั้น Green เพื่อให้เราตรวจได้ด้วย Git ว่าไฟล์เทสต์ถูกแตะหรือไม่
ตัวอย่าง: คำนวณราคาสุทธิและ VAT
กติกาธุรกิจ
- ใบแจ้งหนี้มีหลายรายการ แต่ละรายการมีจำนวน (qty) และราคาต่อหน่วยเป็นสตางค์
- ส่วนลดเป็นเปอร์เซ็นต์ของยอดรวม 0–100 ปัดเศษแบบ half-up เป็นสตางค์
- VAT 7% คิดจากยอดหลังหักส่วนลด (ราคายังไม่รวม VAT) ปัดเศษแบบ half-up ระดับใบ
- qty ต้องมากกว่า 0 ราคาต่อหน่วยต้องไม่ติดลบ
Red: เขียนเทสต์ก่อน
<?php
declare(strict_types=1);
namespace common\tests\unit\services;
use Codeception\Test\Unit;
use common\services\InvoiceCalculator;
use InvalidArgumentException;
final class InvoiceCalculatorTest extends Unit
{
public function testTypicalInvoiceWithDiscount(): void
{
$result = (new InvoiceCalculator())->calculate([
['qty' => 2, 'unitPrice' => 10000],
['qty' => 1, 'unitPrice' => 5050],
], 10);
$this->assertSame([
'subtotal' => 25050,
'discount' => 2505, // 2,505.0
'net' => 22545,
'vat' => 1578, // 1,578.15 ปัดลง
'total' => 24123,
], $result);
}
public function testRoundingHalfUpOnDiscountAndVat(): void
{
$result = (new InvoiceCalculator())->calculate([
['qty' => 1, 'unitPrice' => 999],
], 10);
$this->assertSame(100, $result['discount']); // 99.9 → 100
$this->assertSame(63, $result['vat']); // 62.93 → 63
$this->assertSame(962, $result['total']);
}
public function testEmptyInvoiceIsAllZero(): void
{
$result = (new InvoiceCalculator())->calculate([], 0);
$this->assertSame(0, $result['total']);
}
public function testZeroQuantityIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
(new InvoiceCalculator())->calculate([['qty' => 0, 'unitPrice' => 100]], 0);
}
public function testDiscountOverHundredIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
(new InvoiceCalculator())->calculate([['qty' => 1, 'unitPrice' => 100]], 101);
}
}
ทุกตัวเลขใน expected คิดด้วยมือจากกติกา เช่น 22,545 × 7% = 1,578.15 ปัดเป็น 1,578 การคิดเองตรงนี้คือหัวใจของ TDD ถ้าให้ AI คิดตัวเลข expected เราต้องตรวจทุกตัวด้วยตัวเอง
รันเทสต์ให้เห็นว่าแดง แล้ว commit
vendor/bin/codecept run unit services/InvoiceCalculatorTest
git add common/tests/unit/services/InvoiceCalculatorTest.php
git commit -m "test: add InvoiceCalculator spec (red)"
Green: ให้ AI ทำให้ผ่าน
สร้าง
common\services\InvoiceCalculatorให้ผ่านเทสต์ในcommon/tests/unit/services/InvoiceCalculatorTest.php
- ห้ามแก้ไฟล์เทสต์ทุกกรณี ถ้าคิดว่าเทสต์ผิด ให้หยุดและอธิบายเหตุผล
- ใช้เลขจำนวนเต็มทั้งหมด ห้ามใช้ float
- PHP 8,
declare(strict_types=1), ไม่เพิ่ม dependency- รันเทสต์ด้วย
vendor/bin/codecept run unit services/InvoiceCalculatorTestจนผ่าน
ผลลัพธ์ที่ผ่านอาจเป็นแบบนี้
<?php
declare(strict_types=1);
namespace common\services;
use InvalidArgumentException;
final class InvoiceCalculator
{
private const VAT_PERCENT = 7;
/**
* @param array<int, array{qty:int, unitPrice:int}> $lines ราคาเป็นสตางค์
* @return array{subtotal:int, discount:int, net:int, vat:int, total:int}
*/
public function calculate(array $lines, int $discountPercent): array
{
if ($discountPercent < 0 || $discountPercent > 100) {
throw new InvalidArgumentException('Discount must be between 0 and 100');
}
$subtotal = 0;
foreach ($lines as $line) {
if ($line['qty'] <= 0 || $line['unitPrice'] < 0) {
throw new InvalidArgumentException('Invalid line item');
}
$subtotal += $line['qty'] * $line['unitPrice'];
}
$discount = $this->percentHalfUp($subtotal, $discountPercent);
$net = $subtotal - $discount;
$vat = $this->percentHalfUp($net, self::VAT_PERCENT);
return [
'subtotal' => $subtotal,
'discount' => $discount,
'net' => $net,
'vat' => $vat,
'total' => $net + $vat,
];
}
/** คำนวณ amount * percent / 100 ปัดแบบ half-up สำหรับค่าที่ไม่ติดลบ */
private function percentHalfUp(int $amount, int $percent): int
{
return intdiv($amount * $percent * 2 + 100, 200);
}
}
Refactor
เมื่อเขียวแล้ว ขอให้ AI เสนอการปรับปรุง เช่น แยก value object สำหรับ line item หรือเปลี่ยนอัตรา VAT ให้รับจาก config โดยมีเงื่อนไขเดียวคือเทสต์ต้องเขียวทุกขั้น และห้ามแก้เทสต์ ถ้าการ refactor ต้องการเปลี่ยน interface ให้กลับไปเริ่มที่ Red ใหม่ คือเราแก้เทสต์เองก่อน
ห้าม AI แก้เทสต์ให้ผ่าน
นี่คือกับดักที่พบบ่อยที่สุด เมื่อ AI ทำให้เทสต์ผ่านไม่ได้ มันอาจเลือกทางลัด
- เปลี่ยนค่า expected ในเทสต์ให้ตรงกับผลที่โค้ดคำนวณได้
- ลบเทสต์ที่ fail หรือใส่
markTestSkipped() - เขียนโค้ดแบบ hardcode ให้ตรงกับ input ในเทสต์ เช่น
if ($subtotal === 25050) return ... - จับ exception แล้วกลืนไว้เพื่อให้ไม่ error
วิธีป้องกันที่ใช้ได้จริง
- Commit เทสต์ก่อนเสมอ แล้วตรวจหลัง AI ทำงานเสร็จว่าไฟล์เทสต์ไม่เปลี่ยน
# คืนค่า exit code ไม่เป็น 0 ถ้ามีการแก้ไฟล์ใต้โฟลเดอร์เทสต์ git diff --exit-code HEAD -- common/tests/ && echo "tests untouched" - เขียนข้อห้ามใน prompt และไฟล์แนวทางโปรเจกต์ ว่าห้ามแก้ไฟล์ใต้
tests/ในขั้น Green - ตั้งสิทธิ์เครื่องมือ ถ้า agent รองรับ ให้ห้ามแก้ path ของเทสต์ระหว่างขั้นนี้
- เพิ่มเทสต์ที่ input หลากหลาย เพื่อให้การ hardcode ไม่คุ้ม
- อ่านโค้ด Green ทุกครั้ง มองหา
ifที่เช็กค่าเฉพาะเจาะจงแปลก ๆ
ถ้า AI แย้งว่าเทสต์ผิด ให้รับฟัง บางครั้งมันพูดถูก เช่น เราคิดเลขผิดเอง แต่การตัดสินใจแก้เทสต์ต้องเป็นของคน และควรเป็น commit แยกที่ระบุเหตุผลชัดเจน
TDD กับ AI เหมาะกับงานแบบไหน
- เหมาะมาก ตรรกะธุรกิจที่มี input/output ชัด เช่น คำนวณราคา ภาษี ค่าส่ง ตรวจรูปแบบข้อมูล แปลงข้อมูล
- เหมาะ แก้บั๊ก โดยเขียนเทสต์จำลองบั๊กก่อน แล้วให้ AI แก้
- ต้องปรับ งาน UI หรืองานที่ผูกกับระบบภายนอกมาก อาจใช้ functional หรือ acceptance test แทน unit test
- ไม่คุ้ม สคริปต์ใช้ครั้งเดียวที่ทิ้งได้
สรุป
- เทสต์คือ spec ที่รันได้ ไม่กำกวม และเป็นสัญญาณชัดเจนให้ AI รู้ว่าเสร็จหรือยัง
- คนเป็นเจ้าของขั้น Red (ตัดสินว่าอะไรถูก) AI รับผิดชอบขั้น Green และช่วยเสนอ Refactor
- Commit เทสต์ก่อน แล้วใช้
git diffตรวจว่าไฟล์เทสต์ไม่ถูกแก้ - ระวังทางลัด: แก้ expected, skip เทสต์, hardcode ค่า, กลืน exception
- งานคำนวณเงินควรใช้จำนวนเต็มหน่วยย่อยและกำหนดวิธีปัดเศษในเทสต์ให้ชัด
ลองทำ
ฝึก TDD กับ AI ด้วยฟังก์ชันคำนวณค่าปรับคืนหนังสือล่าช้า
- กำหนดกติกาเอง เช่น วันละ 5 บาท สูงสุดไม่เกิน 100 บาท สมาชิกพรีเมียมได้ยกเว้น 3 วันแรก
- เขียนเทสต์อย่างน้อย 6 เคส รวมค่าขอบ (0 วัน, 3 วัน, 4 วัน, ถึงเพดานพอดี) รันให้แดง แล้ว commit
- ให้ AI เขียน implementation พร้อมข้อห้ามแก้เทสต์
- ตรวจด้วย
git diff --exit-code HEAD -- tests/แล้วอ่านโค้ดหา hardcode - ขอให้ AI refactor หนึ่งรอบ และยืนยันว่าเทสต์ยังเขียว
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ