NM
← blog

TDD คืออะไร

·5 min read·testingdevelopmentthai

เกริ่นนำหลังจากหายไปเกือบเดือน

สวัสดีครับทุกคน วันนี้ผมจะมาเล่าเรื่องเกี่ยวกับ Test-Driven Development (TDD) กันครับ ก็หายไปนานเลย(เกือบๆ 1 เดือน) แต่ก็ยังไม่ลืมเขียนบล็อกนะครับ

พอดีว่านั่งหางานแบบไฟลุกท่วม ก็เลยไม่ได้เว้นเวลามาเขียนบล็อกเลย ก็เลยมาอัพเดทบล็อกกันบ้าง วันนี้เราจะมาเริ่มต้นด้วย Test-Driven Development (TDD) กันครับ

พอดีที่ทำงานนั้นให้ความสำคัญกับ Test First (เดี๋ยวผมกับมาเขียนทีหลังฮ่าๆ) และ TDD อยู่ และผมที่เคยได้ยินเรื่องนี้เป็นครั้งแรกเพราะเคยเขียนโค้ด แบบเขียนโค้ดแล้วค่อยเขียน test มาตลอดเวลา 5555 ก็ถือว่าเป็นการเรียนรู้อะไรใหม่ๆน่ะครับ

โอเคครับ เกริ่นมาสักพักแล้วเรามาเริ่มเรื่องกันเลยครับ

Test-Driven Development (TDD) คืออะไร

เป็นแนวทางการพัฒนาที่เน้นการเขียน test ก่อนเขียนโค้ดจริง โดยมีขั้นตอนหลัก 3 ขั้นตอนที่เรียกว่า Red-Green-Refactor ดังนี้

TDD

  • Red: เขียน test ก่อน แล้วรันให้ล้มเหลว (เพราะยังไม่มีโค้ดจริงรองรับ)
  • Green: เขียนโค้ดให้ผ่าน test
  • Refactor: ปรับปรุงโค้ดให้ดีขึ้นโดยยังคงผ่าน test

การเริ่มต้นแบบ TDD นั้นก็เหมือนกับการทำอะไรสักอย่างในชีวิตประจำวัน เราต้องกำหนดเป้าหมายก่อน (Red) พยายามทำให้ผ่านเป้าหมายนั้น (Green) และปรับปรุงให้ดีขึ้นเรื่อยๆ (Refactor)

โอเคครับ เราอาจจะเข้าใจ Concept คร่าวๆแล้ว แต่ว่าเรามาดูตัวอย่างกันดีกว่า

ตัวอย่างการเขียนโค้ดด้วย TDD

ก่อนอื่นเลยเดี๋ยวผมจะแนะนำเครื่องมือที่ผมจะใช้กันก่อนเลยครับ

  • Bun ใช้เป็น Runtime ของ TypeScript ที่เราจะใช้ในการรันโค้ดกันครับ (เหมือนกับ Node.js)
  • Bun Test ใช้เป็น Test Runner ของ TypeScript ที่เราจะใช้ในการรัน Test กันครับ (เหมือนกับ Jest)

โดยปกติแล้วเนี่ยเราอาจจะใช้ Jest ในการเขียน Test แต่ว่าเนื่องจากผมไม่อยากใช้เวลา Setup นานก็เลยใช้ Bun Test แทน

โอเคครับ นี้คือโจทย์ของเราวันนี้ครับ ระบบคำนวณค่าอาหาร + ค่าทิป

  • รับค่าอาหาร (billAmount) และเปอร์เซ็นต์ทิป (tipRate)
  • คำนวณ ยอดรวม ตามสูตร:
    • billAmount + (billAmount × tipRate ÷ 100)
  • ถ้าไม่ได้เลือกเปอร์เซ็นต์ทิป ให้ใช้ค่าเริ่มต้น 10%

เรามาเริ่มที่เขียน Test กันก่อนเลยครับ

typescript
import { calculateTotal } from "./calculateTotal";
 
describe("Calculate Tip Success Case", () => {
  it("should return total amount including 20% tip", () => {
    const bill = 100;
    const tipPercentage = 20;
    const result = 120;
 
    const expected = calculateTip(bill, tipPercentage);
 
    expect(expected).toBe(result);
  });
});

เราเริ่มจากการเขียน Test ก่อนเสมอ โดยเราจะเขียน Test ที่เราคาดหวังได้ก่อน แล้วจึงเขียนโค้ดให้ผ่าน Test นั้น

typescript
export function calculateTotal(billAmount: number, tipRate: number): number {
  return billAmount + (billAmount * tipRate) / 100;
}

ลองรัน Test ดูกันครับ โดยใช้คำสั่ง bun test

ถ้า Test ผ่านเราจะได้ผลลัพธ์ดังนี้

bash
 
bun test v1.1.43 (76800b04)
 
src/calculatetip/tip.test.ts:
 Calculate Tip Success Case > should return total amount including 20% tip
 
 1 pass
 0 fail
 1 expect() calls
Ran 1 tests across 1 files. [9.00ms]

เราจะเขียนโค้ดให้ผ่าน Test ที่เราเขียนไว้ก่อนหน้านี้ แล้วจึง Refactor โค้ดให้ดีขึ้น

typescript
export function calculateTotal(billAmount: number, tipRate: number): number {
  const tipAmount = (billAmount * tipRate) / 100;
  const total = billAmount + tipAmount;
 
  return total;
}

เราจะ Refactor โค้ดให้ดีขึ้นโดยยังคงผ่าน Test ที่เราเขียนไว้ก่อนหน้านี้

ต่อไปเราจะมาลองเพิ่ม Test ใหม่เพื่อเช็คว่าเมื่อเราไม่ใส่เปอร์เซ็นต์ทิปเข้าไปและถ้าค่าอาหารเป็น 0 จะคำนวณถูกต้องหรือไม่

typescript
describe("Calculate Tip Default Case", () => {
  it("should return total amount including 10% tip by default", () => {
    const bill = 100;
    const result = 110;
 
    const expected = calculateTip(bill);
 
    expect(expected).toBe(result);
  });
 
  it("should return 0 if bill is 0", () => {
    const bill = 0;
    const result = 0;
 
    const expected = calculateTip(bill);
 
    expect(expected).toBe(result);
  });
});

เราจะเขียน Test ใหม่เพื่อเช็คว่าเมื่อเราไม่ใส่เปอร์เซ็นต์ทิปเข้าไป จะคำนวณถูกต้องหรือไม่ ลองรัน Test ดูกันครับ

bash
 
bun test v1.1.43 (76800b04)
 
src/calculatetip/tip.test.ts:
 Calculate Tip Success Case > should return total amount including 20% tip
25 |     const bill = 100;
26 |     const result = 110;
27 |
28 |     const expected = calculateTip(bill);
29 |
30 |     expect(expected).toBe(result);
                          ^
error: expect(received).toBe(expected)
 
Expected: 110
Received: NaN
 
      at <anonymous>
 Calculate Tip Success Case > should use default tip percentage of 10% if not provided
 Calculate Tip Success Case > should return 0 if bill is 0
 
 2 pass
 1 fail
 3 expect() calls
Ran 3 tests across 1 files. [9.00ms]
 

จากผลลัพธ์ที่ได้เราจะเห็นว่า Test ที่เราเขียนไว้ก่อนหน้านี้มี Test ที่ผ่านและ Test ที่ไม่ผ่าน เราจะ Refactor โค้ดให้ผ่าน Test ที่เราเขียนไว้ก่อนหน้านี้

typescript
export function calculateTotal(
  billAmount: number,
  tipRate: number = 10
): number {
  const tipAmount = (billAmount * tipRate) / 100;
  const total = billAmount + tipAmount;
 
  return total;
}

ลองรัน Test อีกครั้งดูกันครับ

bash
bun test v1.1.43 (76800b04)
 
src/calculatetip/tip.test.ts:
 Calculate Tip Success Case > should return total amount including 20% tip
 Calculate Tip Success Case > should use default tip percentage of 10% if not provided
 Calculate Tip Success Case > should return 0 if bill is 0
 
 3 pass
 0 fail
 3 expect() calls
Ran 3 tests across 1 files. [9.00ms]

เราจะเห็นว่า Test ทั้งหมดผ่าน และโค้ดของเราผ่าน Test ทั้งหมด

ต่อไปเราจะมาลองสร้าง Alternative Test ใหม่เพื่อเช็คว่าเมื่อเราใส่ค่าที่ผิดเข้าไป จะคำนวณถูกต้องหรือไม่

typescript
describe("Calculate Tip Failure Case", () => {
  it("should throw an error if bill is negative", () => {
    const bill = -100;
    const tipPercentage = 20;
 
    expect(() => calculateTip(bill, tipPercentage)).toThrow();
  });
 
  it("should throw an error if tip percentage is negative", () => {
    const bill = 100;
    const tipPercentage = -20;
 
    expect(() => calculateTip(bill, tipPercentage)).toThrow();
  });
});

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

bash
bun test v1.1.43 (76800b04)
 
src/calculatetip/tip.test.ts:
 Calculate Tip Success Case > should return total amount including 20% tip
 Calculate Tip Success Case > should use default tip percentage of 10% if not provided
 Calculate Tip Success Case > should return 0 if bill is 0
44 | describe("Calculate Tip Failure Case", () => {
45 |   it("should throw an error if bill is negative", () => {
46 |     const bill = -100;
47 |     const tipPercentage = 20;
48 |
49 |     expect(() => calculateTip(bill, tipPercentage)).toThrow();
                                                         ^
error: expect(received).toThrow()
 
Received function did not throw
Received value: -120
 
      at <anonymous>
 Calculate Tip Failure Case > should throw an error if bill is negative
51 |
52 |   it("should throw an error if tip percentage is negative", () => {
53 |     const bill = 100;
54 |     const tipPercentage = -20;
55 |
56 |     expect(() => calculateTip(bill, tipPercentage)).toThrow();
                                                         ^
error: expect(received).toThrow()
 
Received function did not throw
Received value: 80
 
      at <anonymous>
 Calculate Tip Failure Case > should throw an error if tip percentage is negative
 
 3 pass
 2 fail
 5 expect() calls
Ran 5 tests across 1 files. [10.00ms]
 

จากผลลัพธ์ที่ได้เราจะเห็นว่า Test ที่เราเขียนเพิ่มเข้ามาไม่ผ่าน และเราจะ Refactor โค้ดให้ผ่าน Test ที่เราเขียนไว้ก่อนหน้านี้

typescript
export function calculateTotal(
  billAmount: number,
  tipRate: number = 10
): number {
  if (billAmount < 0 || tipRate < 0) {
    throw new Error("Bill amount and tip rate must be positive");
  }
 
  const tipAmount = (billAmount * tipRate) / 100;
  const total = billAmount + tipAmount;
 
  return total;
}

ลองรัน Test อีกครั้งดูกันครับ

bash
bun test v1.1.43 (76800b04)
 
src/calculatetip/tip.test.ts:
 Calculate Tip Success Case > should return total amount including 20% tip
 Calculate Tip Success Case > should use default tip percentage of 10% if not provided
 Calculate Tip Success Case > should return 0 if bill is 0
 Calculate Tip Failure Case > should throw an error if bill is negative
 Calculate Tip Failure Case > should throw an error if tip percentage is negative
 
 5 pass
 0 fail
 5 expect() calls
Ran 5 tests across 1 files. [10.00ms]

เราจะเห็นว่า Test ทั้งหมดผ่าน และโค้ดของเราผ่าน Test ทั้งหมด เย้ๆ

นี้คือตาราง Test ทั้งหมดที่เราเขียนไว้ครับ

Test CaseInputExpected Output
✅ คำนวณยอดรวมถูกต้อง100, 20120
✅ คำนวณยอดรวมถูกต้อง100110
✅ คำนวณยอดรวมถูกต้อง00
✅ คำนวณผิดพลาดเมื่อใส่ค่าผิด-100, 20Error
✅ คำนวณผิดพลาดเมื่อใส่ค่าผิด100, -20Error

ผมกับ TDD และสรุป

เราจะเห็นว่าการเขียน Test ก่อนเขียนโค้ดจริงๆนั้นจะช่วยให้เราเขียนโค้ดได้ดีขึ้น และเราสามารถ Refactor โค้ดได้ง่ายขึ้น และเราสามารถเช็คว่าโค้ดของเราทำงานถูกต้องหรือไม่

สำหรับตัวผมแล้วผมไม่ค่อยได้เขียน Test นัก คงปรับ mindset ให้เหมาะกับ TDD ด้วยวิธีนี้

  • เขียน test ก่อนเขียนโค้ดจริง
  • ทำให้ test เข้าใจง่าย (ไม่ซับซ้อนเกินไป)
  • ทดสอบเฉพาะ logic สำคัญ (ไม่ต้องทดสอบทุกอย่าง)
  • ใช้ Mocks & Stubs เมื่อจำเป็น

ถ้าคุณยังไม่เคยเขียน Test ก่อนเขียนโค้ด ลองเริ่มต้นด้วย TDD ดูนะครับ แล้วก็มาเขียนโค้ดกัน เราจะเจอกันใหม่ในบล็อคต่อไปนะครับ ขอบคุณที่มาอ่านบล็อคผมครับ