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:

TDD explained
  1. 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.
  2. 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ử.
  3. 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 CodeSOLIDClean ArchitectureKISSSimple Design là các quy tắc và tiêu chuẩn thường được áp dụng trong bước này.
  4. 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.

Nguồn: https://nguyenbinhson.com/2020/06/23/bai-quyen-bowling-game/