Saladpuk.com
Search…
👦
Test-First Design
โชว์พลังที่แท้จริงของ Test-Driven Development (TDD) ด้วยเกม OX
รอบนี้เราจะมาดูพลังที่แท้จริงในการใช้ Test-Driven Development (TDD) ว่ามันจะมาช่วยเรื่อง Code Design ได้ยังไง!! โดยใช้เกม OX เป็นโจทย์ในการเขียนโปรแกรม เพื่อให้เพื่อนๆได้ลองเอาไปศึกษาปรับใช้ดู แล้วจะรู้ว่าการเขียนเทสก่อนมันทรงพลังขนาดไหน
ความเข้าใจผิดกับการเขียนเทส Developer หลายๆคนจะบ่นว่า "การเขียนเทสทำให้งานช้า" เลยหลีกเลี่ยงไม่ยอมเขียนเทสก่อนเขียนโค้ด แต่ในขณะที่ครูระดับระดับตำนานและนักเขียนโค้ดระดับเทพทุกคนเขาเขียนเทสกันก่อนเขียนโค้ดทุกคน และงานส่วนใหญ่ก็ชี้ออกมาแล้วว่า "การเขียนเทสก่อนสุดท้ายมันเร็วกว่าการไม่เขียนเทสเยอะมาก"

🎮 Game Start !

1.Design scenarios

🧔 หลายๆคนพอรู้ว่าจะเขียนเกม OX ก็จะนึกภาพ class diagram หรือโค้ดต่างๆไว้ในหัวกันแล้วใช่ไหมล่ะ ?
Class Diagram แบบคิดเร็วๆ ไม่ต้องไปนั่งวิเคราะห์มันหรอกอ่านต่อเลย
🧔 แต่ปรกติการทำ Test First ขั้นตอนแรกเราจะไม่เขียนโค้ด และไม่เขียน Diagrams กันนะ!! แต่สิ่งที่เราจะเขียนเป็นตัวแรกคือสิ่งที่เขาเรียกกันว่า เทสเคส หรือ Scenarios นั่นเอง ซึ่งเทสเคสมันแบ่งออกเป็น 3 กลุ่มง่ายตามนี้
    1.
    กลุ่มปรกติที่เห็นได้บ่อยๆ (Normal cases)
    2.
    กลุ่มที่นานๆจะเจอที (Alternative cases)
    3.
    กลุ่มที่เกิดสถานะการณ์แปลกๆในในโปรแกรม (Exception cases)
🧔 ถัดมาเราจะต้องคิดเหตุการณ์ทั้งหมดที่จะเกิดขึ้นกับเกมของเราแล้วเอาไปใส่ในแต่ละกลุ่ม ซึ่งเหตุการณ์ที่จะใส่จะต้องบอกรายละเอียดตั้งแต่ต้นจนจบว่าจะทำอะไรและเกิดผลลัพท์อะไรด้วย มาเดี๋ยวจะลองใส่ตัวอย่างกลุ่มแรกให้
1
กลุ่มปรกติที่เห็นได้บ่อยๆ (Normal cases)
2
------
3
1.ลง X ลงไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
4
2.ลง O ลงไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
Copied!
🧔 ข้อดีในการทำแบบนี้คือเราสามารถเห็นข้อผิดพลาดได้เลยโดยที่ยังไม่ต้องเขียนโค้ดด้วยซ้ำ เช่น กฎิกาของเกม OX จริงๆจะต้องให้ X ลงก่อน ดังนั้นกรณีที่ 2 จะต้องไม่มี เพราะ O ลงก่อนไม่ได้! ดังนั้นก็ลบข้อ 2 ออกซะ
1
กลุ่มปรกติที่เห็นได้บ่อยๆ (Normal cases)
2
------
3
1.ลง X ลงไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
Copied!
ป้องกันการเข้าใจผิดก่อนที่จะเขียนโค้ด คุณคิดว่าไปไล่อ่านโค้ดที่เขาเขียนเพื่อตรวจว่าคนเขียนเข้าใจงานถูกหรือเปล่าได้ง่ายไหม? เมื่อเทียบกับไปไล่ตรวจจากเทสเคสก่อน อันไหนง่ายกว่ากัน ?
🧔 ตรงนี้เราจะคิดเหตุการณ์ของ กลุ่มปรกติที่เห็นได้บ่อยๆ กันก่อน เพราะคนใช้จะต้องเจอเคสนี้บ่อยสุดราวๆ 80% ดังนั้นถ้าเราไล่เก็บเคสนี้ก่อน มันหมายความว่าน่าจะไม่มี bug กับงานพื้นฐานของแอพเราแล้วนั่นเอง ปะลองไล่ดูว่ามีไรบ้าง
1
กลุ่มปรกติที่เห็นได้บ่อยๆ (Normal cases)
2
------
3
1.ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
4
2.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
5
3.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
6
4.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
7
5.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
8
6.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว แต่ O ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
9
7.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว และ X ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า X ชนะพร้อมกับเพิ่มแต้มให้ X 1 คะแนน
10
8.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว และ O ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า O ชนะ พร้อมกับเพิ่มแต้มให้ O 1 คะแนน
11
9.ลง X ไปในช่องว่างซึ่งบนกระดานมี X 4 ตัวและ O 4 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ และแจ้งว่าเกมเสมอ
Copied!
ไม่ต้องนั่งคิดให้มันครบทุกเคสก็ได้นะ เอาแค่ที่นึกออกก็พอ คิดออกเมื่อไหร่ค่อยมาเพิ่มอีกหลังก็ได้
🧔 เราก็จะได้เทสเคสของกรณีที่เกิดขึ้นบ่อยๆออกมาราวๆด้านบนนี้ ถัดไปเราก็จะลองมาคิด กลุ่มนานๆจะเจอที กันบ้าง เพราะมันจะเป็นการเพิ่มความมั่นใจว่าถ้าเกิดเคสที่นานๆจะเจอที อย่างน้อยมันก็จะทำงานได้นั่นเอง ซึ่งพอไปคิดคร่าวๆมาก็น่าจะได้ราวๆนี้ (ไม่ต้องรีดสมองคิดให้คลุมทั้งหมดก็ได้ มาเติมเอาทีหลังเช่นเคยก็โอเคนะ)
1
กลุ่มที่นานๆจะเจอที (Alternative cases)
2
-----
3
1.ลง X ไปในช่องที่ไม่ว่าง ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ X อยู่
4
2.ลง O ไปในช่องที่ไม่ว่าง ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ O อยู่
Copied!
🧔 ถัดไปก็ กลุ่มที่เกิดสถานะการณ์แปลกๆในในโปรแกรม ซึ่งจะเพิ่มความมั่นใจว่าถ้าเจออะไรแปลกๆ อย่างน้อยเราแอพเราก็น่าจะรับมือได้ในระดับนึงนั่นเอง ซึ่งคิดคร่าวๆก็จะได้ราวๆนี้
1
กลุ่มที่เกิดสถานะการณ์แปลกๆในในโปรแกรม
2
-----
3
1.ลง X ไปในช่องว่างซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบไม่ให้ลงพร้อมแจ้งเตือน และสลับเป็นตาของ O
4
2.ลง X ไปในช่องที่ไม่มีอยู่ในกระดาน ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ X อยู่
Copied!
🧔 จากที่ทำมาทั้งหมดเราก็จะได้ เทสเคส ของตัวโปรแกรมอย่างง่ายๆออกมาแล้ว ซึ่งหน้าตาก็ประมาณนี้
1
กลุ่มปรกติที่เห็นได้บ่อยๆ (Normal cases)
2
-----
3
1.ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
4
2.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
5
3.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
6
4.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
7
5.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
8
6.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว แต่ O ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
9
7.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว และ X ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า X ชนะพร้อมกับเพิ่มแต้มให้ X 1 คะแนน
10
8.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว และ O ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า O ชนะ พร้อมกับเพิ่มแต้มให้ O 1 คะแนน
11
9.ลง X ไปในช่องว่างซึ่งบนกระดานมี X 4 ตัวและ O 4 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ และแจ้งว่าเกมเสมอ
12
13
กลุ่มที่นานๆจะเจอที (Alternative cases)
14
-----
15
10.ลง X ไปในช่องที่ไม่ว่าง ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ X อยู่
16
11.ลง O ไปในช่องที่ไม่ว่าง ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ O อยู่
17
18
กลุ่มที่เกิดสถานะการณ์แปลกๆในในโปรแกรม
19
-----
20
12.ลง X ไปในช่องว่างซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบไม่ให้ลงพร้อมแจ้งเตือน และสลับเป็นตาของ O
21
13.ลง X ไปในช่องที่ไม่มีอยู่ในกระดาน ระบบไม่ให้ลงพร้อมแจ้งเตือน และยังคงเป็นตาของ X อยู่
Copied!

2.Testable code

🧔 ถัดมาเราก็จะเอา เทสเคส จากขั้นตอนที่ 1 มาแปลงเป็นโค้ดที่เอาไว้ทดสอบโปรแกรมของเรา โดยเราจะเอามาทีละข้อ ไล่จากบนลงล่างเลย ดังนั้นข้อที่ 1 ของเราคือ
1.ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
เราก็จะเขียนเทสให้กับข้อนี้ก่อน ได้ตามนี้ (ในตัวอย่างผมใช้ภาษา C# กับ xUnit นะครับ)
BoardGameTest.cs
BoardGame.cs
1
[Fact(DisplayName = "ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
2
public void PlaceXWhenBoardIsEmpty()
3
{
4
var boardGame = new BoardGame();
5
var canPlace = boardGame.Place("X", 0, 0);
6
Assert.True(canPlace);
7
Assert.Equal("O", boardGame.CurrentTurn);
8
Assert.Null(boardGame.GetWinner());
9
}
Copied!
1
public string CurrentTurn { get; set; }
2
3
public bool Place(string symbol, int row, int column)
4
{
5
throw new NotImplementedException();
6
}
7
8
public string GetWinner()
9
{
10
throw new NotImplementedException();
11
}
Copied!
ตัวอย่างโค้ดมันจะมีชื่อไฟล์อยู่นะ ให้กดชื่อไฟล์เพื่อนดูโค้ดในไฟล์นั้น + ตัวอย่างโค้ดผมจะเอาเฉพาะที่สำคัญมาให้ดูนะ จะได้ focus ได้ถูกจุดไม่งั้น งง ตายเลย
🧔 ซึ่งพอ run test มันก็จะ Fail ครับ เพราะโค้ดใน BoardGame.cs ยังไม่ได้เขียนอะไรเลย ดังนั้นเราก็จะเขียนแบบง่ายที่สุดเพื่อให้มันผ่านเทส เราก็จะได้โค้ดประมาณนี้
BoardGame.cs
1
public string CurrentTurn { get; set; }
2
3
public bool Place(string symbol, int row, int column)
4
{
5
CurrentTurn = "O";
6
return true;
7
}
8
9
public string GetWinner()
10
{
11
return null;
12
}
Copied!
เขียนโค้ดให้น้อยที่สุดเท่าที่จะทำได้ เพื่อให้เทสผ่าน เพราะเราจะได้โค้ดที่ไม่ซับซ้อนอะไรเลยออกมาก่อน
🧔 เย่เทสผ่านละ!! ต่อมาเราก็จะเอาเคสตัวถัดไปเข้ามาเพิ่ม ซึ่งตัวถัดไปก็คือ
2.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
เราก็จะเขียนเทสเคสให้ตัวนี้ต่อ ได้ตามนี้
BoardGameTest.cs
GameBoard.cs
1
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X")]
2
public void PlaceOInEmptySlotWhenBoardHave_1X_0O()
3
{
4
var boardGame = new BoardGame
5
{
6
Slots = new string[,]
7
{
8
{ "X", null, null },
9
{ null, null, null },
10
{ null, null, null },
11
}
12
};
13
var canPlace = boardGame.Place("O", 0, 1);
14
Assert.True(canPlace);
15
Assert.Equal("X", boardGame.CurrentTurn);
16
Assert.Null(boardGame.GetWinner());
17
}
Copied!
1
public string[,] Slots { get; set; }
2
public string CurrentTurn { get; set; }
3
4
public bool Place(string symbol, int row, int column)
5
{
6
CurrentTurn = "O";
7
return true;
8
}
9
10
public string GetWinner()
11
{
12
return null;
13
}
Copied!
🧔 ซึ่งพอเอาไป Run มันก็จะไม่ผ่าน เพราะคลาส GameBoard ยังเขียนแบบกากๆ ดังนั้นเราก็ต้องไปแก้ให้มันผ่านเคสนี้และเคสก่อนหน้าด้วยโดยเขียนให้ง่ายที่สุด
ในรอบนี้ผมจะคำนวณว่าเป็นตาของใครจาก Slots โดยมันจะนับว่า Array ที่ไม่เป็น null เป็นเลขคู่หรือเลขคี่ เพราะถ้าเป็นเลขคู่แสดงว่าเป็นตาของ X แต่ถ้าเป็นเลขคี่แสดงว่าเป็นตาของ O นั่นเอง
GameBoard.cs
1
public string[,] Slots { get; set; }
2
public string CurrentTurn { get; set; }
3
4
public BoardGame()
5
{
6
Slots = new string[3, 3];
7
}
8
9
public bool Place(string symbol, int row, int column)
10
{
11
Slots[row, column] = symbol;
12
13
var counter = 0;
14
foreach (var item in Slots)
15
{
16
if (item != null)
17
{
18
counter++;
19
}
20
}
21
22
if (counter % 2 == 0)
23
{
24
CurrentTurn = "X";
25
}
26
else
27
{
28
CurrentTurn = "O";
29
}
30
31
return true;
32
}
Copied!
🧔 ในตอนนี้ทั้งสองเคสก็จะทำงานผ่านหมดละ แต่โค้ดน่าเกลียดมาก ดังนั้นผมจะทำการปรับโค้ดให้อ่านง่ายขึ้นกว่าเดิมหน่อย ซึ่งเราเรียกขั้นตอนนี้ว่า Refactor
Refactor คือการปรับโค้ดให้มันดียิ่งขึ้นกว่าเดิม ซึ่งการจะทำ Refactor ได้โค้ดจะต้องถูกเขียนเทสเคสคลุมไว้แล้ว เพราะ ถ้าเราปรับโค้ดไปแล้ว เราจะรู้ได้ไงว่าไอ้ที่ปรับไปมันยังทำงานถูกอยู่? ดังนั้นมันเลยต้องมีเทสเคสเป็นตัวช่วยเช็คว่ายังทำงานถูกเหมือนเดิมหรือเปล่านั้นเอง
🧔 ขั้นตอนแรกผมก็ Refactor ตัว loop ว่าเป็นเลขคู่เลขคี่หรือเปล่าในไฟล์ GameBoard.cs บรรทัดที่ 13~20 ซึ่งก็จะได้โค้ดใหม่ออกมาเป็นแบบนี้
GameBoard.cs
1
public bool Place(string symbol, int row, int column)
2
{
3
Slots[row, column] = symbol;
4
5
var isEvenNumber = Slots.Cast<string>().Count(it => it != null) % 2 == 0;
6
7
if (isEvenNumber)
8
{
9
CurrentTurn = "X";
10
}
11
else
12
{
13
CurrentTurn = "O";
14
}
15
16
return true;
17
}
Copied!
🧔 ลอง Run test ละก็ผ่าน ดังนั้นผมก็จะ Refactor ต่อกับไฟล์เดิมนี่แหละ เพราะผมคิดว่า การตรวจว่าเป็นตาของ X หรือ O ในบรรทัดที่ 7~14 ยังเยิ่นเย้ออยู่ ซึ่งก็จะ Refactor ใหม่ออกมาได้เป็นแบบนี้
GameBoard.cs
1
public bool Place(string symbol, int row, int column)
2
{
3
Slots[row, column] = symbol;
4
5
var isEvenNumber = Slots.Cast<string>().Count(it => it != null) % 2 == 0;
6
CurrentTurn = isEvenNumber ? "X" : "O";
7
8
return true;
9
}
Copied!
🧔 อะเช Run test แล้วก็ผ่านอยู่ งั้นตอนนี้ไปเอาเทสเคสที่ 3 มาทำต่อบ้างดีกว่า ซึ่งมันเขียนไว้ว่า
3.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
ดังนั้นผมก็จะเขียนเทสให้ตัวนี้ออกมาเป็นตามนี้
BoardGameTest.cs
1
[Fact(DisplayName = "ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
2
public void PlaceXInEmptySlotWhenBoardHave_1X_1O()
3
{
4
var boardGame = new BoardGame
5
{
6
Slots = new string[,]
7
{
8
{ "X", "O", null },
9
{ null, null, null },
10
{ null, null, null },
11
}
12
};
13
var canPlace = boardGame.Place("X", 1, 0);
14
Assert.True(canPlace);
15
Assert.Equal("O", boardGame.CurrentTurn);
16
Assert.Null(boardGame.GetWinner());
17
}
Copied!
🧔 แล้วก็ลอง Run test ก็จะพบว่ามันผ่านเหมือนกัน แต่สิ่งที่ผมเห็นแล้วน่ารำคาญคือเจ้าไฟล์ BoardGameTest.cs เพราะทุกครั้งที่ผมเขียนเทส มันจะดูเหมือนมันเขียนของเดิมซ้ำๆ ไม่เชื่อลองดูไฟล์เต็มๆมันดูนะ
BoardGameTest.cs
1
[Fact(DisplayName = "ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
2
public void PlaceXWhenBoardIsEmpty()
3
{
4
var boardGame = new BoardGame();
5
var canPlace = boardGame.Place("X", 0, 0);
6
Assert.True(canPlace);
7
Assert.Equal("O", boardGame.CurrentTurn);
8
Assert.Null(boardGame.GetWinner());
9
}
10
11
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X")]
12
public void PlaceOInEmptySlotWhenBoardHave_1X_0O()
13
{
14
var boardGame = new BoardGame
15
{
16
Slots = new string[,]
17
{
18
{ "X", null, null },
19
{ null, null, null },
20
{ null, null, null },
21
}
22
};
23
var canPlace = boardGame.Place("O", 0, 1);
24
Assert.True(canPlace);
25
Assert.Equal("X", boardGame.CurrentTurn);
26
Assert.Null(boardGame.GetWinner());
27
}
28
29
[Fact(DisplayName = "ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
30
public void PlaceXInEmptySlotWhenBoardHave_1X_1O()
31
{
32
var boardGame = new BoardGame
33
{
34
Slots = new string[,]
35
{
36
{ "X", "O", null },
37
{ null, null, null },
38
{ null, null, null },
39
}
40
};
41
var canPlace = boardGame.Place("X", 1, 0);
42
Assert.True(canPlace);
43
Assert.Equal("O", boardGame.CurrentTurn);
44
Assert.Null(boardGame.GetWinner());
45
}
Copied!
🧔 สังเกตุดีๆจะเห็นว่าข้างใน method ทั้ง 3 ตัวมันเขียนเกือบจะเหมือนกันเลย นั่นแสดงว่าทุกๆครั้งที่ผมจะเอาเทสเคสมาเพิ่ม ผมก็จะเขียนของที่คล้ายๆเดิมไปลงเรื่อยๆ ทำให้โค้ดมันรก ดังนั้นในรอบนี้ผมก็จะทำการ Refactor ในฝั่งของตัว Test บ้างละ
สิ่งที่ผมจะทำคือรวมทั้ง 3 method ให้กลายเป็น method เดียว แล้วส่ง parameter แบบต่างๆเข้าไปแทน ก็จะได้โค้ดออกมาเป็นแบบนี้
BoardGameTest.cs
1
[Fact(DisplayName = "ลง X ไปตอนที่กระดานว่าง ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
2
public void PlaceXWhenBoardIsEmpty()
3
{
4
var slots = new string[3, 3];
5
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "X", 0, 0, "O");
6
}
7
8
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวเท่านั้น ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X")]
9
public void PlaceOInEmptySlotWhenBoardHave_1X_0O()
10
{
11
var slots = new string[,]
12
{
13
{ "X", null, null },
14
{ null, null, null },
15
{ null, null, null },
16
};
17
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "O", 0, 1, "X");
18
}
19
20
[Fact(DisplayName = "ลง X ไปในช่องว่าซึ่งบนกระดานมี X 1 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
21
public void PlaceXInEmptySlotWhenBoardHave_1X_1O()
22
{
23
var slots = new string[,]
24
{
25
{ "X", "O", null },
26
{ null, null, null },
27
{ null, null, null },
28
};
29
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "X", 1, 0, "O");
30
}
31
32
private void verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(string[,] slots, string symbol, int row, int column, string expectedCurrentTurn)
33
{
34
var boardGame = new BoardGame { Slots = slots };
35
var canPlace = boardGame.Place(symbol, row, column);
36
Assert.True(canPlace);
37
Assert.Equal(expectedCurrentTurn, boardGame.CurrentTurn);
38
Assert.Null(boardGame.GetWinner());
39
}
Copied!
Refactor เวลาที่ทำ Refactor สามารถทำได้ทั้ง 2 ฝั่งทั้ง โค้ดที่ถูกเทส และ โค้ดที่เอาไว้เทส แต่เวลาทำ Refactor แต่ละรอบ ต้องทำ Refactor ทีละฝั่งเท่านั้น ห้ามทำพร้อมกัน ไม่งั้นเราจะไม่รู้ว่ามันพังเพราะอะไรกันแน่
🧔 แน่นอนถ้าผมเปลี่ยนเป็นแบบนี้ก็ต้องลอง Run test ให้มันผ่านด้วยเช่นกัน ซึ่งก็ผ่านตามที่คาดไว้ ดังนั้นผมก็จะเริ่มเอาเทสเคสที่ 4~6 ลงมาใส่ต่อเลย (เพราะผมรู้ว่ามันก็ผ่านเหมือนกัน)
4.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
5.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O
6.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว แต่ O ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X
ซึ่งก็จะได้โค้ดออกมาตามนี้
BoardGameTest.cs
1
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 1 ตัว ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X")]
2
public void PlaceOInEmptySlotWhenBoardHave_2X_1O()
3
{
4
var slots = new string[,]
5
{
6
{ "X", "O", null },
7
{ "X", null, null },
8
{ null, null, null },
9
};
10
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "O", 1, 1, "X");
11
}
12
13
[Fact(DisplayName = "ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ O")]
14
public void PlaceXInEmptySlotWhenBoardHave_2X_2O_ButNotConnectedTogather()
15
{
16
var slots = new string[,]
17
{
18
{ "X", "O", null },
19
{ "X", "O", null },
20
{ null, null, null },
21
};
22
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "O", 0, 2, "O");
23
}
24
25
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว แต่ O ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ แต่ยังไม่มีผู้ชนะ และสลับเป็นตาของ X")]
26
public void PlaceOInEmptySlotWhenBoardHave_3X_2O_ButNotConnectedTogather()
27
{
28
var slots = new string[,]
29
{
30
{ "X", "O", null },
31
{ "X", "O", null },
32
{ null, "X", null },
33
};
34
verifyPlaceASymbolToEmptySpaceThenSystemMustAcceptTheRequest(slots, "O", 2, 0, "X");
35
}
Copied!
🧔 จากนั้นเราก็จะเริ่มเอาเทสเคสที่ 7 มาทำต่อ ซึ่งมันเป็นเทสเคสแรกที่มีคนชนะ โดยมันเขียนไว้ว่า
7.ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว และ X ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า X ชนะพร้อมกับเพิ่มแต้มให้ X 1 คะแนน
ดังนั้นผมก็จะเอาไปสร้างเทสเคสออกมาเป็นแบบนี้
BoardGameTest.cs
BoardGame.cs
1
[Fact(DisplayName = "ลง X ไปในช่องว่าซึ่งบนกระดานมี X 2 ตัวและ O 2 ตัว และ X ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า X ชนะพร้อมกับเพิ่มแต้มให้ X 1 คะแนน")]
2
public void PlaceXInEmptySlotWhenBoardHave_2X_2O_WithConnectedTogather()
3
{
4
var slots = new string[,]
5
{
6
{ "X", "O", null },
7
{ "X", "O", null },
8
{ null, null, null },
9
};
10
var boardGame = new BoardGame { Slots = slots };
11
var canPlace = boardGame.Place("X", 2, 0);
12
Assert.True(canPlace);
13
Assert.Equal("X", boardGame.GetWinner());
14
Assert.Equal(0, boardGame.OScore);
15
Assert.Equal(1, boardGame.XScore);
16
}
Copied!
1
public int OScore { get; set; }
2
public int XScore { get; set; }
3
public string[,] Slots { get; set; }
4
public string CurrentTurn { get; set; }
5
6
public BoardGame()
7
{
8
Slots = new string[3, 3];
9
}
10
11
public bool Place(string symbol, int row, int column)
12
{
13
Slots[row, column] = symbol;
14
15
var isEvenNumber = Slots.Cast<string>().Count(it => it != null) % 2 == 0;
16
CurrentTurn = isEvenNumber ? "X" : "O";
17
18
return true;
19
}
20
21
public string GetWinner()
22
{
23
return null;
24
}
Copied!
🧔 ซึ่งพอเอาไป Run test มันก็จะ Fail เพราะ BoardGame.cs ยังไม่ถูกเขียนการคำนวณว่าใครชนะ และ ยังไม่มีการจัดการเรื่องคะแนน ดังนั้นผมเลยต้องเขียนแบบง่ายที่สุดให้มันผ่าน ซึ่งก็จะออกมาเป็นแบบนี้
BoardGame.cs
1
public string GetWinner()
2
{
3
var firstRow = Slots[0, 0] + Slots[0, 1] + Slots[0, 2];
4
var secondRow = Slots[1, 0] + Slots[1, 1] + Slots[1, 2];
5
var thirdRow = Slots[2, 0] + Slots[2, 1] + Slots[2, 2];
6
var firstColumn = Slots[0, 0] + Slots[1, 0] + Slots[2, 0];
7
var secondColumn = Slots[0, 1] + Slots[1, 1] + Slots[2, 1];
8
var thirdColumn = Slots[0, 2] + Slots[1, 2] + Slots[2, 2];
9
var crossTop = Slots[0, 0] + Slots[1, 1] + Slots[2, 2];
10
var crossBottom = Slots[2, 0] + Slots[1, 1] + Slots[0, 2];
11
12
var allPossibilities = new string[]
13
{
14
firstRow, secondRow, thirdRow,
15
firstColumn, secondColumn, thirdColumn,
16
crossTop, crossBottom
17
};
18
19
foreach (var item in allPossibilities)
20
{
21
if (item.Length < 3)
22
{
23
continue;
24
}
25
26
if (item[0] == item[1] && item[0] == item[2])
27
{
28
var winnerSymbol = item[0].ToString();
29
if (winnerSymbol == "X")
30
{
31
XScore++;
32
}
33
return winnerSymbol;
34
}
35
}
36
37
return null;
38
}
Copied!
ไม่ต้องสนใจว่าโค้ดจะน่าเกลียดขนาดไหนขอแค่มันทำงานได้ก็ OK แล้ว แต่ถ้าเขียนแบบลัดได้ก็เขียนไปเลย ที่ผมเขียนแบบกากๆให้ดูเพราะอยากให้ดูการ Refactor
🧔 จากที่เขียนมามันก็ OK นะเพราะมัน Run test ผ่าน แต่โค้ดแบบว่าฝุดๆอ่ะ อ่านก็ยาก ถ้ามันผิดมานี่ผมคงขี้เกียจไปแก้มันแน่ ดังนั้นขอ Refactor มันหน่อยละกัน ซึ่งสิ่งที่ผมจะทำก็คือทำให้โค้ด บรรทัดที่ 3~17 อ่านแล้วเป็นภาษามนุษย์ขึ้นมาหน่อย ซึ่งก็จะได้ออกมาเป็นแบบนี้
BoardGame.cs
1
public string GetWinner()
2
{
3
var allPossibilities = getRowPossibilities()
4
.Union(getColumnPossibilities())
5
.Union(getCrossLinePossibilities());
6
7
foreach (var item in allPossibilities)
8
{
9
if (item.Length < 3)
10
{
11
continue;
12
}
13
14
if (item[0] == item[1] && item[0] == item[2])
15
{
16
var winnerSymbol = item[0].ToString();
17
if (winnerSymbol == "X")
18
{
19
XScore++;
20
}
21
return winnerSymbol;
22
}
23
}
24
25
return null;
26
}
27
28
private IEnumerable<string> getRowPossibilities()
29
{
30
return new string[]
31
{
32
quot;{Slots[0,0]}{Slots[0,1]}{Slots[0,2]}",
33
quot;{Slots[1,0]}{Slots[1,1]}{Slots[1,2]}",
34
quot;{Slots[2,0]}{Slots[2,1]}{Slots[2,2]}",
35
};
36
}
37
38
private IEnumerable<string> getColumnPossibilities()
39
{
40
return new string[]
41
{
42
quot;{Slots[0,0]}{Slots[1,0]}{Slots[2,0]}",
43
quot;{Slots[0,1]}{Slots[1,1]}{Slots[2,1]}",
44
quot;{Slots[0,2]}{Slots[1,2]}{Slots[2,2]}",
45
};
46
}
47
48
private IEnumerable<string> getCrossLinePossibilities()
49
{
50
return new string[]
51
{
52
quot;{Slots[0,0]}{Slots[1,1]}{Slots[2,2]}",
53
quot;{Slots[2,0]}{Slots[1,1]}{Slots[0,2]}",
54
};
55
}
Copied!
🧔 อ่าเทสผ่านละ จะเห็นว่าผม Refactor การสร้าง possibilities ของเกม ให้เป็นภาษามนุษย์แล้ว คือบรรทัดที่ 3 อันเดียวจบ ส่วน method ที่โดนสร้างเพิ่มขึ้นมาปล่อยมันไปเลยเพราะหน้าที่มันสมบูรณ์ในตัวแล้ว (ผมไม่ได้จะมาสอนเขียน LinQ นะดังนั้นขอปล่อยไว้แบบนี้แหละ)
🧔 ถัดมาผมก็จะ Refactor บรรทัดที่ 7~25 เพื่อให้อ่านแล้วเข้าใจได้ง่ายขึ้น ว่ากำลังหาผู้ชนะ ซึ่งก็จะออกมาเป็นแบบโค้ดด้านล่างนี้
BoardGame.cs
1
public string GetWinner()
2
{
3
var allPossibilities = getRowPossibilities()
4
.Union(getColumnPossibilities())
5
.Union(getCrossLinePossibilities());
6
7
const int MinimumRequiredCharacters = 3;
8
var winnerSpot = allPossibilities
9
.Where(it => it.Length == MinimumRequiredCharacters)
10
.FirstOrDefault(it => it.All(c => c == it.First()));
11
12
var winnerSymbol = winnerSpot?.First().ToString();
13
14
const string XSymbol = "X";
15
if (winnerSymbol == XSymbol)
16
{
17
XScore++;
18
}
19
20
return winnerSymbol;
21
}
Copied!
🧔 เทสผ่านเช่นเคย ดังนั้นผมก็จะลองเอาเทสเคส 8 มาลงต่อเลยละกัน
8.ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว และ O ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า O ชนะ พร้อมกับเพิ่มแต้มให้ O 1 คะแนน
BoardGameTest.cs
1
[Fact(DisplayName = "ลง O ไปในช่องว่าซึ่งบนกระดานมี X 3 ตัวและ O 2 ตัว และ O ทั้งหมดเรียงกัน ระบบให้ลงได้และประกาศว่า O ชนะ พร้อมกับเพิ่มแต้มให้ O 1 คะแนน")]
2
public void PlaceOInEmptySlotWhenBoardHave_3X_2O_WithConnectedTogather()
3
{
4
var slots = new string[,]
5
{
6
{ "X", "O", "X" },
7
{ "X", "O", null },
8
{ null, null, null },
9
};
10
var boardGame = new BoardGame { Slots = slots };
11
var canPlace = boardGame.Place("O", 2, 1);
12
Assert.True(canPlace);
13
Assert.Equal("O", boardGame.GetWinner());
14
Assert.Equal(1, boardGame.OScore);
15
Assert.Equal(0, boardGame.XScore);
16
}
Copied!
🧔 แต่มันก็จะยังไม่ผ่าน เพราะผมยังไม่ได้เขียน เพิ่มคะแนนให้กับ O เลย ดังนั้นก็ไปทำให้มันผ่านซะ
BoardGame.cs
1
public string GetWinner()
2
{
3
var allPossibilities = getRowPossibilities()
4
.Union(getColumnPossibilities())
5
.Union(getCrossLinePossibilities());
6
7
const int MinimumRequiredCharacters = 3;
8
var winnerSpot = allPossibilities
9
.Where(it => it.Length == MinimumRequiredCharacters)
10
.FirstOrDefault(it => it.All(c => c == it.First()));
11
12
var winnerSymbol = winnerSpot?.First().ToString();
13
14
const string XSymbol = "X";
15
const string OSymbol = "O";
16
if (winnerSymbol == XSymbol)
17
{
18
XScore++;
19
}
20
else if(winnerSymbol == OSymbol)
21
{
22
OScore++;
23
}
24
25
return winnerSymbol;
26
}
Copied!
🧔 ผ่านเรียบร้อยแล้วนะ ตอนนี้กลุ่มปรกติก็เหลือแค่เคส 9 อันสุดท้ายละ
9.ลง X ไปในช่องว่างซึ่งบนกระดานมี X 4 ตัวและ O 4 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ และแจ้งว่าเกมเสมอ
BoardGameTest.cs
BoardGame.cs
1
[Fact(DisplayName = "ลง X ไปในช่องว่างซึ่งบนกระดานมี X 4 ตัวและ O 4 ตัว แต่ X ทั้งหมดไม่ได้เรียงกัน ระบบให้ลงได้ และแจ้งว่าเกมเสมอ")]
2
public void PlaceXInEmptySlotWhenBoardHave_4X_4O_WithConnectedTogather()
3
{
4
var slots = new string[,]
5
{
6
{ "X", "O", "X" },
7
{ "X", "O", "O" },
8
{ "O", "X", null },
9
};
10
var boardGame = new BoardGame { Slots = slots };
11
var canPlace = boardGame.Place("X", 2, 2);
12
Assert.True(canPlace);
13
Assert.Null(boardGame.GetWinner());
14
Assert.Equal(0, boardGame.OScore);
15
Assert.Equal(0, boardGame.XScore);
16
Assert.True(boardGame.IsDraw);
17
}
Copied!
1
public bool IsDraw { get; set; } // มีแค่อันนี้พี่เพิ่มเข้ามา
Copied!
🧔 แล้วก็ Fail ตามที่คาด ดังนั้นก็ไปทำให้ผ่านครับ
BoardGame.cs
1
public string GetWinner()
2
{
3
// ...
4
else if (winnerSymbol == OSymbol)
5
{
6
OScore++;
7
}
8
else
9
{
10
var anyEmptySpace = Slots.Cast<string>().Any(it => it == null);
11
IsDraw = !anyEmptySpace;
12
}
13
14
return winnerSymbol;
15
}
Copied!
🧔 เย่ผ่าน สุดท้ายเราจะเห็นว่าเทสที่ 9 มันทำให้เราต้องกลับไปแก้เทสเคสทุกตัวเพื่อเช็คว่าเกมมันจะต้องไม่เสมอนะ ดังนั้นฝากไปลองเล่นกันต่อดูนะครับ เย่ๆๆ หนีจากบทความอันแสนยาวนี้ได้แล้ว

🎯 บทสรุป

การทำ Test First Design หรือ TDD สิ่งที่เราได้คือคุณภาพของโค้ดที่ดีเพราะโค้ดเราจะถูกคลุมด้วยเทสเคสทั้งหมดแล้ว ทำให้เรามั่นใจได้ว่าถ้าเกิดเหตุการณ์ที่อยู่ในเคสเกิดขึ้น โปรแกรมมันสามารถทำงานได้แบบไม่มี bug ค่อนข้างแน่นอน และเรายังสามารถทำ Refactor เพื่อทำ Clean Code เมื่อไหร่ก็ได้อีกด้วย และโค้ดที่ได้ออกมาก็จะไม่มีการ ออกแบบที่เกินความจำเป็น เพราะทุกอย่างเป็น minimal หมดเลย ซึ่งของที่เป็น minimal นี่แหละสามารถแก้ไขหรือปรับไปเป็นโครงสร้างอื่นๆได้ง่ายที่สุด

👨‍🚀 หัวใจหลักในการทำ TDD

หัวใจในการทำ Test-Driven Development คือการทำ 3 เรื่องครับ Red - Green - Refactor หรือพูดง่ายๆคือเขียนเทสให้มันไม่ผ่านก่อน แล้วทำให้มันผ่าน สุดท้ายค่อยกลับมาทำให้โค้ดมัน Clean ขึ้น นั่นเอง ซึ่งพอทำแบบนี้โค้ดที่เราเขียนมันก็จะค่อยๆเก่งขึ้นไปเรื่อยๆ ข้อผิดพลาดก็จะน้อยลงไปเรื่อยๆเช่นกันครับ
ต้องขอปรบมือให้เลยสำหรับคนที่อ่านตั้งแต่ต้นจนถึงตรงนี้ได้ สุดยอดกับความรักในการเขียนโค้ดจริงๆครับ

👨‍🚀 ดาวโหลดตัวอย่างทั้งหมด

โหลดได้จาก GitHub นี้เบย https://github.com/saladpuk/demo-test-first

👨‍🚀 คอร์สเรื่องการทำ TDD แบบเต็มสูบ

Last modified 1yr ago