ข้ามไปยังเนื้อหาหลัก
ProgrammerThailand

AI & Vibe Coding

Spec-Driven Development: เขียน Spec ก่อนให้ AI ลงมือ

Spec-Driven Development: เขียน Spec ก่อนให้ AI ลงมือ

ถ้าคุณเคยสั่ง AI ว่า "ทำระบบค้นหาบทความให้หน่อย" แล้วได้โค้ดที่ค้นได้แค่ชื่อเรื่อง ไม่กรองบทความที่ยังไม่เผยแพร่ ไม่มีแบ่งหน้า และพังเมื่อผู้ใช้พิมพ์เครื่องหมาย % ลงไป ปัญหาไม่ได้อยู่ที่ AI อย่างเดียว แต่อยู่ที่ไม่มีใครบอกมันว่า "เสร็จ" หน้าตาเป็นอย่างไร Spec-Driven Development คือแนวทางที่เราเขียน spec สั้น ๆ ก่อนลงมือ ให้ทั้งคนและ AI ใช้เป็นจุดอ้างอิงเดียวกัน แล้วแปลง spec นั้นเป็นเทสต์ที่ตรวจได้อัตโนมัติ บทความนี้จะพาเขียน spec ของฟีเจอร์ค้นหาบทความใน Yii2 ตั้งแต่ต้นจนเป็นเทสต์

ทำไมต้องเขียน spec ก่อนให้ AI ลงมือ

AI เขียนโค้ดเร็วมาก แต่จะเขียนตามสิ่งที่ "คิดว่าเราต้องการ" ส่วนที่เราไม่ได้พูดถึง มันจะเติมเองจากรูปแบบทั่วไปที่เคยเห็น ซึ่งอาจไม่ตรงกับระบบของเรา การเขียน spec ก่อนช่วยได้หลายทาง

  • บังคับให้เราคิดให้ชัด หลายครั้งเราเพิ่งรู้ตอนเขียน spec ว่ายังไม่ได้ตัดสินใจบางเรื่อง เช่น ค้นเนื้อหาด้วยไหม
  • ลดรอบแก้ AI ได้ข้อมูลครบตั้งแต่รอบแรก แทนที่จะค่อย ๆ เติมทีละข้อความ
  • มีเกณฑ์ตรวจรับงาน รู้ว่าเมื่อไร "เสร็จ" ไม่ใช่แค่ "ดูเหมือนใช้ได้"
  • ส่งต่อได้ คนในทีม คนรีวิว หรือ session AI ใหม่ อ่าน spec แล้วเข้าใจงานตรงกัน

สิ่งสำคัญคือ spec ต้อง สั้น เราไม่ได้กลับไปเขียนเอกสารความต้องการหนาหลายสิบหน้า spec สำหรับฟีเจอร์ขนาดปกติควรอยู่ในหนึ่งหน้าจอ อ่านจบในสองนาที

องค์ประกอบของ spec สั้นที่ดี

spec ที่ใช้ได้จริงกับ AI มีแค่สามส่วนหลัก บวกส่วนเสริมตามความจำเป็น

ส่วนตอบคำถามข้อควรระวัง
เป้าหมายทำเพื่อใคร แก้ปัญหาอะไรหนึ่งถึงสองประโยค ไม่ใช่รายการฟีเจอร์
ขอบเขตทำอะไร และ ไม่ทำ อะไรส่วน "ไม่ทำ" สำคัญพอกัน กัน AI ทำเกิน
เกณฑ์รับงาน (acceptance criteria)ตรวจอย่างไรว่าเสร็จแต่ละข้อต้องตรวจได้ เขียนเป็นตัวอย่าง input และผลลัพธ์
ข้อจำกัดทางเทคนิค (เสริม)ต้องใช้อะไร ห้ามใช้อะไรframework, ฐานข้อมูล, ห้ามเพิ่ม dependency

เกณฑ์รับงานที่ดีคือข้อที่เปลี่ยนเป็นเทสต์ได้ทันที คำว่า "ค้นหาได้เร็ว" หรือ "ใช้ง่าย" ตรวจไม่ได้ แต่ "พิมพ์ yii2 แล้วเจอบทความชื่อ เริ่มต้นใช้ Yii2" ตรวจได้ ลองเทียบตัวอย่างการเขียนเกณฑ์แบบกำกวมกับแบบที่ตรวจได้

กำกวมตรวจได้
ค้นหาต้องฉลาดคำค้นตรงกับบางส่วนของชื่อเรื่องหรือเนื้อหา โดยไม่สนตัวพิมพ์เล็กใหญ่
ต้องปลอดภัยคำค้นที่มีแท็ก HTML ถูกแสดงเป็นข้อความ ไม่ถูกตีความเป็นโค้ด
รองรับข้อมูลเยอะแบ่งหน้าละ 20 รายการ และ query ไม่ดึงทุกแถวมาไว้ในหน่วยความจำ
จัดการ input แปลก ๆ ได้คำค้นว่างหรือสั้นกว่า 2 ตัวอักษร ไม่ค้นและแสดงข้อความแจ้ง

เคล็ดลับคือ ถ้าเขียน AC แล้วนึกไม่ออกว่าจะเขียนเทสต์ตรวจอย่างไร แปลว่า AC ข้อนั้นยังไม่ชัดพอ ให้ถามตัวเองว่า "ผู้ใช้ทำอะไร แล้วควรเห็นอะไร" จนได้คำตอบที่เป็นรูปธรรม

ตัวอย่าง spec: ฟีเจอร์ค้นหาบทความ

# Spec: ค้นหาบทความ (v1)

## เป้าหมาย
ผู้อ่านหาบทความที่เคยเผยแพร่ได้ด้วยคำค้น โดยไม่ต้องไล่ดูหน้ารวม

## ขอบเขต
ทำ:
- ช่องค้นหาหนึ่งช่อง (พารามิเตอร์ q) ค้นจาก title และ content
- แสดงผลแบบแบ่งหน้า
ไม่ทำ (รอบนี้):
- ค้นตามแท็ก/หมวด, autocomplete, ไฮไลต์คำ, ตัดคำภาษาไทยขั้นสูง
- ไม่ติดตั้ง search engine หรือ dependency ใหม่

## เกณฑ์รับงาน
AC1 คำค้นตรงกับบางส่วนของ title หรือ content โดยไม่สนตัวพิมพ์เล็ก/ใหญ่
    เช่น q=yii2 เจอบทความ "เริ่มต้นใช้ Yii2"
AC2 แสดงเฉพาะบทความสถานะ published เท่านั้น
AC3 ตัดช่องว่างหัวท้าย คำค้นที่เหลือสั้นกว่า 2 ตัวอักษร ไม่ค้น และแจ้ง error
AC4 คำค้นยาวเกิน 100 ตัวอักษร ไม่ค้น และแจ้ง error
AC5 เรียงจาก published_at ใหม่ไปเก่า
AC6 แบ่งหน้าละ 20 รายการ
AC7 อักขระ % และ _ ในคำค้นถือเป็นตัวอักษรธรรมดา
    เช่น q=100% ไม่เจอ "ส่วนลด 100 บาท"
AC8 ไม่พบผล แสดงข้อความ "ไม่พบบทความที่ตรงกับคำค้น" และแสดงคำค้นแบบ encode แล้ว

## ข้อจำกัดทางเทคนิค
- Yii2 advanced, PostgreSQL, ใช้ ActiveRecord + ActiveDataProvider
- ห้ามต่อ string เป็น SQL เอง ใช้ query builder เท่านั้น

สังเกตว่า AC7 และ AC8 คือเรื่องที่ AI มักลืมถ้าเราไม่บอก AC7 กันผลค้นหาเพี้ยนเมื่อผู้ใช้พิมพ์ wildcard ส่วน AC8 กัน XSS ผ่านคำค้นที่สะท้อนกลับไปบนหน้าเว็บ

แปลง spec เป็นเทสต์

ก่อนให้ AI เขียนโค้ดฟีเจอร์ ให้แปลงเกณฑ์รับงานเป็นเทสต์ก่อน จะเขียนเองหรือให้ AI ร่างแล้วเรารีวิวก็ได้ แต่ต้องแน่ใจว่าเทสต์แต่ละข้อตรงกับ AC จริง ไม่ใช่เทสต์ที่ผ่านง่ายเกินไป ตัวอย่างนี้ใช้ Codeception unit test ซึ่งมากับ Yii2 advanced template โดยตั้งชื่อเทสต์ให้อ้างถึง AC ทุกข้อ

<?php
namespace common\tests\unit\models;

use Codeception\Test\Unit;
use common\models\Post;
use common\models\PostSearch;
use yii\data\ActiveDataProvider;

final class PostSearchTest extends Unit
{
    public function testAc1MatchesTitleCaseInsensitive(): void
    {
        $this->createPost('เริ่มต้นใช้ Yii2', 'เนื้อหาทั่วไป');
        $this->createPost('PostgreSQL เบื้องต้น', 'ไม่เกี่ยวข้อง');

        $this->assertSame(['เริ่มต้นใช้ Yii2'], $this->titles($this->search('yii2')));
    }

    public function testAc1MatchesContent(): void
    {
        $this->createPost('บทความ A', 'อธิบายการใช้ ActiveRecord');

        $this->assertSame(['บทความ A'], $this->titles($this->search('activerecord')));
    }

    public function testAc2ExcludesUnpublished(): void
    {
        $this->createPost('ร่าง Yii2', 'draft', Post::STATUS_DRAFT);

        $this->assertSame([], $this->titles($this->search('yii2')));
    }

    public function testAc3RejectsTooShortKeywordAfterTrim(): void
    {
        $this->createPost('Yii2', 'x');
        $model = new PostSearch();
        $provider = $model->search(['q' => '  y  ']);

        $this->assertTrue($model->hasErrors('q'));
        $this->assertSame(0, $provider->getTotalCount());
    }

    public function testAc4RejectsTooLongKeyword(): void
    {
        $model = new PostSearch();
        $model->search(['q' => str_repeat('ก', 101)]);

        $this->assertTrue($model->hasErrors('q'));
    }

    public function testAc5SortsNewestFirst(): void
    {
        $this->createPost('Yii2 เก่า', 'x', Post::STATUS_PUBLISHED, '2026-01-01 10:00:00');
        $this->createPost('Yii2 ใหม่', 'x', Post::STATUS_PUBLISHED, '2026-02-01 10:00:00');

        $this->assertSame(['Yii2 ใหม่', 'Yii2 เก่า'], $this->titles($this->search('yii2')));
    }

    public function testAc6PageSizeIsTwenty(): void
    {
        $this->assertSame(20, $this->search('yii2')->getPagination()->pageSize);
    }

    public function testAc7WildcardIsLiteral(): void
    {
        $this->createPost('ลด 100% ทั้งร้าน', 'x');
        $this->createPost('ส่วนลด 100 บาท', 'x');

        $this->assertSame(['ลด 100% ทั้งร้าน'], $this->titles($this->search('100%')));
    }

    private function search(string $q): ActiveDataProvider
    {
        return (new PostSearch())->search(['q' => $q]);
    }

    /** @return string[] */
    private function titles(ActiveDataProvider $provider): array
    {
        return array_map(fn (Post $p) => $p->title, $provider->getModels());
    }

    private function createPost(
        string $title,
        string $content,
        int $status = Post::STATUS_PUBLISHED,
        string $publishedAt = '2026-01-15 10:00:00'
    ): void {
        $post = new Post([
            'title' => $title,
            'content' => $content,
            'status' => $status,
            'published_at' => $publishedAt,
        ]);
        $this->assertTrue($post->save(false));
    }
}

AC8 เป็นเรื่องการแสดงผลบนหน้าเว็บ จึงเหมาะกับ functional test หรือรายการตรวจด้วยมือ สิ่งสำคัญคือทุก AC ต้องมีที่ตรวจ ไม่ว่าจะอัตโนมัติหรือด้วยมือ และเทสต์ทั้งหมดควร fail ก่อนเริ่มเขียนโค้ด ถ้ามีข้อไหนผ่านตั้งแต่ยังไม่เขียน แสดงว่าเทสต์นั้นไม่ได้ตรวจอะไรจริง

ให้ AI ลงมือจาก spec และเทสต์

เมื่อมีทั้ง spec และเทสต์ prompt ที่ส่งให้ AI จะสั้นและชัดมาก

แนบ: spec-search.md, PostSearchTest.php, models/Post.php
งาน: เขียน common/models/PostSearch.php ให้เทสต์ทั้งหมดผ่าน
ข้อห้าม: ห้ามแก้ไฟล์เทสต์ ห้ามเพิ่ม dependency ทำเฉพาะสิ่งที่อยู่ในขอบเขต
ถ้า spec ไม่ชัดตรงไหน ให้ถามก่อน

ผลลัพธ์ที่ควรได้หน้าตาประมาณนี้ ซึ่งเราตรวจกับ spec ได้ทีละข้อ

<?php
namespace common\models;

use yii\base\Model;
use yii\data\ActiveDataProvider;

final class PostSearch extends Model
{
    /** @var string|null */
    public $q;

    public function formName(): string
    {
        return ''; // รับ ?q=... ตรง ๆ
    }

    public function rules(): array
    {
        return [
            ['q', 'filter', 'filter' => fn ($v) => is_string($v) ? trim($v) : ''], // AC3
            ['q', 'required', 'message' => 'กรุณาพิมพ์คำค้นหา'],
            ['q', 'string', 'min' => 2, 'max' => 100],                              // AC3, AC4
        ];
    }

    public function search(array $params): ActiveDataProvider
    {
        $query = Post::find()->where(['status' => Post::STATUS_PUBLISHED]); // AC2

        $provider = new ActiveDataProvider([
            'query' => $query,
            'pagination' => ['pageSize' => 20],                                  // AC6
            'sort' => ['defaultOrder' => ['published_at' => SORT_DESC, 'id' => SORT_DESC]], // AC5
        ]);

        $this->load($params);
        if (!$this->validate()) {
            $query->andWhere('0=1');
            return $provider;
        }

        // ilike ของ query builder escape % และ _ ให้โดยค่าเริ่มต้น (AC1, AC7)
        $query->andWhere(['or',
            ['ilike', 'title', $this->q],
            ['ilike', 'content', $this->q],
        ]);

        return $provider;
    }
}

จุดที่ต้องรีวิวเองแม้เทสต์ผ่าน: ประสิทธิภาพ เพราะ ILIKE แบบมี wildcard ด้านหน้าใช้ B-tree index ธรรมดาไม่ได้ ถ้าข้อมูลมากขึ้นอาจต้องพิจารณา index แบบอื่นหรือ full-text search ซึ่งควรบันทึกเป็นเรื่องที่ "ไม่ทำในรอบนี้" ใน spec ฉบับถัดไป

เมื่อความต้องการเปลี่ยน

spec ไม่ใช่เอกสารที่เขียนครั้งเดียวแล้วทิ้ง เมื่อความต้องการเปลี่ยน ให้แก้ spec ก่อน เพิ่มหรือแก้ AC แก้เทสต์ให้ตรง แล้วค่อยให้ AI แก้โค้ด ลำดับนี้ทำให้ spec กับโค้ดไม่แยกทางกัน เก็บ spec ไว้ใน repository ข้างโค้ด เช่น โฟลเดอร์ docs/specs/ และอ้างถึงใน PR ทุกครั้งที่เกี่ยวข้อง

สรุป

  • เขียน spec สั้นก่อนให้ AI ลงมือ เพื่อลดการเดาและมีเกณฑ์ตรวจรับงานที่ชัด
  • spec ที่ดีมีเป้าหมาย ขอบเขตที่ระบุทั้งสิ่งที่ทำและไม่ทำ และเกณฑ์รับงานที่ตรวจได้
  • แปลงทุก AC เป็นเทสต์หรือรายการตรวจ และเทสต์ต้อง fail ก่อนเขียนโค้ด
  • สั่ง AI ให้ทำให้เทสต์ผ่านโดยห้ามแก้เทสต์ แล้วรีวิวเรื่องที่เทสต์ไม่ครอบคลุม เช่น ประสิทธิภาพ

ลองทำ

เลือกฟีเจอร์เล็กที่กำลังจะทำ เขียน spec ตามแบบด้านบนให้ไม่เกินหนึ่งหน้าจอ โดยมี AC อย่างน้อย 5 ข้อ และต้องมีอย่างน้อยหนึ่งข้อเรื่อง input ผิดปกติ เช่น ค่าว่าง ยาวเกิน หรืออักขระพิเศษ จากนั้นให้ AI อ่าน spec แล้วถามว่า "spec นี้มีจุดไหนกำกวมหรือขาดหายบ้าง" ปรับ spec ตามคำถามที่สมเหตุสมผล แล้วจึงแปลงเป็นเทสต์

ติดปัญหาตอนทำตาม? ถามในชุมชน

ความคิดเห็น (0)

ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ