Jitrak Blog

How to TDD correctly

การทำ TDD อย่างถูกต้อง

22 Jun 2023 07:00

Written by: Yosapol Jitrak

Tags:

TDD

Test-driven development

Unit test

Automation

หลายคนเอา TDD ไปทำกันแบบผิดจริง ๆ แหละ ทั้งเขียน Product Code ก่อน Test แต่บอกว่าทำ TDD (แบบนี้เรียกเขียน Automate test ก็พอ), บอกว่าต้องมี Test นะ, ต้อง Coverage กี่ % งั้น Pipeline ไม่ผ่าน โดยไม่ได้ศึกษา และเข้าใจถึงแก่นวิธีการที่มันควรจะเป็นมาดีพอ

ผมเริ่มรู้จัก TDD จาก Blog somkiat.cc ของพี่ปุ๋ย Somkiat Puisungnoen ส่วนการเข้าใจวิธีการเขียน เริ่มต้นจากการฝึกฝนที่ Geeky Base และได้เจอกับพี่ยอด Pallat Anchaleechamaikorn เจ้าของบทความ ‘กว่าจะ WoW!!! TDD’ ส่วนอาจารย์คนสอนหลักนั้นผมจำไม่ได้ เพราะรู้สึกว่าทุกคนตอนนั้นเป็นอาจารย์ผมหมด หลังจากนั้นด้วยเหตุผลที่เพิ่งลาออกจากงานที่เก่า ผมได้มีเวลาฝึกฝนอยู่ที่บ้านนานหลายเดือน (เมษายน 2015 - มิถุนายน 2015) โดยฝึกฝันวันละไม่กี่ชม. หลังจากที่ได้เอามาใช้กับงานจริง ก็มีคำถามผุดออกมาอีกเพียบ ทั้งควรจะทำละเอียดแค่ไหนดี ตรงนี้ควรจะเป็น Unit test หรือเปล่า ใช้ Test double ช่วยได้ไหม หรือยกขึ้นเป็น Integration test ดี งานพวกนี้ต้องอาศัยการฝึกฝน และประสบการณ์เท่านั้น ถึงจะเจอจุด Wow อย่างที่พี่ยอดกล่าวไว้ ไม่ใช่มาถึง On the job training โดยไม่ได้ศึกษาวิธีการที่มันควรจะเป็น และฝึกฝนเพิ่มเติมเลย พี่รูฟ Twin Panitsombat เคยกล่าวเปรียบเทียบระหว่างการทำ Software กับ นักฟุตบอลมืออาชีพ และเตะบอลอบต.หลังเลิกงานไว้

บ่นมาซะเยอะ เรามาเข้าเรื่องดีกว่า การที่เราจะทำ TDD (Test-driven development) นั้น ตามชื่อเลย ก็คือ Test-driven ก็เลยจะต้องเขียน Test ก่อนเสมอ ย้ำว่าเสมอนะครับ ไม่มีข้อยกเว้น เมื่อเขียน Test เสร็จแล้วต้อง Run test ให้มัน Failed ก่อน โดยที่เรายังไม่ต้อง Implement logic เข้าไปที่ Product code หลังจากนั้นค่อยไปเขียน Product code ต่อ ให้มัน Run test ผ่าน แบบง่าย ๆ ไม่ต้องคิดอะไรมาก (Make it’s simple) แล้วหลังจากนั้น ถ้าเห็นว่าสามารถ Refactor ได้ก็ทำได้เลยครับ แต่ถ้ายัง ก็ไม่เป็นไร วนกลับไปเขียน Test case ต่อ แล้วก็ Loop เดิมแบบนี้ไปเรื่อย ๆ ครับ โดย Flow จะเป็นตามรูปด้านล่างนี้เลย

TDD Credit image: Red, Green, Refactor!

คราวนี้เราลองมาดูตัวอย่างการทำ TDD กับตอน Code มันทำยังไง
ขอยกตัวอย่างเป็น FizzBuzz โจทย์ง่าย ๆ ด้วย TypeScript และ Run test ด้วย Jest โดยโจทย์มีเนื้อความตามนี้เลยครับ

Write a program that prints the numbers from 1 to 100. But for multiples of three print “Fizz” instead of the number and for the multiples of five print “Buzz”. For numbers which are multiples of both three and five print “FizzBuzz”.

Sample output:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
… etc up to 100
Credit: cyber-dojo.org

  1. เริ่มแรกเราเขียน Test case ก่อนนะครับ
import fizzBuzz from './fizzBuzz';

describe('FizzBuzz', () => {
  it(`should be '1' when input is 1`, () => {
    // Arrange
    const input = 1;
    const expected = '1';

    // Act
    const actual = fizzBuzz(input);

    // Assert
    expect(actual).toBe(expected);
  });
});

ตอนนี้เราจะยัง Run test ไม่ได้ เพราะว่ายังไม่มี fizzBuzz.ts ก็ต้องไปเขียนก่อน โดยที่เรายังไม่ Implement logic แค่เขียน Function ให้มัน Compile ได้ก่อน

export default (num: number): string => {
  return '';
};
  1. Run test เลยครับ

1st test failed

  1. คราวนี้เราก็มาแก้ไข Code ให้มัน Test ผ่านแบบง่ายที่สุดครับ
export default (num: number): string => {
  return '1';
};
  1. Run test อีกครั้ง

1st test passed

รอบนี้ผ่านแล้ว เย้
คราวนี้เราต้องตัดสินใจต่อว่า เราอยากจะ Refactor เลยไหม หรือไปเขียน Test case ถัดไปต่อเลย
สมมติว่าผมยังคิดไม่ออกว่าจะ Refactor ยังไง ผมขอไปเขียน Test case ถัดไปต่อเลย

  1. เขียน Test case ถัดไปครับ
it(`should be '2' when input is 2`, () => {
  // Arrange
  const input = 2;
  const expected = '2';

  // Act
  const actual = fizzBuzz(input);

  // Assert
  expect(actual).toBe(expected);
});
  1. Runt test ครับ จะต้องไม่ผ่าน เพราะเรายังไม่ได้แก้ Product code

2nd test failed

  1. แก้ไข Code ให้มันผ่านครับ แบบง่าย ๆ
export default (num: number): string => {
  if (num === 1) {
    return '1';
  }
  return '2';
};
  1. Run test คร๊าบบบบ ต้องผ่านแล้ว

2nd test passed

  1. ตอนนี้คิดว่าน่าจะ Refactor ได้แล้ว
export default (num: number): string => {
  return num.toString();
};
  1. อย่าลืม Run test อีกครั้งครับหลัง Refactor code ว่ายังผ่านเหมือนเดิมไหม

2nd test passed again

  1. เขียน Test case ถัดไปครับ เอาแค่เป็นเลข 3 แล้วต้องได้ Fizz ก่อนก็ได้ครับ
it(`should be 'Fizz' when input is 3`, () => {
  // Arrange
  const input = 3;
  const expected = 'Fizz';

  // Act
  const actual = fizzBuzz(input);

  // Assert
  expect(actual).toBe(expected);
});
  1. Run test ครับ ซึ่งจะไม่ผ่านแน่นอน เพราะว่าเรายังไม่ได้แก้ไข Product code

3rd test failed

  1. แก้ไข Code ให้ผ่านง่าย ๆ ครับ
export default (num: number): string => {
  if (num === 3) {
    return 'Fizz';
  }
  return num.toString();
};
  1. Run test เลยครับ

3rd test passed

ผ่านแล้วเย้

  1. ตอนนี้ผมจะ Focus เคส Fizz ก่อน เลยเพิ่ม Test case 6 จะต้องได้ Fizz มาต่อเลย
it(`should be 'Fizz' when input is 6`, () => {
  // Arrange
  const input = 6;
  const expected = 'Fizz';

  // Act
  const actual = fizzBuzz(input);

  // Assert
  expect(actual).toBe(expected);
});
  1. Run test ครับ

4th test failed

  1. แก้ไข Code ครับ
export default (num: number): string => {
  if (num === 3 || num === 6) {
    return 'Fizz';
  }
  return num.toString();
};
  1. Run test จะต้องผ่านแล้วรอบนี้

4th test passed

  1. เห็น Pattern แล้ว Refactor ได้
export default (num: number): string => {
  const isMultipleOfThree = num % 3 === 0;
  if (isMultipleOfThree) {
    return 'Fizz';
  }
  return num.toString();
};
  1. Run test หลัง Refactor ครับ

4th test passed again

  1. ตอนนี้ผมเริ่มคิดว่าอยากจะยุบ Test case แล้ว เลย Refactor เป็นตามนี้ครับ
import fizzBuzz from './fizzBuzz';

describe('FizzBuzz', () => {
  it.each`
    input | expected
    ${1}  | ${'1'}
    ${2}  | ${'2'}
  `(
    `should be $expected when input is not match rule ($input)`,
    ({ input, expected }: { input: number; expected: string }) => {
      // Act
      const actual = fizzBuzz(input);

      // Assert
      expect(actual).toBe(expected);
    },
  );

  it.each`
    input
    ${3}
    ${6}
    ${9}
  `(`should be 'Fizz' when input is isMultipleOfThree ($input)`, ({ input }: { input: number }) => {
    // Arrange
    const expected = 'Fizz';

    // Act
    const actual = fizzBuzz(input);

    // Assert
    expect(actual).toBe(expected);
  });
});
  1. Refactor test แล้ว อย่าลืม Run นะครับ

5th test passed

ผ่านฉลุย

  1. เราไปกันต่อที่ Test case 5, 10 และ 20 ต้องได้ Buzz ครับ ตอนนี้ยุบได้ เพราะเราเห็น Pattern แล้ว
it.each`
  input
  ${5}
  ${10}
  ${20}
`(`should be 'Buzz' when input is isMultipleOfFive ($input)`, ({ input }: { input: number }) => {
  // Arrange
  const expected = 'Buzz';

  // Act
  const actual = fizzBuzz(input);

  // Assert
  expect(actual).toBe(expected);
});
  1. Run test ครับ แน่นอนว่าจะต้องไม่ผ่าน

6th test failed

  1. แก้ไข Product code ให้ผ่าน
export default (num: number): string => {
  const isMultipleOfThree = num % 3 === 0;
  const isMultipleOfFive = num % 5 === 0;
  if (isMultipleOfThree) {
    return 'Fizz';
  }
  if (isMultipleOfFive) {
    return 'Buzz';
  }
  return num.toString();
};
  1. Run test อีกรอบครับ

6th test passed

  1. คราวนี้เราจะมีเขียน Test case 15, 30 และ 45 จะต้องได้ FizzBuzz กันครับ
it.each`
  input
  ${15}
  ${30}
  ${45}
`(`should be 'FizzBuzz' when input is isMultipleOfFifteen ($input)`, ({ input }: { input: number }) => {
  // Arrange
  const expected = 'FizzBuzz';

  // Act
  const actual = fizzBuzz(input);

  // Assert
  expect(actual).toBe(expected);
});
  1. ตามสูตรเดิมครับ Run test ก่อน

7th test failed

แน่นอน ต้องไม่ผ่าน

  1. Implement code ครับ
export default (num: number): string => {
  const isMultipleOfThree = num % 3 === 0;
  const isMultipleOfFive = num % 5 === 0;
  const isMultipleOfFifteen = isMultipleOfThree && isMultipleOfFive;
  if (isMultipleOfThree) {
    return 'Fizz';
  }
  if (isMultipleOfFive) {
    return 'Buzz';
  }
  if (isMultipleOfFifteen) {
    return 'FizzBuzz';
  }
  return num.toString();
};
  1. Run test ครับ

7th test failed again

อุ้ย ทำไม Test ไม่ผ่าน อันนี้ผมจงใจให้เห็นครับ ว่าการทำ TDD ช่วยเราตรวจสอบว่าสิ่งที่เรา Implement เข้าไปถูกต้องไหม ถ้าเราเจอโจทย์ที่ยากกว่านี้

  1. แก้ไข Product code อีกรอบ
export default (num: number): string => {
  const isMultipleOfThree = num % 3 === 0;
  const isMultipleOfFive = num % 5 === 0;
  const isMultipleOfFifteen = isMultipleOfThree && isMultipleOfFive;
  if (isMultipleOfFifteen) {
    return 'FizzBuzz';
  }
  if (isMultipleOfThree) {
    return 'Fizz';
  }
  if (isMultipleOfFive) {
    return 'Buzz';
  }
  return num.toString();
};
  1. Run test อีกรอบ

7th test passed

เย้ผ่านแล้ว

  1. ถึงเวลาเรียกใช้ Function fizzBuzz จริง ๆ แล้ว
import fizzBuzz from './fizzBuzz';

Array.from({ length: 100 })
  .map((_, i) => i + 1)
  .map(fizzBuzz)
  .forEach((result) => console.log(result));
  1. Build แล้ว Run โล้ด

จะได้ผลลัพท์ตามนี้: https://bit.ly/3XhEQ2L

pnpm build && pnpm start

> [email protected] build /Volumes/Backup/Works/teachs/fizz-buzz
> tsc


> [email protected] start /Volumes/Backup/Works/teachs/fizz-buzz
> node dist/index.js

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
31
32
Fizz
34
Buzz
Fizz
37
38
Fizz
Buzz
41
Fizz
43
44
FizzBuzz
46
47
Fizz
49
Buzz
Fizz
52
53
Fizz
Buzz
56
Fizz
58
59
FizzBuzz
61
62
Fizz
64
Buzz
Fizz
67
68
Fizz
Buzz
71
Fizz
73
74
FizzBuzz
76
77
Fizz
79
Buzz
Fizz
82
83
Fizz
Buzz
86
Fizz
88
89
FizzBuzz
91
92
Fizz
94
Buzz
Fizz
97
98
Fizz
Buzz

จบไปแล้วนะครับ กับการทำ TDD อย่างถูกวิธีด้วยโจทย์ FizzBuzz แต่จริง ๆ Code นี้ก็ยังมีปัญหาอยู่ เพราะถ้าในอนาคตมี Feature เพิ่มเข้ามา อย่างหาร 7 ลงตัว จะต้องได้ Bang เข้าไปในผลลัพท์ด้วย จะเกิด if เพิ่มเข้ามาใน Code อีก และผิดหลักการ Open-Closed principle ซึ่งบทความนี้จะไม่พูดถึง สามารถไปดูต่อได้ที่ Kata:: Open Closed Principle ที่พี่ปุ้ยSomkiat Puisungnoen ได้ทำ Slide เอาไว้เมื่อชาติปางก่อน สามารถไปตามเสพกันต่อได้

Source code: https://github.com/Eji4h/fizz-buzz

ของแถมท้ายบทความ