การทำ TDD อย่างถูกต้อง
22 Jun 2023 07:00
Written by: Yosapol Jitrak
หลายคนเอา 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 จะเป็นตามรูปด้านล่างนี้เลย
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
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 '';
};
export default (num: number): string => {
return '1';
};
รอบนี้ผ่านแล้ว เย้
คราวนี้เราต้องตัดสินใจต่อว่า เราอยากจะ Refactor เลยไหม หรือไปเขียน Test case ถัดไปต่อเลย
สมมติว่าผมยังคิดไม่ออกว่าจะ Refactor ยังไง ผมขอไปเขียน 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);
});
export default (num: number): string => {
if (num === 1) {
return '1';
}
return '2';
};
export default (num: number): string => {
return num.toString();
};
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);
});
export default (num: number): string => {
if (num === 3) {
return 'Fizz';
}
return num.toString();
};
ผ่านแล้วเย้
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);
});
export default (num: number): string => {
if (num === 3 || num === 6) {
return 'Fizz';
}
return num.toString();
};
export default (num: number): string => {
const isMultipleOfThree = num % 3 === 0;
if (isMultipleOfThree) {
return 'Fizz';
}
return num.toString();
};
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);
});
});
ผ่านฉลุย
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);
});
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();
};
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);
});
แน่นอน ต้องไม่ผ่าน
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();
};
อุ้ย ทำไม Test ไม่ผ่าน อันนี้ผมจงใจให้เห็นครับ ว่าการทำ TDD ช่วยเราตรวจสอบว่าสิ่งที่เรา Implement เข้าไปถูกต้องไหม ถ้าเราเจอโจทย์ที่ยากกว่านี้
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();
};
เย้ผ่านแล้ว
import fizzBuzz from './fizzBuzz';
Array.from({ length: 100 })
.map((_, i) => i + 1)
.map(fizzBuzz)
.forEach((result) => console.log(result));
จะได้ผลลัพท์ตามนี้: 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
ของแถมท้ายบทความ