สัปดาห์ที่ 13 · Advanced

Debug + Test + Reality

ใน lab — code รันได้ · ใน production — code พัง · สัปดาห์นี้แยกผู้พัฒนามืออาชีพออกจากนักศึกษาปี 1 · เรียน "ระเบียบวิธีหา bug" · เขียน test ที่ ทดสอบจริง (ไม่ใช่ test ที่แค่ผ่าน) · และ ตรวจ test ที่ AI สร้าง ก่อนเชื่อ

เป้าหมายสัปดาห์นี้

🔒 สิ่งที่ AI ทำให้คุณไม่ได้ในสัปดาห์นี้ AI "เขียน test ได้" · แต่ AI เขียน test ที่ผ่านได้ง่าย ๆ โดย "test ตามว่า code ทำอะไร" ไม่ใช่ "test ว่า code ทำสิ่งที่ ควรทำ" · ความแตกต่างนี้ = สาเหตุของ bug 80% ในงานจริง · คุณต้องเป็นคน review test ที่ AI สร้าง และจับว่า "test นี้พิสูจน์อะไรจริง ๆ?"

🔬 Scientific Debugging — 5 ขั้นไม่ "เดามั่ว"

นักศึกษาปี 1 เจอ bug → "ลองเปลี่ยนนู่นนี่ดู" · "wiggle and hope" · มืออาชีพใช้วิธีวิทยาศาสตร์ — เร็วกว่าและเรียนรู้ทุกครั้ง

flowchart LR bug[🐛 เจอ bug] --> R[1. Reproduce
ทำให้พังซ้ำได้] R --> I[2. Isolate
หาขอบเขตที่พัง] I --> H[3. Hypothesize
เดาสาเหตุ] H --> T[4. Test hypothesis
เปลี่ยน 1 อย่าง] T -->|ตรง| F[5. Fix + Test
+ regression test] T -->|ไม่ตรง| H F --> done[✅ done] classDef step fill:#1e3a5f,stroke:#3776ab,color:#fff classDef bad fill:#5c1818,stroke:#ef4444,color:#fff classDef good fill:#064e3b,stroke:#34d399,color:#fff class bug bad class R,I,H,T step class F,done good

📋 5 ขั้น กับตัวอย่าง

ขั้นถามตัวอย่าง
1. Reproduce "ทำให้พังซ้ำได้มั้ย?" "feed Mochi 4 ครั้ง → hunger เป็น -2" · ทุกครั้งที่ทำ = พังเหมือนเดิม
2. Isolate "พังที่ไหน?" print หรือ debugger ดู: feed ครั้งที่ 4 → hunger = 1, ลบ 3 → -2 · มาจาก feed() ไม่มี max(0, ...)
3. Hypothesize "น่าจะเป็นเพราะ..." "hunger ไม่มี lower bound · ขั้นเดียวที่ลด hunger คือ feed → ต้องใส่ max(0, ...)"
4. Test hypothesis "ถ้าถูก จะเห็น Y · ลองดู" เพิ่ม self.hunger = max(0, self.hunger - 3) · feed 4 ครั้ง → hunger = 0 ✅
5. Fix + regression test "แก้แล้ว · ป้องกันไม่ให้กลับมา" เพิ่ม test_hunger_never_negative() ใน pytest · commit
⚠️ ขั้นที่นักศึกษาข้าม: "Reproduce" + "Hypothesize" ข้าม Reproduce = แก้แล้วไม่รู้ว่าหายจริงมั้ย (อาจหายชั่วคราว) · ข้าม Hypothesize = สุ่มเปลี่ยน · ไม่ได้เรียน · พังที่อื่นต่อ

🔍 Minimum Reproducible Example (MRE)

เมื่อขอความช่วยเหลือ (จาก AI, เพื่อน, Stack Overflow) — ต้อง "ย่อ bug ให้เล็กที่สุด" · MRE = code ที่ สั้นที่สุดที่ยังพังเหมือนเดิม

# ❌ Bug report ที่ไม่ช่วยใคร "ทำ Pet app แล้วพัง · code ของผมเยอะมาก · เปิด Cursor → ลงไฟล์ทั้ง folder → ก็พัง · ขอความช่วยเหลือ" # ❌ Code ที่ส่ง: 500 บรรทัด · 10 ไฟล์ · มี database · มี Flask · มี LINE # ❌ ไม่บอกว่าพังแบบไหน · ไม่มี error message · ไม่มี expected vs actual
# ✅ MRE — สั้นที่สุดที่ยังพังเหมือนเดิม class Pet: def __init__(self, name): self.name = name self.hunger = 5 def feed(self): self.hunger -= 3 # ← พัง: ลบไม่หยุดที่ 0 p = Pet("Mochi") p.feed(); p.feed() # hunger = -1 print(p.hunger) # expected: 0 · got: -1 # ✅ 10 บรรทัด · มี expected vs actual ชัด # ✅ AI/เพื่อน fix ได้ใน 30 วินาที
เคล็ดลับสร้าง MRE — "Halving method" ตัด code ครึ่งหนึ่ง · ดูว่ายังพังมั้ย · ถ้ายังพัง → ตัดอีกครึ่ง · ถ้าหาย → bug อยู่ในครึ่งที่ตัดออก → ใส่กลับเข้าไป · วน · ภายใน 10 รอบ จะเหลือ MRE

🧰 Debugging Toolbox — เครื่องมือเพิ่มเติม

วิธีใช้เมื่อทำยังไง
🦆 Rubber Duck Debugging ตอนคิดติด อธิบาย code ของคุณให้ ตุ๊กตาเป็ด / เพื่อน / AI ฟังทีละบรรทัด · 70% จะเจอ bug ระหว่างอธิบาย
⚖️ Binary Search Debugging ไม่รู้ว่า bug ใน code ส่วนไหน comment ครึ่ง code · ดูว่ายังพังมั้ย · ถ้าพัง → bug ในที่ยังเหลือ · ถ้าหาย → bug ในที่ comment
🌳 git bisect เคยใช้ได้ · ตอนนี้พัง · ไม่รู้ commit ไหนเป็นต้นเหตุ git bisect start · mark good/bad · git ค้นเองด้วย binary search
📖 อ่าน docs (จริง ๆ) library ไม่ทำงานตามที่คาด เปิด official docs · อ่าน method signature · เช็ค type ที่คืน · 50% ของ bug มาจากเข้าใจ API ผิด
🪄 ส่ง bug ให้ AI หลังทำ MRE แล้ว · ยังไม่เข้าใจ paste MRE + error + "ฉันคาดว่า X · ได้ Y · ทำไม?" — AI ตอบเร็วและมักถูก

🐞 อ่าน Stack Trace ให้เป็น

Traceback (most recent call last):
  File "main.py", line 42, in <module>
    process_students(students)
  File "main.py", line 28, in process_students
    grade = calc_grade(s["score"])
  File "grading.py", line 8, in calc_grade
    return rules[avg]
TypeError: list indices must be integers or slices, not str

วิธีอ่าน — "ล่างขึ้นบน"

  1. TypeError: list indices must be integers — error คือใช้ str เป็น index ของ list
  2. grading.py line 8 — เกิดที่ return rules[avg]
  3. ดู context ขึ้นไป — avg มาจาก parameter ของ calc_grade
  4. ขั้นถัดไป — main.py line 28 ส่ง s["score"] เข้าไป
  5. แต่ s["score"] เป็น string (จาก CSV ที่ยังไม่แปลง int)

🧪 Print Debugging — เริ่มที่นี่

เครื่องมือ debug แรกที่นักศึกษาควรใช้ — เพิ่ม print() ในจุดสำคัญ

def calc_grade(score):
    print(f"DEBUG calc_grade: score={score} type={type(score)}")  # ✨ เพิ่มชั่วคราว
    if score >= 80:
        return "A"
    ...

# รัน → จะเห็น "DEBUG calc_grade: score=85 type=<class 'str'>"
# พบทันทีว่า score เป็น str ไม่ใช่ int
ลบ DEBUG print ก่อน commit หรือใช้ logging module แทน — มี level (DEBUG / INFO / WARNING / ERROR) เปิด-ปิดได้

🔍 Cursor / VS Code Debugger

ขั้นสูงกว่า print — "หยุด code กลางทาง · ดูค่าตัวแปร · เดินทีละบรรทัด"

  1. เปิดไฟล์ Python ใน Cursor
  2. คลิกซ้ายของเลขบรรทัด → ขึ้นจุดแดง (breakpoint)
  3. กด F5 หรือ Run → Start Debugging
  4. โปรแกรมจะหยุดที่ breakpoint — ดูค่าตัวแปรในแถบซ้าย
  5. กด F10 (step over) หรือ F11 (step into) เดินทีละบรรทัด

✅ Assert — Test แบบเร็ว

def calc_grade(score):
    if score >= 80: return "A"
    elif score >= 70: return "B"
    elif score >= 60: return "C"
    else: return "F"


# ทดสอบทันทีในไฟล์เดียวกัน
assert calc_grade(85) == "A"
assert calc_grade(75) == "B"
assert calc_grade(65) == "C"
assert calc_grade(45) == "F"
print("✅ All tests passed")

ถ้า assert ผิด — โปรแกรมหยุด พร้อม AssertionError · ใช้ระหว่าง dev เพื่อ catch bug เร็ว ๆ

🧬 pytest — Test framework

เมื่อ test เริ่มเยอะ ใช้ pytest · ติดตั้ง: pip install pytest

tests/test_grading.py

from grading import calc_grade


def test_a_grade():
    assert calc_grade(85) == "A"
    assert calc_grade(80) == "A"


def test_b_grade():
    assert calc_grade(75) == "B"


def test_f_grade():
    assert calc_grade(40) == "F"
    assert calc_grade(0) == "F"


def test_edge_zero():
    assert calc_grade(0) == "F"


def test_negative():
    # spec ไม่ได้บอก แต่เราอยากให้ behavior ชัด
    assert calc_grade(-1) == "F"   # หรือ raise error?

รัน: pytest ใน terminal · จะเห็นผลทุก test

$ pytest
========================= test session starts =========================
collected 5 items

tests/test_grading.py .....                                    [100%]

========================== 5 passed in 0.02s ==========================

parametrize — test หลาย case ใน function เดียว

import pytest

@pytest.mark.parametrize("score,expected", [
    (100, "A"), (85, "A"), (80, "A"),
    (75, "B"), (70, "B"),
    (65, "C"), (60, "C"),
    (50, "D"), (40, "F"), (0, "F"),
])
def test_calc_grade(score, expected):
    assert calc_grade(score) == expected

🚨 Error Handling — try/except/finally/raise

W04-W05 สอน "อ่าน error" · W13 ก่อนหน้านี้สอน "test ว่า error เกิด" · แต่ "เขียน code ที่ handle error ดี" ยังไม่ครอบ · นี่คือ producer-side ของ error

🌳 Exception Hierarchy ใน Python

Python มี exception class ที่สืบทอด · จับ class แม่ = จับลูกหลานทั้งหมด

BaseException
 └── Exception              ← จับตรงนี้ ครอบเกือบทุกอย่าง
      ├── ArithmeticError
      │    ├── ZeroDivisionError
      │    └── OverflowError
      ├── LookupError
      │    ├── KeyError       ← dict["x"] · "x" ไม่มี
      │    └── IndexError     ← list[100] · เกินขนาด
      ├── OSError
      │    ├── FileNotFoundError
      │    └── PermissionError
      ├── ValueError          ← int("abc")
      ├── TypeError           ← 1 + "two"
      ├── AttributeError      ← obj.no_such_method()
      └── ImportError

🔧 try/except/else/finally — โครงสร้างเต็ม

def safe_divide(a, b): try: # 🟢 ลองทำ — code ที่อาจพัง result = a / b except ZeroDivisionError: # 🔴 จับ error เฉพาะชนิด print(f"❌ หารด้วย 0 ไม่ได้") return None except TypeError as e: # 🔴 จับ error อื่น print(f"❌ Type ผิด: {e}") return None else: # 🟡 ทำเมื่อไม่มี error print(f"✅ คำนวณสำเร็จ") return result finally: # 🔵 ทำทุกครั้ง · พังหรือไม่พังก็ตาม print(f" (เรียก safe_divide({a}, {b}))") # Test cases print(safe_divide(10, 2)) # ปกติ print() print(safe_divide(10, 0)) # ZeroDivisionError print() print(safe_divide(10, "x")) # TypeError
Blockเมื่อทำใช้สำหรับ
tryเสมอcode ที่อาจพัง
exceptเมื่อ error เกิดhandle / fallback
elseเมื่อ try ไม่พัง"happy path follow-up"
finallyเสมอ (พังหรือไม่ก็ทำ)cleanup (ปิดไฟล์, ปิด connection)

📛 raise — ส่ง error ของตัวเอง

def calc_grade(score: int) -> str: # ✅ Validate input ก่อน · raise ถ้าผิด if not isinstance(score, (int, float)): raise TypeError(f"score ต้องเป็นเลข · ได้ {type(score).__name__}") if score < 0 or score > 100: raise ValueError(f"score ต้อง 0-100 · ได้ {score}") # Happy path — เรียบง่าย ไม่ต้องเช็คอีก if score >= 80: return "A" if score >= 70: return "B" if score >= 60: return "C" return "F" # ใช้ใน function อื่น — handle error def grade_student(score): try: grade = calc_grade(score) print(f"score={score} → {grade}") except (TypeError, ValueError) as e: print(f"❌ Bad input: {e}") grade_student(85) # ✅ A grade_student(150) # ❌ ValueError grade_student("abc") # ❌ TypeError grade_student(72.5) # ✅ C

🎨 Custom Exception — สร้าง error class ของตัวเอง

class GradingError(Exception):
    """Base exception สำหรับ Grading App"""
    pass

class InvalidScoreError(GradingError):
    """Score อยู่นอกช่วง 0-100"""
    pass

class IncompleteSubmissionError(GradingError):
    """ส่งงานไม่ครบ"""
    def __init__(self, missing: list):
        self.missing = missing
        super().__init__(f"ขาด: {', '.join(missing)}")


# ใช้
def grade(submission):
    if not submission.get("drawing"):
        raise IncompleteSubmissionError(["drawing"])
    if submission["score"] < 0:
        raise InvalidScoreError(...)

# caller จับ class แม่ = ครอบทุก subclass
try:
    grade(sub)
except GradingError as e:
    log.error(f"Grading failed: {e}")

📋 Best Practices — Error Handling Rules

กฎเพราะ
1. จับเฉพาะ error ที่รู้ว่าจะเกิด การจับ except: เปล่า = ซ่อน bug ที่เราไม่รู้
2. ห้าม except: pass "swallow error" = ที่มาของ bug ที่ debug ไม่เจอ · log ทุกครั้งที่จับ
3. raise ใหม่ดีกว่าเปลี่ยน error type ใช้ raise NewError(...) from e · เก็บ original cause
4. validate input ที่ boundary raise ที่จุดรับ input · ไม่ปล่อยให้ error เกิดลึก ๆ ใน code
5. ใช้ specific exception ValueError("score ต้อง 0-100") > Exception("bad")
6. finally สำหรับ cleanup เท่านั้น ปิด file, db connection, network · ไม่ใช่ business logic
7. ใช้ context manager (with) with open(f) = auto-close · ดีกว่า try/finally manual
🚨 Anti-pattern: "Silent failure"
# ❌ AI ชอบทำ — bug ที่เลวร้ายที่สุด
try:
    result = call_api()
except Exception:
    result = None       # error หาย · ไม่มี log

# ✅ ทำให้ดี
try:
    result = call_api()
except requests.Timeout:
    log.warning("API timeout · retry...")
    result = None
except requests.RequestException as e:
    log.error(f"API failed: {e}")
    raise   # re-raise ถ้า caller ต้องรู้

🅰️🅰️🅰️ AAA Pattern — โครงสร้างของ test ที่ดี

ทุก test ที่ดี = 3 ส่วน Arrange · Act · Assert — แยกบรรทัดให้ชัด · อ่านง่าย · ดูออกว่า test "พิสูจน์อะไร"

def test_feed_reduces_hunger():
    # 🟢 ARRANGE — เตรียม object + ข้อมูล
    pet = Pet("Mochi")
    pet.hunger = 8

    # 🟡 ACT — เรียก behavior ที่ทดสอบ
    pet.feed("แครอท")

    # 🔴 ASSERT — เช็คผล
    assert pet.hunger == 5     # หิวลดลง 3

📝 Test naming — บอก behavior ไม่ใช่ implementation

❌ ชื่อแบบเทคนิค✅ ชื่อแบบ behavior
test_feedtest_feed_reduces_hunger_by_three
test_gradetest_score_80_returns_grade_A
test_validatetest_negative_score_raises_value_error
Naming convention: test_when_X_then_Y อ่านชื่อ → รู้ทันทีว่า test อะไร · เมื่อ test fail → ชื่อบอกปัญหาทันที · ดีกว่าเห็น "test_feed failed"

🎭 Mocking — แทนของจริงตอน test

ปัญหา: test ต้องเรียก IQAir API จริง → ช้า · ใช้ key · network แตก · ผลไม่แน่นอน · ทางแก้: mock — ปลอม response ให้

💡 เมื่อไหร่ใช้ Mock

สิ่งที่ควร mockเพราะ
External API (IQAir, LINE, Claude)ช้า · ใช้ token · ผลไม่คงที่
Databasestate กระทบ test อื่น
เวลา (datetime.now())test ไม่ deterministic
Randomผลเปลี่ยนทุก run
File I/Oกระทบ filesystem จริง
Hardware (sensor, motor)ไม่มีอุปกรณ์ใน CI

🐾 ตัวอย่าง: Mock IQAir API

# aqi.py — production code
import requests

def fetch_pm25(city):
    r = requests.get(f"https://api.airvisual.com/.../{city}", timeout=10)
    r.raise_for_status()
    return r.json()["data"]["current"]["pollution"]["aqius"]


def alert_if_unhealthy(city):
    pm = fetch_pm25(city)
    if pm > 100:
        return f"⚠️ {city}: PM2.5 = {pm}"
    return None


# test_aqi.py
from unittest.mock import patch
import aqi

@patch("aqi.fetch_pm25")    # 🎭 mock fetch_pm25
def test_alert_when_unhealthy(mock_fetch):
    mock_fetch.return_value = 150   # ปลอม PM2.5 = 150

    result = aqi.alert_if_unhealthy("Bangkok")

    assert "PM2.5 = 150" in result
    mock_fetch.assert_called_once_with("Bangkok")


@patch("aqi.fetch_pm25")
def test_no_alert_when_safe(mock_fetch):
    mock_fetch.return_value = 50

    result = aqi.alert_if_unhealthy("Bangkok")

    assert result is None
⚠️ Mock พลาดที่พบบ่อย
  • Mock มากเกินไป — test แค่ "mock ทำงาน" ไม่ได้ test logic จริง
  • Mock ที่ตำแหน่งผิด@patch("requests.get") ❌ vs @patch("aqi.requests.get") ✅ (mock ที่ "ที่ที่ใช้" ไม่ใช่ "ที่ที่ define")
  • ไม่ assert ว่า mock ถูกเรียก — code อาจไม่ได้เรียก fetch_pm25 เลย

📊 Test Coverage — วัดว่า test ครอบคลุมแค่ไหน

pytest-cov รัน test แล้วบอกว่า "บรรทัดไหนของ code ที่ test ไม่ได้แตะ"

# ติดตั้ง
pip install pytest-cov

# รัน
pytest --cov=src --cov-report=term-missing

# output:
Name              Stmts   Miss  Cover   Missing
-------------------------------------------------
src/grading.py       12      2    83%   18-19   ← บรรทัดที่ test ไม่แตะ
src/pet.py           20      0   100%
-------------------------------------------------
TOTAL                32      2    94%
📌 Coverage % เท่าไหร่ถึง "พอ"
  • < 50% — ส่วนใหญ่ไม่ได้ test · เสี่ยง
  • 60-80% — พอใช้สำหรับ project นักศึกษา · happy path + edge cases หลัก ๆ ครบ
  • 80-90% — ดี · production-ready
  • > 95% — ระวัง! · มักทำได้ด้วยการ test "บรรทัด" ไม่ใช่ "behavior" · false confidence
กฎ: Coverage บอกว่า "บรรทัดถูกแตะ" ไม่ได้บอกว่า "test ดี" · 100% coverage ที่ test ห่วย = ปลอดภัยจอมปลอม
Categoryตัวอย่าง
Empty / Zerolist ว่าง, string ว่าง, 0, None
Boundaryค่าที่อยู่ตรง threshold (เช่น 80 พอดี, 79.999)
Negative / Out of range-1, > 100, > max_size
Wrong typeใส่ string ใน int parameter
Special charactersภาษาไทย, emoji, quote, < >
Duplicateเพิ่ม id เดียวกัน 2 ครั้ง
Concurrent2 คนเขียน file พร้อมกัน
NetworkAPI ช้า, timeout, return 500
Time zoneUTC vs ICT, daylight saving
Large inputfile 1 GB, list 1 ล้านตัว

🤖 AI Test Review — Checklist 8 ข้อ ก่อนเชื่อ Test ที่ AI สร้าง

สถานการณ์: คุณบอก AI "เขียน test สำหรับ calc_grade" · AI สร้างให้ 10 test ทั้งหมดผ่าน · หยุดก่อน! · test ที่ผ่าน ≠ test ที่ดี · ตรวจ 8 ข้อนี้ก่อนเชื่อ

💡 ทำไมเรื่องนี้สำคัญที่สุดในยุค AI AI เขียน test ที่ผ่านได้ เก่งกว่านักศึกษา · แต่ AI "test ตามว่า code ทำอะไร" ไม่ใช่ "test ว่า code ทำสิ่งที่ควรทำ" · ความแตกต่างนี้คือ bug 80% ในงานจริง

✅ Checklist 8 ข้อ

ข้อคำถามวิธีตรวจ
1. Test test "behavior" หรือ "implementation"? Test ดูว่า "result ถูก" หรือ "เรียก function X ภายใน"? ลอง refactor implementation ขณะ test เดิม — ถ้า test fail = test implementation (ไม่ดี)
2. ครอบ edge cases มั้ย? 0 · empty · None · negative · max · type ผิด เช็คจาก "Edge Cases ต้องคิด" table · อย่างน้อย 5 หมวด
3. ทุก test "fail ตอน code ผิด" จริงมั้ย? เปลี่ยน code ผิดเล็ก ๆ · test ควร fail Mutation testing — เปลี่ยน >= 80 เป็น >= 81 · ถ้าไม่มี test fail = test ไม่จริง
4. Test มี hidden assumption มั้ย? Test ทำงานเพราะ "เผอิญ" หรือ "ตามที่ควร"? เปลี่ยน input คล้าย ๆ — เช่น 85 → 84.9 · ผลควรชัดเจน
5. Mock realistic มั้ย? Mock return value เป็น format จริงของ API มั้ย เทียบ mock value กับ response จริงของ API · ใช้ response.json() ของจริง 1 รอบเป็น reference
6. Test อิสระจากกันมั้ย? รัน test แค่อันใดอันหนึ่งได้มั้ย · order matter มั้ย pytest tests/test_x.py::test_specific · ถ้า fail = ไม่ isolated
7. Test ไม่ผิดจาก "การ copy-paste" มั้ย? AI ก็อปแล้วเปลี่ยนแค่ตัวเลข · ลืมเปลี่ยน expected อ่านทุก assert · เปรียบเทียบกับ test ก่อนหน้า
8. มี "regression test" สำหรับ bug ที่เจอมั้ย? หลังแก้ bug · มี test ป้องกัน bug นั้นกลับมามั้ย ทุก bug ที่แก้ → ต้องมี test เพิ่ม · test ชื่อ test_regression_issue_X

🐛 Tests That Pass But Don't Test — false confidence

นี่คือปัญหาที่ใหญ่ที่สุดของ AI-generated tests · test ผ่าน 100% — แต่ ไม่ได้พิสูจน์อะไร

🚩 Pattern 1: Test แค่ "code รันได้ไม่ crash"

def divide(a, b): return a / b # ❌ Test ที่ AI เขียน — ผ่านง่ายแต่ไม่พิสูจน์อะไร def test_divide(): result = divide(10, 2) assert result is not None # ผ่านเสมอถ้าไม่ crash assert isinstance(result, (int, float)) # ผ่านเสมอ test_divide() print("✅ test ผ่าน — แต่...") print(f" divide(10, 2) = {divide(10, 2)}") # 5.0 print(f" divide(10, 3) = {divide(10, 3)}") # 3.33... # ถ้า divide() คืน 9999 ก็ยังผ่าน test! เพราะ test ไม่เช็คค่าจริง # ✅ Test ที่ดี def test_divide_correct_value(): assert divide(10, 2) == 5 assert divide(7, 2) == 3.5 assert divide(0, 5) == 0 # มี boundary import pytest # divide(10, 0) ควร raise ZeroDivisionError test_divide_correct_value() print("✅ test ที่ดี ผ่าน — และจริง ๆ พิสูจน์ behavior")

🚩 Pattern 2: Test สะท้อนความผิดของ code

# ❌ Code มี bug
def add(a, b):
    return a - b      # BUG: ลบแทนบวก

# ❌ AI เขียน test โดยรัน code ก่อน แล้วใช้ผลเป็น expected
def test_add():
    assert add(5, 3) == 2     # ✅ ผ่าน · แต่ผิดจาก spec
# Test ผ่าน 100% · ทั้งที่ code ผิด!


# ✅ Test ที่ดี — เขียนจาก spec ก่อนรู้ผล
def test_add():
    # spec บอก: add(a, b) = a + b
    assert add(5, 3) == 8      # ❌ FAIL — เห็น bug!

🚩 Pattern 3: Test ที่ทดสอบ mock ตัวเอง

@patch("aqi.fetch_pm25")
def test_alert(mock_fetch):
    mock_fetch.return_value = 150
    result = aqi.alert_if_unhealthy("Bangkok")

    # ❌ Bad — test แค่ว่า fetch_pm25 ถูกเรียก
    mock_fetch.assert_called_once()
    # → ไม่ได้ check ผลลัพธ์ของ alert_if_unhealthy เลย

    # ✅ Good — check behavior ที่อยากให้เกิด
    assert "150" in result
    assert "⚠️" in result

🚩 Pattern 4: Test ไม่มี edge case

# ❌ AI เขียน — ครอบ happy path เท่านั้น
def test_grade():
    assert calc_grade(85) == "A"
    assert calc_grade(75) == "B"
    assert calc_grade(65) == "C"
    assert calc_grade(45) == "F"


# ✅ Test ที่ดี — มี boundary + edge case
def test_grade():
    # happy path
    assert calc_grade(85) == "A"
    # boundaries (ตรงเกณฑ์)
    assert calc_grade(80) == "A"
    assert calc_grade(79) == "B"   # ← จุดอ่อนของ off-by-one
    # edge cases
    assert calc_grade(0) == "F"
    assert calc_grade(100) == "A"
    # invalid
    import pytest
    with pytest.raises(ValueError):
        calc_grade(-1)

🧬 Mutation Testing — ทดสอบ "ว่า test จริง" ด้วยมือ

Idea: ตั้งใจ "ทำลาย code" ทีละจุด · ดูว่า test fail มั้ย · ถ้า test ไม่ fail = test ไม่ test ส่วนนั้นจริง

Mutationเปลี่ยนใน codeTest ที่ดีต้อง fail
เปลี่ยน operator>=>ใช่ — boundary test ต้องจับ
เปลี่ยน threshold8081ใช่
swap argumentsdivide(a, b)divide(b, a)ใช่
negate booleanif xif not xใช่
ลบบรรทัด validationลบ if score < 0: raiseใช่
เคล็ดลับ — Manual mutation 1 นาที หลัง AI เขียน test เสร็จ · ลอง mutate code 3 จุด · รัน test · ถ้า test ไม่ fail = AI เขียน test อ่อน · บอก AI: "test ของคุณไม่จับ mutation นี้ — แก้"

🤥 จับ AI Hallucination — ระดับ Medium

รูปแบบวิธีจับ
Function ที่ไม่มีจริง — เช่น pandas.smart_clean()รัน → AttributeError
Parameter ที่ไม่มีrequests.get(url, retry=3)ตรวจ docs / รัน → TypeError
Logic ผิดแบบ subtle — off-by-one, <= แทน <test boundary case
"ใช้ได้แล้ว!" ทั้งที่ยังไม่ testรันด้วยตัวเอง
เพิ่ม feature ที่ไม่ขอdiff กับ spec บรรทัดต่อบรรทัด
เปลี่ยน behavior ที่ไม่ขอcompare กับ test cases เดิม (regression)
Library version ไม่ตรง — ใช้ method ที่ deprecate ไปเช็ค requirements.txt + docs
Confidence สูง คำตอบมั่วverify ทุกครั้ง · "show me, don't tell me"

📋 "Works on My Machine" — ปัญหาคลาสสิก

code รันได้บนเครื่องคุณ · ไม่ทำงานบนเครื่องเพื่อน · เพราะ:

การทดสอบที่ดีที่สุด — "rerun on fresh clone"
1. clone repo ลงเครื่องใหม่ / folder ใหม่
2. python -m venv .venv && activate
3. pip install -r requirements.txt
4. cp .env.example .env (เติมค่า)
5. python main.py
ถ้าทำงานได้ = พอใช้

📊 Logging แทน print

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
)

log = logging.getLogger(__name__)

def fetch_aqi(city):
    log.info(f"Fetching AQI for {city}")
    try:
        r = requests.get(URL, timeout=10)
        r.raise_for_status()
        log.debug(f"Response: {r.json()}")
        return r.json()
    except requests.RequestException as e:
        log.error(f"API failed: {e}")
        return None

⚙️ CI/CD with GitHub Actions — รัน test ทุก push อัตโนมัติ

CI (Continuous Integration) = ทุกครั้งที่ push code · server รัน test ให้อัตโนมัติ · ถ้า test fail → แสดง ❌ บน GitHub · ป้องกัน merge code ที่พัง · ฟรีสำหรับ public repo

📝 Setup ใน 3 นาที

# สร้างไฟล์: .github/workflows/test.yml

name: Python Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install deps
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-cov

      - name: Run tests
        run: pytest --cov=src --cov-report=term-missing

      - name: Lint
        run: |
          pip install ruff
          ruff check .

หลังจาก push:

📌 Benefits ของ CI
  • เพื่อน clone repo → push pull request → คุณรู้ทันทีว่า test ผ่านมั้ย
  • "fresh clone test" อัตโนมัติ — works on my machine ไม่หลุดเข้า main
  • Portfolio: "repo ของฉันมี CI" = สัญญาณว่า dev มืออาชีพ

🎯 Common CI strategies

Strategyเมื่อไหร่ใช้
Run all testsproject เล็ก-กลาง · test < 5 นาที
Run on push to main onlyประหยัด minutes
Run on PRก่อน merge — บังคับให้ test ผ่าน
Matrix test (multi Python)library ที่ support หลาย version
Cache depsเร็วขึ้น 30-60s
Run smoke tests on scheduleตรวจว่า external API ยังทำงาน

🔧 Engineering Testing — Sensor / Hardware patterns

วิศวกรรม ≠ pure software · มี hardware · sensor มี noise · serial port ไม่เสถียร · ทดสอบยังไงไม่ต้องเอาเครื่องจริงทุกครั้ง?

🎭 Pattern 1: Stub sensor — ใช้ class ที่ทำตัวเหมือน sensor

# production
class TempSensor:
    def __init__(self, port="/dev/ttyUSB0"):
        import serial
        self.serial = serial.Serial(port, 9600)

    def read(self):
        line = self.serial.readline().decode()
        return float(line.strip())


# test stub — ทำตัวเหมือน sensor
class FakeTempSensor:
    def __init__(self, values):
        self.values = iter(values)

    def read(self):
        return next(self.values)


# code ที่ใช้ — รับ sensor เป็น dependency
class TempLogger:
    def __init__(self, sensor):
        self.sensor = sensor
        self.history = []

    def log(self):
        t = self.sensor.read()
        self.history.append(t)
        return t


# test ใช้ Fake — ไม่ต้องมี hardware
def test_logger_tracks_history():
    fake = FakeTempSensor([25.0, 26.5, 27.0])
    logger = TempLogger(fake)

    logger.log()
    logger.log()
    logger.log()

    assert logger.history == [25.0, 26.5, 27.0]


def test_logger_handles_overheat():
    fake = FakeTempSensor([95.0])
    logger = TempLogger(fake)
    t = logger.log()
    assert t > 90  # alert range

🎬 Pattern 2: Replay test — บันทึก data จริง · เล่นซ้ำใน test

# บันทึก 1 ครั้ง — ตอนวัดจริง
import csv, datetime
with open("recorded_data.csv", "w") as f:
    w = csv.writer(f)
    w.writerow(["ts", "temp", "humidity"])
    for _ in range(1000):
        w.writerow([datetime.datetime.now(), sensor.read(), hum.read()])


# test — เล่นซ้ำจาก CSV ไม่ใช้ sensor จริง
class ReplayDataSource:
    def __init__(self, csv_path):
        import csv
        with open(csv_path) as f:
            self.rows = list(csv.DictReader(f))
        self.idx = 0

    def read(self):
        row = self.rows[self.idx]
        self.idx += 1
        return float(row["temp"])


def test_analyze_24h_data():
    src = ReplayDataSource("test_data/24h_normal.csv")
    result = analyze_period(src, hours=24)
    assert result["max_temp"] < 30
    assert result["alerts"] == 0

🛠 Pattern 3: Calibration test

# Sensor มี noise — test ใช้ "ช่วง" ไม่ใช่ "ค่าตรง"
def test_temp_reading_within_tolerance():
    sensor = TempSensor("/dev/ttyUSB0")
    readings = [sensor.read() for _ in range(10)]
    mean = sum(readings) / len(readings)

    # known-good range สำหรับห้องที่ 25°C ± 2
    assert 23 <= mean <= 27, f"temp out of expected range: {mean}"

    # standard deviation ของ noise
    import statistics
    std = statistics.stdev(readings)
    assert std < 0.5, f"noisy sensor: stdev = {std}"

🛡 Pattern 4: Safety test — ทดสอบ failure mode

def test_motor_stops_on_emergency():
    motor = FakeMotor()
    controller = SafetyController(motor)
    controller.start()
    assert motor.is_running()

    controller.emergency_stop()
    assert not motor.is_running()
    assert motor.stop_time_ms < 100   # ต้องหยุดใน 100ms

def test_sensor_disconnect_triggers_alarm():
    sensor = FakeTempSensor([25.0, None, None, None])   # disconnect
    controller = ProcessController(sensor)
    for _ in range(4):
        controller.tick()
    assert controller.alarm_state == "SENSOR_FAILURE"
กฎทอง Engineering Testing
  • Sensor มี noise → test ช่วง · ไม่ test ค่าตรง
  • Hardware ไม่มี → ใช้ Fake/Stub · inject เป็น dependency
  • Data จริง → บันทึก replay · test ไม่ต้องต่อ hardware
  • Safety test สำคัญที่สุด — emergency stop, sensor failure, power loss

🧪 Workshop — Test Suite for "Smart Lab Monitor"

เอา project จาก W12 มา เพิ่ม tests + handle error ดีขึ้น

  1. สร้าง folder tests/ + tests/test_aqi.py + tests/test_storage.py + tests/test_alert.py
  2. เขียน test สำหรับ pure functions ตาม AAA pattern — function ที่ไม่ต้อง network · เช่น parse_response, format_alert, calc_average · ใช้ test_when_X_then_Y naming
  3. Mock external API — ใช้ unittest.mock.patch("aqi.requests.get") · mock IQAir + LINE + Claude · เช็คทั้ง "called with correct args" และ "result ถูก"
  4. เพิ่ม edge cases ครบ 5 หมวด — empty/zero · boundary · negative · wrong type · network failure (timeout, 500, malformed JSON)
  5. 🤖 ใช้ AI ช่วย generate test เพิ่ม — paste code + spec → "เขียน pytest 10 cases · มี edge case · ใช้ AAA pattern"
  6. 🔍 ตรวจ AI test ตาม Checklist 8 ข้อ — เช็คทุก test ของ AI · ลอง mutation testing manual 3 จุด · บันทึกใน ai-review.md
  7. 📊 Run coveragepytest --cov=src --cov-report=term-missing · ตั้งเป้า ≥ 70% · เพิ่ม test สำหรับบรรทัดที่ขาด
  8. ⚙️ เพิ่ม GitHub Actions.github/workflows/test.yml · push และดูว่า test ผ่าน · เพิ่ม badge ใน README
  9. 🐛 Break test ตัวเอง — เปลี่ยน threshold ใน production code · ดูว่า test fail ที่ตรงไหน · ถ้าไม่ fail = test อ่อน
  10. 📝 Bug log — บันทึก 3 bug ที่ test จับได้ใน error-log.md · ใช้ format Scientific Debugging

🎯 Test Pyramid — ออกแบบ test ที่สมดุล

E2E

few · slow

"คนใช้จริงคลิกได้"

Integration

some · medium

"function หลายตัวคุยกัน"

Unit Tests

many · fast

"แต่ละ function ทำงานถูก"

เริ่มจาก unit test เยอะ ๆ ก่อน · integration น้อยกว่า · E2E น้อยที่สุด

ข้อผิดที่พบบ่อย

Test แค่ happy path — เคสปกติผ่าน คิดว่าใช้ได้ · ของจริงพังเพราะ edge case
เชื่อ AI ว่า code ใช้ได้ AI พูดว่า "tested" — มันไม่ได้รันจริง · เรารันเอง
Test ที่ flaky (สุ่มผ่าน/ไม่ผ่าน) มัก involve time, network, random · ทำให้ deterministic
ไม่เขียน test เพราะ "ยังไม่มีเวลา" เขียน test เร็วกว่า debug bug ภายหลัง · กฎ: ทุก function ที่มี logic → ≥ 3 test cases

ส่งงานสัปดาห์นี้

Reference จาก slide เดิม

เนื้อหานี้ ไม่มี ใน slide เดิม — เป็น Mainidea Foundation 8 (Debug) + 9 (Testing) + 10 (AI Operation) ที่ขาดไม่ได้ในยุค AI