Bowling Game là một bài kata kinh điển của hoạt động Coding Dojo. Bài kata này rất phù hợp để thực hành kỹ thuật TDD, Baby Steps và Refactoring.
Về TDD
TDD (Test Driven Development – Phát triển (mà trong đó việc phát triển) được lái bởi Kiểm thử) là một phương pháp tiếp cận để phát triển phần mềm. Nói cách khác, là một cách để suy nghĩ về requirement (yêu cầu) cũng như thiết kế trước khi viết các mã triển khai.
Có một mô hình giải thích khác về TDD mà trong đó coi TDD là một kỹ thuật lập trình. Tuy vậy trong bài viết này sử dụng thuật ngữ TDD theo mô hình “cách nghĩ”.
Cụ thể, “cách nghĩ” theo phương pháp TDD được diễn giải như sau:
- RED: Quá trình phát triển bắt đầu bằng thao tác Add a Test: bổ sung một ca kiểm thử. Chưa có mã triển khai tương ứng nên ca kiểm thử này sẽ Failed. Nhưng ngay từ thời điểm đó ca kiểm thử đã có tác dụng mô tả yêu cầu cũng như thiết kế của mã triển khai.
- GREEN: Mã triển khai tương ứng với ca kiểm thử được bổ sung. Mã nguồn chuyển sang giai đoạn Passed (vượt qua kiểm thử). Các mã triển khai nội bộ của chức năng có thể linh hoạt, nhưng thiết kế của mã thì tuân theo mô tả của kiểm thử.
- BLUE: Mã nguồn được tái cấu trúc. Mục đích của việc tái cấu trúc là đưa mã nguồn tới trạng thái sẵn sàng để bổ sung tính năng mới. Clean Code, SOLID, Clean Architecture, KISS, Simple Design là các quy tắc và tiêu chuẩn thường được áp dụng trong bước này.
- Quá trình RGB (RedGreenBlue) được lặp đi lặp lại cho đến khi các chức năng được triển khai hoàn thiện.
Về các Coding Dojo Kata
Kata (bài quyền) trong một buổi Coding Dojo được định nghĩa là những bài toán được thiết kế cho lập trình viên để luyện tập một kỹ năng trong lập trình, thông qua thực hành và lặp lại.
Các vấn đề (bài toán) được sử dụng làm kata thường đủ nhỏ (không quá khó) để giải quyết nhưng đủ thách thức để người tập không có khả năng hoàn thành trong thời gian cho phép. Đây là thiết kế của Coding Dojo nhằm giúp người tham gia tập trung vào luyện tập kỹ năng thay vì hướng tới mục đích “xong việc”.
Về Vấn đề Bowling
Vấn đề Bowling xuất phát từ luật của trò chơi Bowling.
Một ván chơi diễn ra trong 10 frame. Mỗi frame, người chơi có 2 lượt ném để hạ đổ 10 pin. Điểm của frame là tổng số pin bị hạ đổ cộng thêm điểm thưởng từ trike và spare.
Spare (các ký hiệu /
trong hình trên)xảy ra khi người chơi hạ thành công cả 10 pin sau hai lượt ném. Điểm thưởng cho spare là số pin bị hạ tại lượt ném ngay sau đó.
Strike (các ký hiệu X
trong hình trên) xảy ra khi người chơi hạ thành công cả 10 pin ngay từ lượt ném đầu tiên, và theo đó kết thúc frame trong một roll duy nhất. Điểm thưởng cho strike là tổng số pin bị hạ tại hai lượt ném ngay sau đó.
Người chơi đạt spare hay strike tại frame cuối cùng sẽ được ném thêm các lượt ném phụ để nhận trọn các lượt thưởng. Theo đó frame này sẽ kết thúc sau ba lượt ném.
Bài kata Bowling Game yêu cầu lập trình viên viết chương trình để tính điểm cho trò chơi này.
Bài quyền
Thiết kế ban đầu
Mọi tính thông tin cần thiết để có được điểm của một ván chơi nằm trọn trong một ván chơi.
BowlingGame game = new BowlingGame();
Rõ ràng, điểm của một game chỉ có thể tính được tại thời điểm mà kết quả của mọi roll đều đã rõ ràng. Giả sử ta thiết kế phương thức getScore
dùng để tính về điểm số của game, getScore
chỉ làm được điều đó khi nó đã nhận được đầy đủ thông tin về các roll. Chẳng hạn:
BowlingGame game = new BowlingGame();
int[] rolls = // a dummy rolls array
int score = game.getScore(rolls);
Cách thiết kế này không ổn, hãy để ý game.getScore(rolls)
, ở đây game là một thực thể (entity) nhưng lại đang được sử dụng như một service cung cấp khả năng tính điểm.
Một thiết kế tốt hơn là đặt một phương thức roll
để nhận thông tin về các roll, và đặt một phương thức score
được gọi khi các roll đều đã được nhập liệu hoàn chỉnh.
BowlingGame game = new BowlingGame();
repeat () {
int pins = // a dummy pins;
game.roll(pins);
}
int score = game.score();
Đây là thiết kế tối thiểu mà chúng ta có thể có thể bắt đầu phát triển chương trình.
Bắt đầu
Bài viết này dựa trên tiền đề rằng chương trình được viết bằng ngôn ngữ Java và test framework được sử dụng là JUnit.
Tạo một kiểm thử đơn vị
Với JUnit, một kiểm thử đơn vị là một class trong đó có các ca kiểm thử là những phương thức được chú thích @Test
. Dưới đây là kiểm thử đơn vị cho chương trình BowlingGame với một ca kiểm thử “dummy” dùng để xác nhận rằng test framework hoạt động ổn định.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class BowlingGameTest {
@Test
void testAddition() {
assertEquals(2, 1 + 1);
}
}
Thực thi kiểm thử cho kết quả
+-- JUnit Jupiter [OK]
| '-- BowlingGameTest [OK]
Ca kiểm thử đầu tiên
Kiểm thử đầu tiên mô tả rằng “nếu người chơi ném trượt tất cả các roll thì điểm số sẽ là 0”.
Mô tả class chức năng
@Test
void testAllMissedGame() {
BowlingGame game = new BowlingGame();
}
Mã triển khai để vượt qua kiểm thử:
public class BowlingGame {
}
Mô tả thao tác nhập thông tin các roll
Để mô tả một ván chơi mà tất cả các roll (tổng cộng 20) đều trượt, ở đây sử dụng một phép lặp:
@Test
void testAllMissedGame() {
BowlingGame game = new BowlingGame();
for (int i = 0; i < 20; i++) {
game.roll(0);
}
}
Mã triển khai để vượt qua kiểm thử (để đơn giản, từ mục này về sau mã triển khai sẽ được viết ra mà không có chú thích gì thêm):
public class BowlingGame {
public void roll(int pins) {
}
}
Mô tả thao tác tính điểm
@Test
void testAllMissedGame() {
BowlingGame game = new BowlingGame();
for (int i = 0; i < 20; i++) {
game.roll(0);
}
int score = game.score();
}
public class BowlingGame {
public void roll(int pins) {
}
public int score() {
return -1;
}
}
Mô tả điểm số mong muốn
@Test
void testAllMissedGame() {
BowlingGame game = new BowlingGame();
for (int i = 0; i < 20; i++) {
game.roll(0);
}
assertEquals(0, game.score());
}
public int score() {
return 0;
}
Tới bước này, chương trình đã có khả năng tính đúng số điểm của những ván chơi mà tất cả các roll đều trượt.
Ca kiểm thử thứ hai
Ca kiểm thử thứ hai mô tả trường hợp mà “người chơi ném đổ một số pin nào đó, nhưng không có điểm thưởng”, chúng ta chọn một trường hợp điển hình là “người chơi chỉ ném đổ một pin mỗi roll”.
Mô tả một game ăn một điểm mỗi roll
@Test
void testAllMissedGame() {
BowlingGame game = new BowlingGame();
for (int i = 0; i < 20; i++) {
game.roll(0);
}
assertEquals(0, game.score());
}
@Test
void testAllOneGame() {
BowlingGame game = new BowlingGame();
for (int i = 0; i < 20; i++) {
game.roll(1);
}
assertEquals(20, game.score());
}
private int score;
public void roll(int pins) {
score += pins;
}
public int score() {
return score;
}
Trước khi triển khai chức năng tiếp theo, hãy để ý có dấu hiệu mã xấu
- Trùng lắp mã khởi tạo đối tượng
game
- Trùng lắp mã lặp lại thao tác
roll
Khử trùng lắp cho mã khởi tạo đối tượng game
Trùng lắp này có thể được khử bằng một phương thức setup mà hầu như mọi test framework đều hỗ trợ. Trong JUnit, phương thức setup được mô tả bởi chú thích @BeforeEach
:
private BowlingGame game;
@BeforeEach
void setup() {
game = new BowlingGame();
}
@Test
void testAllMissedGame() {
for (int i = 0; i < 20; i++) {
game.roll(0);
}
assertEquals(0, game.score());
}
@Test
void testAllOneGame() {
for (int i = 0; i < 20; i++) {
game.roll(1);
}
assertEquals(20, game.score());
}
Khử trùng lắp cho thao tác roll
nhiều lần
Trùng lắp này có thể được khử bằng kỹ thuật tách hàm:
private BowlingGame game;
@BeforeEach
void setup() {
game = new BowlingGame();
}
@Test
void testAllMissedGame() {
rollMany(0, 20);
assertEquals(0, game.score());
}
@Test
void testAllOneGame() {
rollMany(1, 20);
assertEquals(20, game.score());
}
private void rollMany(int pins, int times) {
for (int i = 0; i < times; i++) {
game.roll(pins);
}
}
Ca kiểm thử thứ ba
Ca kiểm thử thứ ba hướng đến việc mô tả chức năng tính điểm thưởng cho spare.
Mô tả một spare
@Test
void testSpare() {
game.roll(3);
game.roll(7); // spare!
game.roll(4);
rollMany(0, 17);
assertEquals(18, game.score());
}
Thiết kế hiện tại không thể vượt qua được kiểm thử này vì những lý do sau đây:
- Score đang được tính toán “lâm thời”, trong khi để tính điểm thưởng thì cần dựa vào roll “tương lai” (hoặc “quá khứ”, tùy cách hiểu).
- Để giải quyết vấn đề trên thì lịch sử kết quả của các roll cần được lưu lại, nhưng phương thức
roll
hiện tại không làm điều đó. - Việc tính toán điểm số cần dựa trên lịch sử, nhưng phương thức
score
hiện tại không làm điều đó.
Để giải quyết vấn đề trên, cần tái thiết kế mã nguồn. Để có thể tái thiết kế an toàn ta cần giữ lại hai ca kiểm thử đầu tiên. Ca kiểm thử thứ ba tạm thời chưa thể pass được và cần phải đặt sang một bên.
// @Test
// void testSpare() {
// ...
Lưu giữ lịch sử các roll
Phương thức roll sẽ đảm nhiệm chức năng lưu giữ lịch sử ném:
private int score = 0;
private int[] rolls = new int[21];
private int currentRoll = 0;
public void roll(int pins) {
rolls[currentRoll++] = pins;
score += pins;
}
public int score() {
return score;
}
Tính điểm số dựa trên lịch sử ném
Các mã đảm nhiệm chức năng tính điểm sẽ được bỏ ra khỏi phương thức roll
. Phương thức score
sẽ đảm nhiệm tính năng này, và nó thực hiện dựa theo lịch sử ném:
private int[] rolls = new int[21];
private int currentRoll = 0;
public void roll(int pins) {
rolls[currentRoll++] = pins;
}
public int score() {
int score = 0;
for (int i = 0; i < rolls.length; i++) {
score += rolls[i];
}
return score;
}
Tới lúc này thì ca kiểm thử số ba có thể được gỡ comment.
Phát hiện spare
Thao tác cộng điểm thưởng cho spare bắt đầu bằng việc phát hiện ra sự kiện spare:
public int score() {
// ...
for (int i = 0; i < rolls.length; i++) {
if (rolls[i] + rolls[i+1] == 10) // spare score += ...
// score += rolls[i];
}
// ...
}
Giải pháp này không sử dụng được bởi thông tin i
không mô tả được roll hiện tại là roll bắt đầu hay kết thúc của một frame. Thiết kế hiện tại vẫn chưa đáp ứng được ca kiểm thử số 3. Chúng ta cần một biến đếm có khả năng đại diện cho một frame, không phải một roll.
Duyệt qua các frame
Ca kiểm thử số 3 cần được comment lại một lần nữa. Phương thức score
được tái cấu trúc để duyệt qua từng frame một:
public int score() {
int score = 0;
int i = 0;
for (int frame = 0; frame < 10; frame++) {
score += rolls[i] + rolls[i + 1];
i += 2;
}
return score;
}
Tới lúc này thì thiết kế đã sẵn sàng để vượt qua ca kiểm thử thứ ba.
Vượt qua ca kiểm thử thứ 3
Gỡ bỏ comment cho ca kiểm thử thứ ba và bổ sung mã tính điểm thưởng cho spare:
public int score() {
int score = 0;
int i = 0;
for (int frame = 0; frame < 10; frame++) {
if (rolls[i] + rolls[i + 1] == 10) { // spare
score += 10 + rolls[i + 2];
i += 2;
} else {
score += rolls[i] + rolls[i + 1];
i += 2;
}
}
return score;
}
Kiểm thử số ba đã được vượt qua, nhưng những mã xấu sau vẫn hiện diện:
- Tên biến không có tính mô tả (biến
i
) tại mã triển khai - Sự tồn tại của comment tại mã triển khai (để giải thích cho phép so sánh magic
== 10
) - Sự tồn tại của comment tại mã kiểm thử (để giải thích cho cặp số magic
3-7
)
Đặt lại tên biến mặc tả
Biến i
của phương thức score
đang mô tả vị trí bắt đầu của frame trong lịch sử các roll, có thể được đặt lại tên thành frameIndex
.
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (rolls[frameIndex] + rolls[frameIndex + 1] == 10) { // spare
score += 10 + rolls[frameIndex + 2];
frameIndex += 2;
} else {
score += rolls[frameIndex] + rolls[frameIndex + 1];
frameIndex += 2;
}
}
return score;
}
Khử comment tại mã triển khai
Comment tại mã triển khai có thể được khử bằng cách đặt tên cho biểu thức so sánh magic rolls[frameIndex] + rolls[frameIndex + 1] == 10
. Biểu thức này này mô tả dấu hiệu nhận diện một spare. Mục tiêu “đặt tên” có thể được thực hiện bằng kỹ thuật tách phương thức:
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (isSpare(frameIndex)) {
score += 10 + rolls[frameIndex + 2];
frameIndex += 2;
} else {
score += rolls[frameIndex] + rolls[frameIndex + 1];
frameIndex += 2;
}
}
return score;
}
private boolean isSpare(int frameIndex) {
return rolls[frameIndex] + rolls[frameIndex + 1] == 10;
}
Khử comment tại mã kiểm thử
Tương tự, cặp magic roll tại mã kiểm thử có thể được khử bằng kỹ thuật tách phương thức:
@Test
void testSpare() {
rollSpare();
game.roll(4);
rollMany(0, 17);
assertEquals(18, game.score());
}
private void rollSpare() {
game.roll(3);
game.roll(7);
}
Tới lúc này thì mã nguồn đã sẵn sàng cho kiểm thử tiếp theo.
Ca kiểm thử thứ tư
Mục đích của kiểm thử thứ tư là mô tả khả năng tính điểm thưởng trong trường hợp người chơi ăn strike.
Kiểm thử strike
@Test
void testStrike() {
game.roll(10); // strike
game.roll(1);
game.roll(2);
rollMany(0, 16);
assertEquals(16, game.score());
}
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (rolls[frameIndex] == 10) { // strike
score += 10
+ rolls[frameIndex + 1]
+ rolls[frameIndex + 2];
frameIndex++;
} else if (isSpare(frameIndex)) {
score += 10 + rolls[frameIndex + 2];
frameIndex += 2;
} else {
score += rolls[frameIndex] + rolls[frameIndex + 1];
frameIndex += 2;
}
}
return score;
}
Nhờ có các tái cấu trúc trước đó, ca kiểm thử thứ tư được vượt qua rất dễ dàng. Nhưng những mã xấu sau vẫn hiện diện:
- Comment tại mã triển khai
- Comment tại mã kiểm thử
Khử comment tại mã triển khai
Comment tại mã triển khai hiện diện nhằm giải thích cho biểu thức magic rolls[frameIndex] == 10
. Biểu thức này mô tả dấu hiệu nhận diện một strike. Dấu hiệu này có thể được đặt tên bằng kỹ thuật tách phương thức:
public int score() {
int score = 0;
int frameIndex = 0;
for (int frame = 0; frame < 10; frame++) {
if (isStrike(frameIndex)) {
// ...
}
return score;
}
private boolean isStrike(int frameIndex) {
return rolls[frameIndex] == 10;
}
Khử comment tại mã kiểm thử
Comment tại mã kiểm thử nhằm mô tả magic roll 10
. Roll này có thể được đặt tên bằng kỹ thuật tách phương thức:
@Test
void testStrike() {
rollStrike();
game.roll(1);
game.roll(2);
// ...
}
private void rollStrike() {
game.roll(10);
}
Tới lúc này thì mã triển khai đã có thể tính điểm chính xác trên tất cả các roll có thể xảy ra.
Ca kiểm thử thứ năm
Ca kiểm thử này nhằm kiểm thử trường hợp đặc biệt nhất, khi mà người chơi ăn strike trên tất cả các roll.
@Test
void testPerfectGame() {
rollMany(10, 12);
assertEquals(300, game.score());
}
Ca kiểm thử này được vượt qua mà không cần thêm bất kỳ nỗ lực nào. Đây cũng là kiểm thử cuối cùng của bài kata Bowling Game.
2 Replies to “Bài quyền Bowling Game”