สัปดาห์ที่ 2 Debugging, Testing และ Source Code Maintenance

จุดประสงค์การเรียนรู้

  1. ใช้ Debugger, Breakpoint, Watch และ Call Stack เพื่อตรวจสอบโปรแกรมได้
  2. แยกความแตกต่างของ Unit Test, Integration Test, System Test และ Regression Test ได้
  3. อธิบาย Refactoring และ Source Code Maintenance ได้

เนื้อหา

1. Debugging คือการหาสาเหตุ ไม่ใช่การเดาแก้

Debugging คือกระบวนการค้นหา วิเคราะห์ และแก้ไขข้อผิดพลาดของโปรแกรมอย่างมีหลักฐาน เป้าหมายไม่ใช่แค่ทำให้ error หายไป แต่ต้องเข้าใจว่า error เกิดจากอะไร เกิดในเงื่อนไขใด และการแก้ไขจะไม่ทำให้ส่วนอื่นเสียหาย

ผู้เริ่มต้นมักแก้ Bug ด้วยการเดา เช่น เปลี่ยนเงื่อนไข เพิ่มตัวแปร หรือลบโค้ดบางบรรทัดโดยไม่เข้าใจสาเหตุ วิธีนี้อาจทำให้โปรแกรมดูเหมือนทำงานได้ชั่วคราว แต่มีโอกาสสร้าง Bug ใหม่ การ Debugging ที่ดีต้องเริ่มจากการทำให้ปัญหาเกิดซ้ำได้ก่อน แล้วค่อยตรวจค่าที่เกี่ยวข้องทีละจุด

flowchart TD
  A[พบอาการผิดปกติ] --> B[Reproduce<br/>ทำให้ปัญหาเกิดซ้ำได้]
  B --> C[Observe<br/>ดู input, output, error message]
  C --> D[Hypothesize<br/>ตั้งสมมติฐานสาเหตุ]
  D --> E[Inspect<br/>ใช้ breakpoint, watch, call stack]
  E --> F{พบสาเหตุจริงหรือไม่}
  F -- ไม่พบ --> C
  F -- พบ --> G[Fix<br/>แก้เฉพาะสาเหตุ]
  G --> H[Verify<br/>ทดสอบซ้ำ]
  H --> I[Add Test<br/>เพิ่ม test case กันปัญหาซ้ำ]

เครื่องมือหลักที่ใช้ระหว่าง Debugging:

เครื่องมือ ใช้ทำอะไร ตัวอย่างสถานการณ์
Breakpoint หยุดโปรแกรมที่บรรทัดสำคัญ หยุดก่อนคำนวณส่วนลดเพื่อตรวจค่า input
Step Over รันคำสั่งถัดไปโดยไม่เข้าไปในฟังก์ชันย่อย ต้องการดู flow หลักอย่างรวดเร็ว
Step Into เข้าไปตรวจการทำงานในฟังก์ชันที่ถูกเรียก สงสัยว่าฟังก์ชันคำนวณให้ค่าผิด
Watch ติดตามค่าตัวแปรหรือนิพจน์ ดูว่าค่า total เปลี่ยนตอนไหน
Call Stack ดูลำดับการเรียกฟังก์ชัน ตรวจว่า error ถูกเรียกมาจาก flow ใด

ตัวอย่างสถานการณ์:

double average(int total, int count) {
    return total / count;
}

โค้ดนี้อาจเกิด Runtime Error หรือผลลัพธ์ผิดได้ หาก count เป็น 0 หรือหากต้องการผลลัพธ์ทศนิยมแต่ใช้การหารแบบจำนวนเต็ม Debugger จะช่วยดูค่าของ total และ count ณ เวลาที่ฟังก์ชันถูกเรียก ส่วน Test Case จะช่วยยืนยันว่ากรณี count = 0 ถูกจัดการแล้ว

2. จาก Bug Report ไปสู่สาเหตุจริง

เมื่อได้รับรายงานปัญหา ผู้พัฒนาควรแยก "อาการ" ออกจาก "สาเหตุ" เช่น ผู้ใช้บอกว่าโปรแกรมค้าง นั่นเป็นอาการ แต่สาเหตุอาจมาจาก loop ไม่สิ้นสุด การอ่านไฟล์ขนาดใหญ่ หรือการรอ network โดยไม่มี timeout

flowchart LR
  A[Bug Report] --> B[อาการที่เห็น]
  B --> C[ข้อมูลประกอบ<br/>input, log, screen, step]
  C --> D[สมมติฐาน]
  D --> E[ตรวจด้วย Debugger]
  E --> F[Root Cause]
  F --> G[Fix + Test]

ข้อมูลที่ควรบันทึกเมื่อพบ Bug:

  1. ขั้นตอนที่ทำให้เกิดปัญหา
  2. Input หรือข้อมูลตัวอย่างที่ใช้
  3. ผลลัพธ์ที่คาดหวัง
  4. ผลลัพธ์ที่เกิดขึ้นจริง
  5. ข้อความ error หรือ log ที่เกี่ยวข้อง
  6. เวอร์ชันของโปรแกรมหรือไฟล์ที่ใช้ทดสอบ

3. Software Testing

Testing คือการตรวจสอบว่าโปรแกรมทำงานตรงตามความต้องการและยังทำงานถูกต้องเมื่อมีการเปลี่ยนแปลง การทดสอบที่ดีต้องมีกรณีปกติ กรณีขอบเขต และกรณีผิดพลาด ไม่ควรทดสอบเฉพาะข้อมูลที่ผู้เขียนคาดว่าจะถูกต้องเท่านั้น

ประเภทการทดสอบที่สำคัญ:

ประเภท ตรวจอะไร ตัวอย่าง
Unit Test ฟังก์ชันหรือโมดูลย่อย ทดสอบฟังก์ชันคำนวณภาษี
Integration Test การทำงานร่วมกันของหลายส่วน ทดสอบการอ่านไฟล์แล้วส่งข้อมูลไปคำนวณ
System Test ระบบทั้งชุดตาม flow ของผู้ใช้ ทดสอบตั้งแต่รับข้อมูล ประมวลผล และแสดงผล
Regression Test ตรวจว่าของเดิมไม่เสียหลังแก้ไข รัน test เดิมหลังแก้ Bug
flowchart TB
  A[System Test<br/>ตรวจทั้งระบบ] 
  B[Integration Test<br/>ตรวจหลายโมดูลทำงานร่วมกัน]
  C[Unit Test<br/>ตรวจฟังก์ชันหรือโมดูลย่อย]
  A --> B --> C

ในทางปฏิบัติ Unit Test ควรมีจำนวนมากที่สุด เพราะรันเร็วและบอกตำแหน่งปัญหาได้ชัดเจน Integration Test และ System Test มีจำนวนน้อยกว่าแต่ช่วยยืนยันว่าภาพรวมของระบบทำงานถูกต้อง

4. การออกแบบ Test Case

Test Case ที่ดีควรระบุ input, expected output, ขั้นตอนทดสอบ และผลลัพธ์จริง เพื่อให้ผู้อื่นตรวจซ้ำได้ การเขียน Test Case ไม่ใช่เพียงงานหลังเขียนโค้ดเสร็จ แต่ช่วยให้ผู้พัฒนาเข้าใจ requirement ชัดขึ้นตั้งแต่ต้น

ตัวอย่าง Test Case สำหรับฟังก์ชันคำนวณส่วนลด:

กรณี Input Expected Output เหตุผล
Normal case 1500 75 มากกว่า 1000 ได้ลด 5%
Boundary case 1000 50 ครบ 1000 ต้องได้ลด
Below boundary 999 0 ยังไม่ครบเงื่อนไข
Invalid case -100 Error หรือ 0 ราคาไม่ควรติดลบ
Zero case 0 0 ไม่มีการซื้อ

รูปแบบการคิด Test Case:

mindmap
  root((Test Case Design))
    Normal Case
      ข้อมูลถูกต้องทั่วไป
    Boundary Case
      ค่าต่ำสุด
      ค่าสูงสุด
      จุดเปลี่ยนเงื่อนไข
    Invalid Case
      ค่าว่าง
      ชนิดข้อมูลผิด
      ค่าติดลบ
    Regression Case
      กรณีที่เคยเกิด Bug
      ฟีเจอร์เดิมที่ต้องไม่เสีย

5. Refactoring

Refactoring คือการปรับโครงสร้างโค้ดโดยไม่เปลี่ยนพฤติกรรมภายนอกของโปรแกรม เช่น แยกฟังก์ชัน ลดโค้ดซ้ำ เปลี่ยนชื่อให้สื่อความหมาย หรือจัดโมดูลใหม่ เป้าหมายคือทำให้โค้ดอ่านง่าย ทดสอบง่าย และแก้ไขง่ายขึ้น

Refactoring ควรทำหลังจากมีหลักฐานว่าโปรแกรมทำงานถูกต้องแล้ว เช่น มี Test Case หรือผลทดสอบก่อนหน้า เพราะหากปรับโครงสร้างโดยไม่มี test จะไม่รู้ว่าการปรับนั้นทำให้พฤติกรรมเดิมเปลี่ยนหรือไม่

flowchart LR
  A[Code ทำงานได้] --> B[มี Test Case รองรับ]
  B --> C[Refactor ทีละจุด]
  C --> D[Run Test]
  D --> E{ผลลัพธ์ยังเหมือนเดิมหรือไม่}
  E -- ใช่ --> F[Commit / บันทึกการเปลี่ยนแปลง]
  E -- ไม่ใช่ --> G[ย้อนตรวจจุดที่เพิ่งแก้]
  G --> C

ตัวอย่างสัญญาณว่าโค้ดควรถูก Refactor:

สัญญาณ ผลกระทบ แนวทางปรับ
ฟังก์ชันยาวมาก อ่านและทดสอบยาก Extract Function
โค้ดซ้ำหลายจุด แก้จุดหนึ่งแล้วลืมอีกจุด Create Reusable Function
ชื่อตัวแปรไม่สื่อ อ่านแล้วเข้าใจยาก Rename Variable
เงื่อนไขซ้อนหลายชั้น เสี่ยงต่อ logic error Guard Clause หรือแยกฟังก์ชัน
โมดูลทำหลายหน้าที่ แก้แล้วกระทบหลายส่วน Split Module

6. Source Code Maintenance

Source Code Maintenance คือการดูแลโค้ดให้พร้อมต่อการพัฒนาระยะยาว ครอบคลุมการจัดโครงสร้างไฟล์ การตั้งชื่อ การเขียนเอกสาร การจัดการ dependency การติดตาม issue และการควบคุมเวอร์ชัน

flowchart TD
  A[Source Code Maintenance] --> B[Code Organization]
  A --> C[Documentation]
  A --> D[Dependency Management]
  A --> E[Issue Tracking]
  A --> F[Version Control]
  B --> B1[แยกไฟล์และโมดูลตามหน้าที่]
  C --> C1[README, comment ที่จำเป็น]
  D --> D1[บันทึก library และ version]
  E --> E1[บันทึก Bug และงานที่ต้องแก้]
  F --> F1[commit เป็นช่วงที่ตรวจสอบได้]

โค้ดที่ดูแลได้ดีควรตอบคำถามเหล่านี้ได้:

  1. โปรแกรมเริ่มทำงานจากไฟล์ใด
  2. ฟังก์ชันหรือโมดูลแต่ละส่วนรับผิดชอบอะไร
  3. ต้องติดตั้ง dependency อะไรบ้าง
  4. จะรันทดสอบอย่างไร
  5. หากมี Bug จะตามรอยจากข้อมูลใด

Workshop

ใช้ Debugger ตรวจโปรแกรมที่ทำงานผิด จากนั้นเขียน Test Case และปรับโครงสร้างโค้ดบางส่วนให้ดูแลรักษาง่ายขึ้น

รายละเอียดการเรียนรู้

ผู้เรียนฝึกไล่การทำงานของโปรแกรมจากอาการผิดปกติไปยังสาเหตุจริง โดยใช้ Breakpoint เพื่อหยุดโปรแกรมในจุดสำคัญ ใช้ Watch ตรวจค่าตัวแปร และใช้ Call Stack ดูลำดับการเรียกฟังก์ชัน หลังจากพบสาเหตุแล้วต้องอธิบายได้ว่าข้อผิดพลาดเกิดจากข้อมูลเข้า เงื่อนไข ลำดับคำสั่ง หรือการออกแบบฟังก์ชัน

ในส่วน Testing ผู้เรียนออกแบบ Test Case ทั้งกรณีปกติ กรณีขอบเขต และกรณีผิดพลาด แล้วเชื่อมโยงกับ Regression Test เพื่อป้องกันไม่ให้การแก้ไขครั้งใหม่ทำให้ความสามารถเดิมเสียหาย

แนวปฏิบัติ

  1. Reproduce ปัญหาให้ได้ก่อนแก้
  2. ตั้งสมมติฐานและตรวจด้วย Debugger
  3. แก้ไขเฉพาะสาเหตุ ไม่แก้แบบเดาสุ่ม
  4. เพิ่ม Test Case ที่ยืนยันว่าปัญหาถูกแก้แล้ว
  5. Refactor หลังโปรแกรมทำงานถูกต้อง

แบบฝึกหัด

เขียน Test Case อย่างน้อย 6 กรณีสำหรับฟังก์ชันคำนวณหรือประมวลผลข้อมูล พร้อมบันทึกผลก่อนและหลังแก้ Bug

รูปแบบการนำเสนอเพิ่มเติมตามแม่แบบ

แนวคิดหลัก (Key Concept): การแก้ Bug ที่ดีต้องเริ่มจากหลักฐาน ไม่ใช่การเดา ผู้เรียนต้องเชื่อมโยง อาการ (Symptom) ไปสู่ สาเหตุจริง (Root Cause) แล้วเพิ่ม Regression Test เพื่อป้องกันปัญหาซ้ำ

%%{init: {'theme': 'base', 'themeVariables': {
  'background': '#282828',
  'primaryColor': '#3c3836',
  'primaryTextColor': '#fbf1c7',
  'primaryBorderColor': '#fabd2f',
  'lineColor': '#83a598',
  'secondaryColor': '#504945',
  'tertiaryColor': '#665c54',
  'fontFamily': 'Arial'
}}}%%
flowchart LR
  A[Symptom
อาการผิดปกติ] --> B[Reproduce
ทำซ้ำได้] B --> C[Debug
ตรวจด้วยเครื่องมือ] C --> D[Root Cause
สาเหตุจริง] D --> E[Fix
แก้ไข] E --> F[Regression Test
ทดสอบกันปัญหาซ้ำ]

สมการประเมินความครอบคลุมของ Test Case

T=PR×100%

ตัวอย่างโค้ดทดลอง

#include <iostream>
using namespace std;

// ฟังก์ชันนี้ป้องกันการหารด้วยศูนย์ก่อนคำนวณค่าเฉลี่ย
double average(double total, int count) {
    if (count <= 0) {
        return 0; // invalid case: ไม่มีจำนวนข้อมูลให้หาร
    }
    return total / count;
}

int main() {
    // ตัวอย่างการใช้งานและใช้เป็น test case เบื้องต้น
    cout << average(100, 5) << endl;  // expected: 20
    cout << average(100, 0) << endl;  // expected: 0
    return 0;
}
รายการตรวจ คำถามสำหรับผู้เรียน
Reproduce ทำให้ Bug เกิดซ้ำได้หรือไม่
Debugger ใช้ Breakpoint, Watch หรือ Call Stack จุดใด
Test Case มี normal, boundary, invalid และ regression case หรือไม่
Refactor ปรับโค้ดแล้วผลลัพธ์เดิมยังถูกต้องหรือไม่

กลับรายวิชา