Mở đầu

Trong các dự án, nhất là gần đến deadline, chúng ta hay tăng tốc độ programming, sửa đổi nhiều chỗ để chương trình hoạt động. Ngược lại, điều đấy khiến chúng ta bỏ qua một số lỗi lập trình, gây ảnh hưởng đến kết quả và thời gian dự án.
Bài viết này là về phương thức TDD, một phương pháp lập trình nhấn mạnh vào việc testing code ngay từ đầu. Bài viết sẽ tập trung vào Unit Test và một chút Integration Testing. TDD đảm bảo là code chúng ta tạo xử lý nhiều trường hợp khác nhau để chứng minh code đạt được kết quả như yêu cầu.

Phương thức

Điểm quan trọng nhất trong TDD là ta viết Test Code trước khi viết code thật. Các step trong TDD là như sau

  1. Tạo test mới.
  2. Chạy tất cả test. Đảm bảo test mới thất bại.
  3. Viết code mới, để test mới đỗ (test pass).
  4. Chạy tất cả test. Đảm bảo tất cả test đều đỗ
  5. Refactor code
  6. Lặp lại

Bài toán

Cách nhanh nhất để học TDD là thử áp dụng vào một bài toán. Ta sẽ sử dụng TDD để tạo game như sau:

Người chơi và chủ nhà (máy tính) được phát mỗi người một quân bài. Mục tiêu của người chơi là đoán đúng quân bài của họ thấp hay cao hơn quân bài của chủ nhà.
Giá trị từ thấp đến cao của quân bài là:
A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K.
Cả 4 bộ ♥ (cơ), ♦ (rô), ♣ (tép), ♠ (bích), có giá trị bằng nhau.
Người chơi ban đầu có 60 điểm. Mức thưởng ban đầu là 20 điểm. Mỗi ván, người chơi mất 30 điểm.

Trong một ván:

  • Chủ nhà mở bài của họ ra trước.
  • Người chơi đoán quân bài của họ thấp hay cao hơn quân bài của chủ nhà.
  • Nếu người chơi đoán sai, họ mất số tiền thưởng.
  • Nếu đoán đúng, người chơi có thể chọn dừng hay chơi tiếp.
  • Nếu dừng, người chơi giữ lại số điểm thưởng.
  • Nếu tiếp tục chơi, người chơi chưa được nhận điểm thưởng, nhưng số điểm thưởng được tăng gấp đôi.

Người chơi thắng hay thua theo điều kiện sau

  • Thắng trò chơi nếu có 1000 điểm sau ván.
  • Thua trò chơi nếu số điểm dưới 30 sau ván.

Bài viết tiếng anh về TDD của trò chơi tương tự: http://www.codekoala.com/pdfs/tdd.pdf

Tạo môi trường

Python 3.6.8
$ pip3 install pydealer # Python Library cho chơi bài
$ pip3 install mock
$ pip3 install coverage

File của chúng ta sẽ được sắp xếp như sau:

├── card_game.py
└── tests
    ├── game_test.py
    └── __init__.py

card_game.py sẽ có code chính để tạo game
game_test.py sẽ có code dùng để test

Áp dụng TDD

Đầu tiên, ta tạo một class đơn giản như sau, với các giá trị ban đầu trong game_card.py:

class CardGame:
    def __init__(self):
        self.player_score = 60
        self.reward = 20
        self.cost = 30
        

Mục tiêu đầu tiên chúng ta sẽ tạo một method để chia một quân bài cho chủ nhà và người chơi. Trước hết, ta tạo một stub method trong game_card.py:

def deal_card(self):
        pass

Sau đó tạo test trong game_test.py, để kiểm tra xem quân bài đã được chia cho người chơi và máy tính chưa?

import unittest
from card_game import CardGame

class GameTest(unittest.TestCase):

    def test_deal_card(self):
        """Test if house and player each has a single card"""
        game = CardGame()
        self.assertEqual(game.deck.size, 52)
        self.assertEqual(game.house_card.size, 0)
        self.assertEqual(game.player_card.size, 0)
        game.deal_card()
        self.assertEqual(game.deck.size, 50)
        self.assertEqual(game.house_card.size, 1)
        self.assertEqual(game.player_card.size, 1)


if __name__ == '__main__':
    unittest.main()

Để tạo test trên, chúng ta sẽ import unittest library có sẵn trong python. Chúng ta tạo class GameTest và inherit TestCase. Nó sẽ có các method sử dụng để test trò chơi, các method này luôn có tên bắt đầu bằng test_

Unit Test có nhiệm vụ là test một đoạn code (bình thường là method) đảm bảo là method deal_card của chúng ta hoạt động đúng như mong muốn.

Để ý ta có thể viết comment để cho biết test này có mục đích kiểm tra gì ở ngay dưới chỗ ta định nghĩa test method.

Ta chỉ cần kiểm tra số bài trong bộ bài, tay của chủ nhà, tay của người chơi trước và sau khi chia bài. Ta sử dụng lệnh self.assertEqual để test số quân bài có đúng như mong đợi không. Ví dụ, trước khi chia bài, bộ bài phải có 52 quân. Sau khi chia mỗi người một quân, bộ bài phải có 50 quân.

Chạy lệnh sau để test:

$ python -m unittest -v tests.game_test

Chúng ta cho -v để print thông tin cụ thể về test đang chạy.

Kết quả:

test_deal_card (tests.game_test.GameTest)
Test if house and player each has a single card ... ERROR

======================================================================
ERROR: test_deal_card (tests.game_test.GameTest)
Test if house and player each has a single card
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 12, in test_deal_card
    self.assertEqual(game.deck.size, 52)
AttributeError: 'CardGame' object has no attribute 'deck'

----------------------------------------------------------------------
Ran 1 test in 0.000s

Test của chúng ta fail. Thế là tốt. Không có gì đáng ngạc nhiên, vì method deal_card chưa được implement.

Tiếp theo ta implement code để chia một quân bài cho người chơi và một quân bài cho máy.

import pydealer

class CardGame:
    def __init__(self):
        self.player_score = 60
        self.reward = 20
        self.cost = 30
        self.deck = pydealer.Deck()
        self.house_card = pydealer.Stack()
        self.player_card = pydealer.Stack()

    def deal_card(self):
        self.deck.shuffle()
        self.house_card.add(self.deck.deal(1))
        self.player_card.add(self.deck.deal(1))

Để ý chúng ta import library pydealer, một library cho các trò chơi bài. Tạo bộ bài bằng Deck(), tay bài của chủ nhà và người chơi bằng Stack() ở method __init__. Ở code deal_card chúng ta tráo quân bài trong bộ và chia mỗi người một quân.

Bây giờ, ta chạy lại test.

$  python -m unittest -v tests.game_test
test_deal_card (tests.game_test.GameTest)
Test if house and player each has a single card ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Test của chúng ta đã chạy OK, code ra output như mong đợi.

Sang phần tiếp theo trong tài liệu yêu cầu, ta tạo method để bày quân bài của chủ nhà cho người chơi. Như ban đầu, ta tạo code stub trước, tạo test, sau đó implement code.

Tạo stub method:

    def reveal_house_card(self):
        pass

Ta có thể sử dụng khái niệm "mocking" để tạo test. Mocking sẽ loại bỏ tất cả các ảnh hưởng mà ta không kiểm soát được từ hệ thống bên ngoài (input người dùng, database...). Tạo test:

    def test_reveal_card(self):
        """Test if house card is revealed correctly"""
        side_effects = [
            # 1st game
            Stack(cards=[Card("Ace", "Spades")]),
            Stack(cards=[Card("2", "Hearts")])
        ]
        game = CardGame()
        game.deck.deal = mock.Mock(side_effect=side_effects)
        game.deal_card()
        with mock.patch("builtins.print") as fake_print:
            game.reveal_house_card()
            fake_print.assert_called_with("House has A♠")

Trong trường hợp này vì deck.deal(1) sẽ chia một quân bài bất kỳ cho chủ nhà, ta không biết quân bài của chủ nhà là gì và khó có thể test được quân bài của chủ nhà được hiện lên như thế nào. Vì thế ta có thể mock, hay làm giả quân bài được phát cho chủ nhà và người chơi. Thay bằng chia cho chủ nhà một quân bài ngẫu nhiên, ta tạo variable side_effect để chia chủ nhà át bích (A♠) và người chơi 2 cơ (2♥). Vì deal trả về object là Stack - một tay có nhiều quân bài, ta cũng trả về object tương tự.

Ta thay thế method deck.deal bằng mock.Mock và chuyển các quân bài ta muốn chia trong side_effect. Khi gọi lệnh deal_card, Mock sẽ chia bài giả như định nghĩa ở Mock. Một điểm cần lưu ý. Mỗi lần gọi function được mock, nó sẽ trả về một giá trị trong side_effect và đi đến giá trị cần phải trả tiếp theo. deck.deal(1) lần 1 trả A♠, nhưng lần thứ 2 sẽ trả 2♥. Có thể xem thêm thông tin ở đây: https://docs.python.org/3/library/unittest.mock.html

Tiếp theo, ta sẽ mock lệnh print của python để kiểm tra kết quả print đúng không. Cách mock trước sử dụng Mock object, cách mock này sử dụng context manager. Ta sẽ có một mock object là fake_print, sau khi gọi method mới reveal_house_card() ta sẽ kiểm tra xem lá bài của chủ nhà có được hiện như mong đợi không. Ta sử dụng method fake_print.assert_called_with. Nó giúp ta kiểm tra xem print có được gọi bằng parameter giống như ở trong assert_called_with hay không.

Chạy test và kiểm tra test có fail không:

python3 -m unittest tests.game_test
.E
======================================================================
ERROR: test_reveal_card (tests.game_test.GameTest)
Test if house card is revealed correctly
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 31, in test_reveal_card
    game.reveal_house_card()
AttributeError: 'CardGame' object has no attribute 'reveal_house_card'

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (errors=1)

Bây giờ ta implement method để hiện card

    def get_card_info(self, card):
        card_value = None
        card_suit = None
        # Card suit
        if card.suit == "Spades":
            card_suit = "♠"
        elif card.suit == "Hearts":
            card_suit = "♥"
        elif card.suit == "Clubs":
            card_suit = "♣"
        elif card.suit == "Diamonds":
            card_suit = "♦"

        # Card value
        if card.value == "Ace":
            card_value = "A"
        elif card.value == "King":
            card_value = "K"
        elif card.value == "Queen":
            card_value = "Q"
        elif card.value == "Jack":
            card_value = "J"

        return "%s%s" % (card_value, card_suit)

    def reveal_house_card(self):
        house_card_info = self.get_card_info(self.house_card[0])
        print("House has %s" % house_card_info)

pydealer không cho chúng ta thể hiện lá bài theo ký hiệu như "A♠", ta có thể tạo method get_card_info để đổi lá bài thành ký hiệu.

Chạy test:

$ python3 -m unittest tests.game_test
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Test mới của chúng ta đã đỗ. Tuy nhiên get_card_info không được gọn gàng lắm. Ta có thể refactor lại đoạn code này cho gọn gàng hơn:

    @staticmethod
    def get_suit_symbol(card):
        if card.suit == "Spades":
            return "♠"
        elif card.suit == "Hearts":
            return "♥"
        elif card.suit == "Clubs":
            return "♣"
        elif card.suit == "Diamonds":
            return "♦"
        else:
            raise Exception("Cannot map suit to symbol, unknown suit %s" % card.suit)

    @staticmethod
    def get_abbrv_card_value(card):
        if card.value == "Ace":
            return "A"
        elif card.value == "King":
            return "K"
        elif card.value == "Queen":
            return "Q"
        elif card.value == "Jack":
            return "J"
        else:
            return card.value

    def get_card_info(self, card):
        card_value = CardGame.get_abbrv_card_value(card)
        suit_symbol = CardGame.get_suit_symbol(card)
        return "%s%s" % (card_value, suit_symbol)

    def reveal_house_card(self):
        house_card_info = self.get_card_info(self.house_card[0])
        print("House has %s" % house_card_info)

Sau khi refactor code ta phải chạy test lại để đảm bảo code ta thay đổi không có lỗi:

$ python3 -m unittest tests.game_test
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Sau khi refactor, code của ta không có lỗi và hiện quân bài đúng như mong đợi. Ta có thể code phần tiếp theo: dự đoán của người chơi.
Như trước, tạo stub method trước:

    def player_guess(self):
            pass

Sau đó tạo test mới

    def test_player_guess(self):
        input_side_effects = ["l", "h", "abc", "higher"]
        with mock.patch("builtins.input", side_effect=input_side_effects) as fake_inputs:
            game = CardGame()
            game.house_card = Stack(cards=[Card("A", "Spades")])
            guess = game.player_guess()
            self.assertEqual(guess, "lower")
            guess = game.player_guess()
            self.assertEqual(guess, "higher")
            guess = game.player_guess()
            self.assertEqual(guess, "higher")

Người chơi có thể dự đoán quân bài của họ thấp hay cao hơn quân bài của chủ nhà. Họ sử dụng lệnh "lower" hay "l" cho thấp hơn, "higher" hay "h" cho cao hơn. Nếu giá trị không hợp lệ, thì người chơi phải nhập lại lệnh cao thấp. Lần này ta tạo mock cho input của người chơi, ta cũng sẽ tạo mock input bằng context manager.

Chúng ta tạo một card tạm thời cho chủ nhà vì máy tính sẽ sử dụng quân bài này để hỏi input của người chơi.

Trừ "abc", các giá trị trong side_effect tương ứng với các lần gọi game.player_guess(). guess lần 1: trả về "l". Lần 2 trả về "h". Lần 3 trả về "abc" nhưng đây là input không hợp lệ, nên guess phải trả input một lần nữa: higher.

Chạy test:

$ python3 -m unittest tests.game_test
.F.
======================================================================
FAIL: test_player_guess (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 40, in test_player_guess
    self.assertEqual(guess, "lower")
AssertionError: None != 'lower'

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=1)

Implement Code mới:

    def process_guess(self, guess):
        if guess.lower() == "lower" or guess.lower() == "l":
            return "lower"
        elif guess.lower() == "higher" or guess.lower() == "h":
            return "higher"
        else:
            return guess

    def player_guess(self):
        house_card_info = self.get_card_info(self.house_card[0])
        print("Is your card lower(l) or higher(h) than %s" % house_card_info)
        guess = input()
        guess = self.process_guess(guess)
        while guess != "lower" and guess != "higher":
            print("Invalid input '%s', type 'lower(l)' or 'higher(h)':" % guess)
            guess = input()
            guess = self.process_guess(guess)
        return guess

Code mới sẽ lấy input của người dùng là cao hay thấp. Trong trường hợp input không hợp lệ thì máy tính sẽ yêu cầu người dùng nhập lại.

Chạy test:

$ python3 -m unittest tests.game_test
.Is your card lower(l) or higher(h) than A♠
Is your card lower(l) or higher(h) than A♠
Is your card lower(l) or higher(h) than A♠
Invalid input 'abc', type 'lower(l)' or 'higher(h)':
..
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Test mới của chúng ta đã đỗ. Tuy nhiên, print trong code thật làm kết qảu chạy test hơi lộn xộn. Ta có thể patch print trong test để giấu các lệnh print này đi:

    @mock.patch("builtins.print")
    def test_player_guess(self, fake_print):
        input_side_effects = ["l", "h", "abc", "higher"]
        with mock.patch("builtins.input", side_effect=input_side_effects):
            game = CardGame()
            game.house_card = Stack(cards=[Card("Ace", "Spades")])
            guess = game.player_guess()
            self.assertEqual(guess, "lower")
            guess = game.player_guess()
            self.assertEqual(guess, "higher")
            guess = game.player_guess()
            self.assertEqual(guess, "higher")

Ta dùng lệnh @mock.patch để patch function print thành mock có tên fake_print định nghĩa ở method test test_player_guess(self, fake_print). Ta có thể lại kiểm tra print trong test này có đúng không bằng assert_called_with như ở trên. Có thể sử dụng cả các method khác như:assert_called_once_with,assert_has_calls... Có thể xem ở đây:
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.Mock.assert_called_with

Sau khi patch print trong test, ta chạy test lại:

$ python3 -m unittest tests.game_test
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Bây giờ các test đỗ nhưng kết quả trông gọn gàng hơn.

Ta sẽ tạo method để so sánh lá bài của người chơi và chủ nhà. Các bạn bây giờ có lẽ dã quen với cách làm rồi. Ta sẽ tạo stub method:

    def compare_card(self, house_card, player_card):
        pass

Tạo test

import unittest
import mock
import pydealer
...
    def test_compare_card(self):
        side_effects = [
            # 1st game
            Stack(cards=[Card("Ace", "Spades")]), Stack(cards=[Card("2", "Spades")]),
            # 2nd game
            Stack(cards=[Card("King", "Spades")]), Stack(cards=[Card("8", "Hearts")]),
            # 3rd game
            Stack(cards=[Card("Ace", "Hearts")]), Stack(cards=[Card("King", "Clubs")]),
            # 4th game
            Stack(cards=[Card("5", "Hearts")]), Stack(cards=[Card("5", "Diamonds")])
        ]

        with mock.patch.object(pydealer.Deck, "deal", side_effect=side_effects):
            game = CardGame()
            result = game.compare_card(game.deck.deal(1), game.deck.deal(1))
            self.assertEqual(result, "higher")
            result = game.compare_card(game.deck.deal(1), game.deck.deal(1))
            self.assertEqual(result, "lower")
            result = game.compare_card(game.deck.deal(1), game.deck.deal(1))
            self.assertEqual(result, "higher")
            result = game.compare_card(game.deck.deal(1), game.deck.deal(1))
            self.assertEqual(result, "equal")

Ta tạo mock cho pydealer.Deck.deal tương tự như game.deck.deal như ở trên nhưng lần này bằng context manager. Cứ với hai lá bài, ta kiểm tra xem quân bài thứ hai cao hay thấp hơn quân bài thứ nhất bằng lệnh assertEqual. Cố gắng tạo mock cho tất cả các trường hợp quân bài cao, thấp, bằng nhau.

Chạy test xem có fail không:

F...
======================================================================
FAIL: test_compare_card (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 65, in test_compare_card
    self.assertEqual(result, "higher")
AssertionError: None != 'higher'

----------------------------------------------------------------------
Ran 4 tests in 0.002s

FAILED (failures=1)

Test này fail. Nếu để ý, bạn sẽ thấy test fail này được chạy trước so với các test trước. Khi tạo test chúng ta phải tạo test như thế này mà thứ tự của test không quan trong và các test chạy không ảnh hưởng gì đến nhau.

Implement code:

    def compare_card(self, house_card, player_card):
        if player_card[0].gt(house_card[0]):
            return "higher"
        elif player_card[0].eq(house_card[0]):
            return "equal"
        else:
            return "lower"

Code trên khá là dễ hiểu. Ta lấy quân bài đầu (và duy nhất) trong tay của chủ nhà và so sánh với quân bài đầu trong tay của người chơi.

Kiểm tra lại kết quả

python3 -m unittest tests.game_test
F...
======================================================================
FAIL: test_compare_card (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 64, in test_compare_card
    self.assertEqual(result, "higher")
AssertionError: 'lower' != 'higher'
- lower
+ higher


----------------------------------------------------------------------
Ran 4 tests in 0.002s

FAILED (failures=1)

Test của chúng ta fail, có một bug ta không biết.
Nếu bạn xem ở trong API của pydealer , thứ tự cao thấp của bộ bài sẽ được định nghĩa theo DEFAULT RANK.
https://pydealer.readthedocs.io/en/latest/code.html?highlight=pydealer.const.default_ranks#pydealer.const.DEFAULT_RANKS

Chúng ta muốn thứ tự cao thấp như sau:
A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K.
Cả 4 bộ ♥ (cơ), ♦ (rô), ♣ (tép), ♠ (bích), có giá trị bằng nhau

Thay đổi code để có thứ tự như vậy:

import pydealer


class CardGame:
    CARD_RANK = {
        "values": {
            "King": 13,
            "Queen": 12,
            "Jack": 11,
            "10": 10,
            "9": 9,
            "8": 8,
            "7": 7,
            "6": 6,
            "5": 5,
            "4": 4,
            "3": 3,
            "2": 2,
            "Ace": 1,
        },
        "suits": {
            "Spades": 1,
            "Hearts": 1,
            "Clubs": 1,
            "Diamonds": 1
        }
    }
...
    def compare_card(self, house_card, player_card):
        if player_card[0].gt(house_card[0], CardGame.CARD_RANK):
            return "higher"
        elif player_card[0].eq(house_card[0], CardGame.CARD_RANK):
            return "equal"
        else:
            return "lower"

Lưu ý, khi so sánh card ta cũng sử dụng thứ tự cao thấp mới.

Chạy test:

$ python3 -m unittest tests.game_test
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK

Test của chúng ta đã đỗ. Code trông khá là gọn gàng, không cần phải refactor thêm gì.
Bạn có thể thấy, Unit Test sẽ giúp chúng ta tạo code có kêt quả đúng như yêu cầu. Nếu như tạo code chỉ để chạy được, thì ta sẽ dùng nhầm DEFAULT_RANK và bỏ qua bước trên mà không biết. Về sau, debug sẽ rất khó khăn.

Bây giờ ta thêm code để người dùng có thể chọn tiếp tục hay dừng ván.
Stub code:

    def player_continue(self):
        pass

Tạo test:

    @mock.patch("builtins.input")
    @mock.patch("builtins.print")
    def test_stop_continue(self, fake_print, fake_inputs):
        fake_inputs.side_effect = ["c", "conTinue", "S", "sto", "Stop"]
        game = CardGame()
        stop_or_continue = game.player_continue()
        self.assertEqual(stop_or_continue, "continue")
        stop_or_continue = game.player_continue()
        self.assertEqual(stop_or_continue, "continue")
        stop_or_continue = game.player_continue()
        self.assertEqual(stop_or_continue, "stop")
        stop_or_continue = game.player_continue()
        self.assertEqual(stop_or_continue, "stop")

Trong code test này ta tạo 2 mock bằng @mock.patch. Một mock cho input người chơi như ở trên, để làm giả lệnh của người chơi và một mock print để kết quả test trông không bị lộn xộn (ta không sử dụng mock này trong test). Để ý, ta định nghĩa @mock.patch input, sau đó mới đến print. Nhưng test method lại định nghĩa argument fake_print sau đó mới đến fake_inputs. Lý do vì patch của print được áp dụng vào trước, sau đó mới đến input:
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch
Bạn có thể tưởng tượng lệnh patch như tạo một lớp ở ngoài method, như củ hành. Trong lõi là method, lớp tiếp theo là print, lớp ngoài cùng là input.

Code test này tương tự như code để chúng ta test người chơi đoán cao thấp test_player_guess. Mock dự đoán của người dùng "continue" hay "c" là tiếp tục chơi, "stop" (Stop) hay "s" ("S") là để dừng. "Sto" là để mô phỏng input không hợp lệ của người chơi, và buộc người chơi phải cho input hợp lệ là "Stop".

Chạy test:

$ python3 -m unittest tests.game_test
....F
======================================================================
FAIL: test_stop_continue (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 78, in test_stop_continue
    self.assertEqual(stop_or_continue, "continue")
AssertionError: None != 'continue'

----------------------------------------------------------------------
Ran 5 tests in 0.003s

FAILED (failures=1)

Test của chúng ta fail đúng như dự đoán. Viết code để nhận input người chơi dừng hay tiếp tục:

    def process_stop_or_continue(self, command):
        if command.lower() == "stop" or command.lower() == "s":
            return "stop"
        elif command.lower() == "continue" or command.lower() == "c":
            return "continue"
        else:
            return command

    def player_continue(self):
        print("Would you like to continue(c) or stop(s)?")
        stop_or_continue = input()
        stop_or_continue = self.process_stop_or_continue(stop_or_continue)
        while stop_or_continue != "stop" and stop_or_continue != "continue":
            print("Invalid input '%s', type 'continue(c)' or 'stop(s)':" % stop_or_continue)
            stop_or_continue = input()
            stop_or_continue = self.process_stop_or_continue(stop_or_continue)
        return stop_or_continue

Code này ta gần như copy code đoán cao thấp ở method process_player_guess, player_guess. Nhưng bây giờ ta chỉ cần viết code để test của chúng ta đỗ thôi, không cần quan tâm gọn gàng hay không.

Chạy lại test

$ python3 -m unittest tests.game_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.003s

OK

Test của chúng ta đỗ. Nhưng bây giờ ta nên refactor lại method player_guessplayer continue để code không bị lặp lại như sau:

    def process_input(self, user_input, pos_inputs):
        for pos_input in pos_inputs:
            if user_input.lower() == pos_input or user_input.lower() == pos_input[0]:
                return pos_input
        return user_input

    def get_player_input(self, possible_inputs):
        player_input = input()
        player_input = self.process_input(player_input, possible_inputs)
        while player_input not in possible_inputs:
            possible_inputs_str = " or ".join([pos_input + "(" + pos_input[0] + ")" for pos_input in possible_inputs])
            invalid_msg = "Invalid input '%s', type %s" % (player_input, possible_inputs_str)
            print(invalid_msg)
            player_input = input()
            player_input = self.process_input(player_input, possible_inputs)
        return player_input

    def player_guess(self):
        house_card_info = self.get_card_info(self.house_card[0])
        print("Is your card lower(l) or higher(h) than %s" % house_card_info)
        possible_inputs = ["lower", "higher"]
        guess = self.get_player_input(possible_inputs)
        return guess

    def player_continue(self):
        print("Would you like to continue(c) or stop(s)?")
        possible_inputs = ["continue", "stop"]
        stop_or_continue = self.get_player_input(possible_inputs)
        return stop_or_continue

Code của chúng ta trông gọn gàng hơn. Bây giờ kiểm tra xem cả test cũ cho player_guess và test mới player_continue có ok không. Chạy test:

python3 -m unittest tests.game_test
.....
----------------------------------------------------------------------
Ran 5 tests in 0.003s

OK

Test ok. Vậy sau khi refactor, cả code mới và cũ có output đúng như mong đợi.

Bây giờ ta cũng nên refactor lại file test vì code này cũng bị lặp nhiều lần game = CardGame(). Refactor lại test:

import unittest
import mock
import pydealer

from card_game import CardGame
from pydealer import Stack, Card


class GameTest(unittest.TestCase):
    def setUp(self):
        self.game = CardGame()

    def test_deal_card(self):
        """Test if house and player each has a single card"""
        self.assertEqual(self.game.deck.size, 52)
        self.assertEqual(self.game.house_card.size, 0)
        self.assertEqual(self.game.player_card.size, 0)
        self.game.deal_card()
        self.assertEqual(self.game.deck.size, 50)
        self.assertEqual(self.game.house_card.size, 1)
        self.assertEqual(self.game.player_card.size, 1)

    def test_reveal_card(self):
        """Test if house card is revealed correctly"""
        side_effects = [
            # 1st game
            Stack(cards=[Card("Ace", "Spades")]),
            Stack(cards=[Card("2", "Hearts")])
        ]
        self.game.deck.deal = mock.Mock(side_effect=side_effects)
        self.game.deal_card()
        with mock.patch("builtins.print") as fake_print:
            self.game.reveal_house_card()
            fake_print.assert_called_with("House has A♠")

    @mock.patch("builtins.print")
    def test_player_guess(self, fake_print):
        input_side_effects = ["l", "h", "abc", "higher"]
        with mock.patch("builtins.input", side_effect=input_side_effects):
            self.game.house_card = Stack(cards=[Card("Ace", "Spades")])
            guess = self.game.player_guess()
            self.assertEqual(guess, "lower")
            guess = self.game.player_guess()
            self.assertEqual(guess, "higher")
            guess = self.game.player_guess()
            self.assertEqual(guess, "higher")

    def test_compare_card(self):
        side_effects = [
            # 1st game
            Stack(cards=[Card("Ace", "Spades")]), Stack(cards=[Card("2", "Spades")]),
            # 2nd game
            Stack(cards=[Card("King", "Spades")]), Stack(cards=[Card("8", "Hearts")]),
            # 3rd game
            Stack(cards=[Card("Ace", "Hearts")]), Stack(cards=[Card("King", "Clubs")]),
            # 4th game
            Stack(cards=[Card("5", "Hearts")]), Stack(cards=[Card("5", "Diamonds")])
        ]

        with mock.patch.object(pydealer.Deck, "deal", side_effect=side_effects):
            result = self.game.compare_card(self.game.deck.deal(1), self.game.deck.deal(1))
            self.assertEqual(result, "higher")
            result = self.game.compare_card(self.game.deck.deal(1), self.game.deck.deal(1))
            self.assertEqual(result, "lower")
            result = self.game.compare_card(self.game.deck.deal(1), self.game.deck.deal(1))
            self.assertEqual(result, "higher")
            result = self.game.compare_card(self.game.deck.deal(1), self.game.deck.deal(1))
            self.assertEqual(result, "equal")

    @mock.patch("builtins.input")
    @mock.patch("builtins.print")
    def test_stop_continue(self, fake_print, fake_inputs):
        fake_inputs.side_effect = ["c", "conTinue", "S", "sto", "Stop"]
        stop_or_continue = self.game.player_continue()
        self.assertEqual(stop_or_continue, "continue")
        stop_or_continue = self.game.player_continue()
        self.assertEqual(stop_or_continue, "continue")
        stop_or_continue = self.game.player_continue()
        self.assertEqual(stop_or_continue, "stop")
        stop_or_continue = self.game.player_continue()
        self.assertEqual(stop_or_continue, "stop")

if __name__ == '__main__':
    unittest.main()

Để ý là bây giờ chúng ta tạo game ở trong method setUp của test thôi. Tất cả test đều gọi self.game. Code trong setUp sẽ luôn được chạy trước từng test method. Bây giờ thay đổi chưa có ý nghĩa gì quan trọng lắm, nhưng đây là việc nên làm.

Sau khi test hết các code bên trên, chúng ta sẽ kết hợp các code đã tạo từ trước đến nay để tạo một trò chơi hoàn chỉnh.
Chúng ta tạo code stub để bắt đầu trò chơi:

    def start_game(self):
        pass

Bây giờ chúng ta tạo test gần như là Integration Test. Chúng ta sẽ không kết hợp code với một system khác, và cũng không kết hợp nhiều module khác nhau. Nhưng chúng ta sẽ tạo một test cho method mà kết hợp tất cả các code ở bên trên:

    @mock.patch("builtins.input")
    def test_start_game(self, fake_input):
        fake_input.side_effect = ["l", "c", "h", "c", "h", "s", "l", "c", "h"]

        deal_side_effects = [
            # 1st match
            Stack(cards=[Card("8", "Spades")]), Stack(cards=[Card("2", "Hearts")]),
            # 2nd match
            Stack(cards=[Card("Ace", "Clubs")]), Stack(cards=[Card("Ace", "Hearts")]),
            # 3rd match
            Stack(cards=[Card("Ace", "Spades")]), Stack(cards=[Card("Jack", "Hearts")]),
            # 4th match
            Stack(cards=[Card("9", "Diamonds")]), Stack(cards=[Card("5", "Diamonds")]),
            # 5th match
            Stack(cards=[Card("3", "Hearts")]), Stack(cards=[Card("2", "Diamonds")]),
        ]

        self.game.deck.deal = mock.Mock(side_effect=deal_side_effects)
        self.assertRaises(StopIteration, self.game.start_game)
        # After 1st match: 60 - 30 + 20
        # After 2nd match (Draw): 60 - 30 + 20
        # After 3rd match: 60 - 30 + (20 * 2) = 70 (STOP)
        # After 4th match: 70 - 30 + 20
        # After 5th match (Lose): 70 - 30 + 0 = 40
        # Stop before 5th match (Lose): 40 - 30 = 10
        self.assertEqual(self.game.player_score, 10)

Ta mock input của người chơi bằng patch. Ta kết hợp input lúc người chơi đoán cao thấp và tiếp tục hay dừng chơi.

Ta sẽ tạo mock để chia quân đúng như mong muốn.
Để ý là lần này ta gọi assertRaises để bắt lỗi StopIteration và chuyền method mới self.game.start_game vào. Lưu ý, là ta không gọi method self.game.start_game mà chỉ chuyền vào thôi. Ở trên, ta mock game.deck.deal với 10 quân bài. Nghĩa là ta có thể gọi game.deck.deal 10 lần không vấn đề. Lỗi StopIteration sẽ xảy ra nếu ta gọi game.deck.deal lần thứ 11. Lúc đấy mock không còn giá trị để trả lại nữa.

Tiếp theo ta thử nghĩ xem trò chơi sẽ diển ra như thế nào với các input của người chơi và cách chia bài như trên.

  • Ván đầu chúng ta thắng nhưng quyết định chơi tiếp.
  • Ván sau, chúng ta hòa.
  • Tiếp theo chúng ta thắng nhưnh quyết định chơi tiếp, và số điểm của người chơi sẽ là 70.
  • 2 lượt chơi tiếp theo chúng ta thua liên tục hai lần, và mất 30 điểm mỗi lần. Chúng ta thua trò chơi

Ta chỉ cần xem kết số điểm của người chơi cuối cùng có đúng như mong đợi hay không

Chạy test:

$ python3 -m unittest tests.game_test
....F.
======================================================================
FAIL: test_start_game (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 101, in test_start_game
    self.assertRaises(StopIteration, game.start_game)
AssertionError: StopIteration not raised by start_game

----------------------------------------------------------------------
Ran 6 tests in 0.004s

FAILED (failures=1)

Test fail.

Ta thêm code mới để biểu diễn lá bài của người chơi.

    ...
    def reveal_house_card(self): #Alreadyい
        house_card_info = self.get_card_info(self.house_card[0])
        print("House has %s" % house_card_info)

    def reveal_player_card(self):
        player_card_info = self.get_card_info(self.player_card[0])
        print("You have %s" % player_card_info)
    ...

Code này giống code biểu diễn bài của chủ nhà. Ta có thể tạo test nhưng không cần thiết lắm.

Kết hợp code để bắt đầu chơi game:

    def start_game(self):
        print("Game Start!")
        print("You have %d points" % self.player_score)
        print("Cost for playing is %d points" % self.cost)
        print("Initial reward is %d points" % self.reward)
        matches = 1
        while self.player_score < 1000 and self.player_score >= 30:
            player_continue = True
            print("\nMatch %d" % matches)
            print("Start new match, -%d cost for playing" % self.cost)
            self.player_score -= 30
            tmp_reward = self.reward

            while player_continue:
                print("\nYour current score is: %d" % self.player_score)
                self.deal_card()
                self.reveal_house_card()
                guess = self.player_guess()
                result = self.compare_card(self.house_card, self.player_card)
                self.reveal_player_card()
                if guess == result:
                    print("Correct! You earned +%d reward points" % (tmp_reward))
                    player_continue = self.player_continue()
                    if player_continue == "stop":
                        self.player_score += tmp_reward
                        break
                    else:
                        tmp_reward = tmp_reward * 2
                else:
                    print("Incorrect! You lose -%d reward points " % (tmp_reward))
                    break
            matches += 1

Code ở trên được đề cập đến ở tài liệu yêu cầu. Ta in các thông tin tính toán điểm cho người chơi. Người chơi tiếp tục chơi cho đến khi được coi là thua hay thắng. Khi chơi mỗi ván, người chơi bị trừ 30 điểm.
Phần quan trọng nhất ta gọi các method chúng ta đã code và test theo thứ tự:

  • self.deal_card(): chia bài
  • self.reveal_house_card(): biểu diễn quân bài của chủ nhà cho người chơi.
  • self.player_guess(): Lấy dự đoán của người chơi
  • self.player_continue(): Nếu đoán đúng thì hỏi người chơi có muốn chơi tiếp không. Nếu dừng thì giữ lại số điểm thưởng. Nếu chơi tiếp thì điểm thưởng tăng gấp đôi.

Chạy test:

$ python3 -m unittest tests.game_test
You have 60 points
Cost for playing is 30 points
Initial reward is 20 points

Match 1
Start new match, -30 cost for playing

Your current score is: 30
House has 8♠
Is your card lower(l) or higher(h) than 8♠
You have 2♥
Correct! You earned +20 reward points
Would you like to continue(c) or stop(s)?

Your current score is: 30
House has 8♠
Is your card lower(l) or higher(h) than 8♠
You have 2♥
Incorrect! You lose -40 reward points 

Match 2
Start new match, -30 cost for playing

Your current score is: 0
House has 8♠
Is your card lower(l) or higher(h) than 8♠
Invalid input 'c', type lower(l) or higher(h)
You have 2♥
Incorrect! You lose -20 reward points 
F.
======================================================================
FAIL: test_start_game (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 101, in test_start_game
    self.assertRaises(StopIteration, game.start_game)
AssertionError: StopIteration not raised by start_game

----------------------------------------------------------------------
Ran 6 tests in 0.004s

FAILED (failures=1)

Có gì đó sai ở đây, lá bài của người chơi và chủ nhà không thay đổi sau mỗi ván. Nguyên nhân là sau một ván, người chơi và chủ nhà không trả lại quân bài

Ta tạo code stub để trả lại quân bài:

    def return_card(self):
        pass

Tạo test để trả lại quân bài:

    def test_deal_return_card(self):
        self.assertEqual(self.game.deck.size, 52)
        self.game.deal_card()
        tmp_house_card = "%s of %s" % (self.game.house_card[0].value, self.game.house_card[0].suit)
        tmp_player_card = "%s of %s" % (self.game.player_card[0].value, self.game.player_card[0].suit)
        self.assertEqual(self.game.deck.size, 50)
        self.assertEqual(self.game.deck.find(tmp_house_card), [])
        self.assertEqual(self.game.deck.find(tmp_player_card), [])

        self.game.return_card()
        self.assertEqual(self.game.deck.size, 52)
        self.assertNotEqual(self.game.deck.find(tmp_house_card), [])
        self.assertNotEqual(self.game.deck.find(tmp_player_card), [])

        self.game.deal_card()
        self.game.deal_card()
        self.assertEqual(self.game.deck.size, 48)
        self.game.return_card()
        self.assertEqual(self.game.deck.size, 52)

Test này ta không phải mock gì. Ta sẽ kiểm tra số bài trước khi chia, sau khi chia, trả lại quân bài. Đồng thời ta sẽ kiểm tra quân bài được chia ra có còn trong bộ bài không, và khi trả lại thì có trong bộ bài không.

Chạy test:

======================================================================
FAIL: test_deal_return_card (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 116, in test_deal_return_card
    self.assertEqual(game.deck.size, 52)
AssertionError: 50 != 52

Test fail. Bây giờ viết code để trả lại lá bài, đồng thời kết hợp code trả bài vào start_game:

    def return_card(self):
        house_cards = self.house_card.empty(return_cards=True)
        player_cards = self.player_card.empty(return_cards=True)
        self.deck.add(cards=house_cards)
        self.deck.add(cards=player_cards)
        self.deck.shuffle()
        
    def start_game(self):
        print("Game Start!")
        print("You have %d points" % self.player_score)
        print("Cost for playing is %d points" % self.cost)
        print("Initial reward is %d points" % self.reward)
        matches = 1
        while self.player_score < 1000 and self.player_score >= 30:
            player_continue = True
            print("\nMatch %d" % matches)
            print("Start new match, -%d cost for playing" % self.cost)
            self.player_score -= 30
            tmp_reward = self.reward

            while player_continue:
                print("\nYour current score is: %d" % self.player_score)
                self.deal_card()
                self.reveal_house_card()
                guess = self.player_guess()
                result = self.compare_card(self.house_card, self.player_card)
                self.reveal_player_card()
                self.return_card()
                if guess == result:
                    print("Correct! You earned +%d reward points" % (tmp_reward))
                    player_continue = self.player_continue()
                    if player_continue == "stop":
                        self.player_score += tmp_reward
                        break
                    else:
                        tmp_reward = tmp_reward * 2
                else:
                    print("Incorrect! You lose -%d reward points " % (tmp_reward))
                    break
                self.return_card()
            matches += 1

Chạy test lại:

$ python3 -m unittest tests.game_test
.....Game Start!
You have 60 points
Cost for playing is 30 points
Initial reward is 20 points

Match 1
Start new match, -30 cost for playing

Your current score is: 30
House has 8♠
Is your card lower(l) or higher(h) than 8♠
You have 2♥
Correct! You earned +20 reward points
Would you like to continue(c) or stop(s)?

Your current score is: 30
House has A♣
Is your card lower(l) or higher(h) than A♣
You have A♥
Incorrect! You lose -40 reward points 

Match 2
Start new match, -30 cost for playing

Your current score is: 0
House has A♠
Is your card lower(l) or higher(h) than A♠
Invalid input 'c', type lower(l) or higher(h)
You have J♥
Correct! You earned +20 reward points
Would you like to continue(c) or stop(s)?
F.
======================================================================
FAIL: test_start_game (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 100, in test_start_game
    self.assertRaises(StopIteration, self.game.start_game)
AssertionError: StopIteration not raised by start_game

----------------------------------------------------------------------
Ran 7 tests in 0.006s

FAILED (failures=1)

Bây giờ sau mỗi ván, bài của chủ nhà và người chơi khác với trước. Đúng như mong đợi. Tuy nhiên ta vẫn có lỗi ở đây:

Your current score is: 30
House has A♣
Is your card lower(l) or higher(h) than A♣
You have A♥
Incorrect! You lose -40 reward points 

Khi chủ nhà và người chơi có lá bài bằng nhau, người chơi đoán thấp hay cao đều thua. Như vậy là không công bằng. Trong tài liệu yêu cầu, đúng là không đề cập đến việc này. Nhưng nhờ có unit test, ta có thể xử lý được việc này. Khi lá bài bằng nhau:

  • Hỏi người chơi có muốn tiếp tục chơi hay không.
  • Nếu người chơi dừng hay không thì đều dùng số điểm thưởng trước.

Thêm điều kiện để xử lý khi quân bài bằng nhau:

    def start_game(self):
        print("Game Start!")
        print("You have %d points" % self.player_score)
        print("Cost for playing is %d points" % self.cost)
        print("Initial reward is %d points" % self.reward)
        matches = 1
        while self.player_score < 1000 and self.player_score >= 30:
            player_continue = True
            print("\nMatch %d" % matches)
            print("Start new match, -%d cost for playing" % self.cost)
            self.player_score -= 30
            tmp_reward = self.reward

            while player_continue:
                print("\nYour current score is: %d" % self.player_score)
                self.deal_card()
                self.reveal_house_card()
                guess = self.player_guess()
                result = self.compare_card(self.house_card, self.player_card)
                self.reveal_player_card()
                self.return_card()
                if guess == result:
                    print("Correct! You earned +%d reward points" % (tmp_reward))
                    player_continue = self.player_continue()
                    if player_continue == "stop":
                        self.player_score += tmp_reward
                        break
                    else:
                        tmp_reward = tmp_reward * 2
                elif guess != result and result == "equal":
                    print("Cards were equal. Your reward points is still %s" % tmp_reward)
                    player_continue = self.player_continue()
                    if player_continue == "stop":
                        self.player_score += tmp_reward
                        break
                else:
                    print("Incorrect! You lose -%d reward points " % (tmp_reward))
                    break
                self.return_card()
            matches += 1

Chạy lại test:

$ python3 -m unittest tests.game_test
.....Game Start!
You have 60 points
Cost for playing is 30 points
Initial reward is 20 points

Match 1
Start new match, -30 cost for playing

Your current score is: 30
House has 8♠
Is your card lower(l) or higher(h) than 8♠
You have 2♥
Correct! You earned +20 reward points
Would you like to continue(c) or stop(s)?

Your current score is: 30
House has A♣
Is your card lower(l) or higher(h) than A♣
You have A♥
Cards were equal. Your reward points is still 40
Would you like to continue(c) or stop(s)?

Your current score is: 30
House has A♠
Is your card lower(l) or higher(h) than A♠
You have J♥
Correct! You earned +40 reward points
Would you like to continue(c) or stop(s)?

Match 2
Start new match, -30 cost for playing

Your current score is: 40
House has 9♦
Is your card lower(l) or higher(h) than 9♦
You have 5♦
Correct! You earned +20 reward points
Would you like to continue(c) or stop(s)?

Your current score is: 40
House has 3♥
Is your card lower(l) or higher(h) than 3♥
You have 2♦
Incorrect! You lose -40 reward points 

Match 3
Start new match, -30 cost for playing

Your current score is: 10
..
----------------------------------------------------------------------
Ran 7 tests in 0.005s

OK

Bây giờ tất cả các test đều đỗ.

Refactor test để print không bị lộn xộn:

    @mock.patch("builtins.print")
    @mock.patch("builtins.input")
    def test_start_game(self, fake_input, fake_print):

Đồng thời, refactor lại code để trông gọn hơn. Phân chia giữa việc chơi một ván bài và chơi trò chơi:

    def play_match(self):
        player_continue = True
        tmp_reward = self.reward
        while player_continue:
            print("\nYour current score is: %d" % self.player_score)
            self.deal_card()
            self.reveal_house_card()
            guess = self.player_guess()
            result = self.compare_card(self.house_card, self.player_card)
            self.reveal_player_card()
            self.return_card()
            if guess == result:
                print("Correct! You earned +%d reward points" % (tmp_reward))
                player_continue = self.player_continue()
                if player_continue == "stop":
                    self.player_score += tmp_reward
                    break
                else:
                    tmp_reward = tmp_reward * 2
            elif guess != result and result == "equal":
                print("Cards were equal. Your reward points is still %s" % tmp_reward)
                player_continue = self.player_continue()
                if player_continue == "stop":
                    self.player_score += tmp_reward
                    break
            else:
                print("Incorrect! You lose -%d reward points " % (tmp_reward))
                break

    def start_game(self):
        print("Game Start!")
        print("You have %d points" % self.player_score)
        print("Cost for playing is %d points" % self.cost)
        print("Initial reward is %d points" % self.reward)
        matches = 1
        while self.player_score < 1000 and self.player_score >= 30:
            print("\nMatch %d" % matches)
            print("Start new match, -%d cost for playing" % self.cost)
            self.player_score -= 30
            self.play_match()
            matches += 1

Chạy lại test:

python3 -m unittest tests.game_test
.......
----------------------------------------------------------------------
Ran 7 tests in 0.005s

OK

Sau khi refactor test và code, tất cả test của chúng ta đều đỗ

Cuối cùng, tạo test để kiểm tra người chơi thắng hay thua được biểu diễn như thế nào.

Tạo stub code:

    def start_game(self):
        print("Game Start!")
        print("You have %d points" % self.player_score)
        print("Cost for playing is %d points" % self.cost)
        print("Initial reward is %d points" % self.reward)
        matches = 1
        game_over = False
        while not game_over:
            print("\nMatch %d" % matches)
            print("Start new match, -%d cost for playing" % self.cost)
            self.player_score -= 30
            self.play_match()
            matches += 1
            game_over = self.check_game_over()

    def check_game_over(self):
        pass

Tạo test code:

    @mock.patch("builtins.input")
    @mock.patch("builtins.print")
    def test_lose(self, fake_print, fake_input):
        deal_side_effects = [
            # 1st match
            Stack(cards=[Card("3", "Spades")]), Stack(cards=[Card("2", "Hearts")]),
            # 2nd match
            Stack(cards=[Card("Queen", "Clubs")]), Stack(cards=[Card("King", "Hearts")]),
        ]

        fake_input.side_effect = ["h", "l"]
        game = CardGame()
        game.deck.deal = mock.Mock(side_effect=deal_side_effects)
        game.start_game()
        # After 1st match (Lose): 60 - 30 + 0 = 30
        # After 2nd match (Lose): 30 - 30 + 0 = 0
        self.assertEqual(game.player_score, 0)
        fake_print.assert_called_with("You lost with 0 points.")

    @mock.patch("builtins.input")
    @mock.patch("builtins.print")
    def test_win(self, fake_print, fake_input):
        deal_side_effects = [
            # 1st match
            Stack(cards=[Card("3", "Spades")]), Stack(cards=[Card("Ace", "Hearts")]),
            # 2nd match
            Stack(cards=[Card("8", "Clubs")]), Stack(cards=[Card("King", "Hearts")]),
            # 3rd match
            Stack(cards=[Card("Ace", "Spades")]), Stack(cards=[Card("Queen", "Hearts")]),
            # 4th match
            Stack(cards=[Card("9", "Diamonds")]), Stack(cards=[Card("5", "Diamonds")]),
        ]

        fake_input.side_effect = ["l", "c", "h", "c", "h", "c", "l", "s"]
        game = CardGame()
        game.deck.deal = mock.Mock(side_effect=deal_side_effects)
        game.player_score = 990
        game.start_game()
        # After 1st match: 990 - 30 + 20
        # After 2nd match: 990 - 30 + (20 * 2) (Already at 1000 here, but can still keep playing)
        # After 3rd match: 990 - 30 + (20 * 2 * 2)
        # After 4th match: 990 - 30 + (20 * 2 * 2 * 2) = 1120
        self.assertEqual(game.player_score, 1120)
        fake_print.assert_called_with("Congratulations! You won with 1120 points!")

Code ở trên có 2 unittest, mô phỏng game trường hợp người chơi thắng hoặc thua.

Chạy test:

...E....E
======================================================================
ERROR: test_lose (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 142, in test_lose
    self.game.start_game()
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 162, in start_game
    self.play_match()
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 133, in play_match
    result = self.compare_card(self.house_card, self.player_card)
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 81, in compare_card
    if player_card[0].gt(house_card[0], CardGame.CARD_RANK):
  File "/home/long/.local/lib/python3.6/site-packages/pydealer/card.py", line 244, in gt
    ranks["values"][self.value] >
KeyError: 'K'

======================================================================
ERROR: test_win (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 167, in test_win
    self.game.start_game()
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 162, in start_game
    self.play_match()
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 130, in play_match
    self.deal_card()
  File "/home/long/PycharmProjects/card_game_check/card_game.py", line 38, in deal_card
    self.house_card.add(self.deck.deal(1))
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1092, in __call__
    return _mock_self._mock_call(*args, **kwargs)
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1145, in _mock_call
    result = next(effect)
StopIteration

----------------------------------------------------------------------
Ran 9 tests in 0.008s

FAILED (errors=2)

Bây giờ thêm code:

    def check_game_over(self):
        if self.player_score >= 1000:
            print("Congratulations! You won with %s points!" % self.player_score)
            return True
        elif self.player_score < 30:
            print("You lost with %s points." % self.player_score)
            return True
        else:
            return False

Chạy test:

$ python3 -m unittest tests.game_test
.........
----------------------------------------------------------------------
Ran 9 tests in 0.007s

OK

Bây giờ game của chúng ta đã hoàn thành.
Tuy nhiên, thêm một điều hay nữa ta có thể làm đó là chạy để kiểm tra coverage, bao nhiêu phần trăm code đã được test.
Chạy lệnh ở dưới đây:

$ coverage run -m tests.game_test
.........
----------------------------------------------------------------------
Ran 9 tests in 0.010s

OK

Coverage sẽ chạy lại test và báo cáo coverage.

Chạy test dưới đây để xem báo cáo:

$ coverage report -m card_game.py 
Name           Stmts   Miss  Cover   Missing
--------------------------------------------
card_game.py     127      1    97%   52, 148-149

Chúng ta đã test được 97% code. Khá là ổn. Ta chưa test dòng 52 và 148-149.

Ta có thể tăng coverage lên 100% được không?

Muốn thế, đầu tiên ta phải xem code ở dòng 52. Trong code của tôi là dòng 52, nhưng có code của bạn có thể là dòng khác:

    @staticmethod
    def get_suit_symbol(card):
        if card.suit == "Spades":
            return "♠"
        elif card.suit == "Hearts":
            return "♥"
        elif card.suit == "Clubs":
            return "♣"
        elif card.suit == "Diamonds":
            return "♦"
        else:
            raise Exception("Cannot map suit to symbol, unknown suit %s" % card.suit) # Not tested

Code dòng 148 -149:

    def play_match(self):
    ...
            elif guess != result and result == "equal":
                print("Cards were equal. Your reward points is still %s" % tmp_reward)
                player_continue = self.player_continue()
                if player_continue == "stop":
                    self.player_score += tmp_reward
                    break
    ...

Ta có thể tạo test như sau để kiểm tra trường hợp dòng 52:

    def test_suit_error(self):
        """Test if exception is raised when you reveal a card with incorrect suit"""
        side_effects = [
            # 1st game
            Stack(cards=[Card("Ace", "NO SUIT")]),
            Stack(cards=[Card("Ace", "Hearts")])
        ]
        self.game.deck.deal = mock.Mock(side_effect=side_effects)
        self.game.deal_card()
        self.assertRaises(Exception, self.game.reveal_house_card)

Và test sau để kiểm tra trường hợp dòng 148-149:

    @mock.patch("builtins.input")
    @mock.patch("builtins.print")
    def test_equal_stop(self, fake_print, fake_input):
        deal_side_effects = [
            # 1st match
            Stack(cards=[Card("3", "Spades")]), Stack(cards=[Card("King", "Hearts")]),
            # 2nd match
            Stack(cards=[Card("Queen", "Clubs")]), Stack(cards=[Card("Queen", "Hearts")]),
        ]

        fake_input.side_effect = ["h", "c", "l", "s"]
        self.game.deck.deal = mock.Mock(side_effect=deal_side_effects)
        self.assertRaises(StopIteration, self.game.start_game)
        # After 1st match: 60 - 30 + 20
        # After 2nd match: 60 - 30 + (20 * 2) = 70
        # Stop before 5th match (Lose): 70 - 30 = 40
        self.assertEqual(self.game.player_score, 40)

Bây giờ ta chạy lại coverage:

coverage run -m tests.game_test
...........
----------------------------------------------------------------------
Ran 11 tests in 0.007s
coverage report -m card_game.py
OK
Name           Stmts   Miss  Cover   Missing
--------------------------------------------
card_game.py     127      0   100%

Bây giờ chúng ta đã kiểm tra 100% code. Kiểm tra coverage cũng không đảm bảo là code không có bug. Tuy nhiên coverage sẽ giúp ta xem có trường hợp nào ta implement trong code, mà chưa được test không. Trường hợp đó có thể gây bug.

Bây giờ thêm code để chạy game trong card_game.py

if __name__ == '__main__':
    game = CardGame()
    game.start_game()

Chạy game:

$ python3 card_game.py

Tôi đã thử chạy game và thấy không có vấn đề gì. Tuy không thể nào chắc chắn là code không có bug, sau các unittest, ta có thể thấy code chạy theo đúng yêu cầu.

Chơi trò này cũng ổn nhưng hơi khó, ta thử tăng điểm thưởng lên thành 50:

    def __init__(self):
        self.player_score = 60
        self.reward = 50
        self.cost = 30
        self.deck = pydealer.Deck()
        self.house_card = pydealer.Stack()
        self.player_card = pydealer.Stack()

Tuy ta chỉ thay đổi một dòng code, theo phương pháp TDD, bây giờ ta phải chạy lại test:

...F...F..F
======================================================================
FAIL: test_equal_stop (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 146, in test_equal_stop
    self.assertEqual(self.game.player_score, 40)
AssertionError: 100 != 40

======================================================================
FAIL: test_start_game (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 108, in test_start_game
    self.assertEqual(self.game.player_score, 10)
AssertionError: 70 != 10

======================================================================
FAIL: test_win (tests.game_test.GameTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/long/.local/lib/python3.6/site-packages/mock/mock.py", line 1330, in patched
    return func(*args, **keywargs)
  File "/home/long/PycharmProjects/card_game_check/tests/game_test.py", line 188, in test_win
    self.assertEqual(self.game.player_score, 1120)
AssertionError: 1360 != 1120

----------------------------------------------------------------------
Ran 11 tests in 0.009s

FAILED (failures=3)

Thay đổi một dòng code thôi mà ba test của ta đều bị fail. Nhưng nếu bạn nhớ ta đã tạo method setUp để tạo game cho tất cả test rồi. Để việc thay đổi các giá trị ban đầu code chính không ảnh hưởng đến test, chúng ta có thể tách cách tạo game chính và game để test ra.

Sửa code chính:

    def __init__(self, player_score=60, reward=20, cost=30):
        self.player_score = player_score
        self.reward = reward
        self.cost = cost
        self.deck = pydealer.Deck()
        self.house_card = pydealer.Stack()
        self.player_card = pydealer.Stack()
   ...
   if __name__ == '__main__':
    game = CardGame(reward=50)
    game.start_game()

Sửa code test:

    def setUp(self):
        self.game = CardGame(player_score=60, reward=20, cost=30)

Bây giờ ta có thể thay đổi điểm thưởng trong code chính mà test vẫn không thay đổi.

Chạy Test

$ python3 -m unittest tests.game_test
...........
----------------------------------------------------------------------
Ran 11 tests in 0.008s

OK

Tips

  • Tạo test với input bình thường và input có thể gây bug.
  • Chỉ test output của code, không nên test cách code hoạt động như thế nào.
  • Không nên tạo test lặp lại một test khác
  • Không viết code mà không làm test pass
  • Nếu như việc tạo test trở nên khó khăn, thì có nghĩa là:
    • Code cần được refactor.
    • Tài liệu yêu cầu có gì đó không rõ ràng, hoặc bạn không hiểu, cần xác nhận lại.
    • Thiết kế cần được thay đổi.

Điểm lợi của TDD

  • Tạo test trước khi viết code, khiến ta nghĩ về output của code trước khi code như thế nào.
  • Giúp ta chuẩn bị cho các vấn đề khi sáp nhập code mới vào hệ thống cũ.
  • Làm sáng tỏ mâu thuẫn nếu có giữa các yêu cầu trong tài liệu.
  • Tự tin là code quan trọng hoạt động đúng như mong đợi.
  • Tiết kiệm hàng chục tiếng tìm bug trong lập trình.

Điểm cần chú ý của TDD

  • Code bạn viết có thể tăng gấp đôi. Và phải maintain cả code chính và code để test. Ngược lại, thời gian tìm bug sẽ giảm đáng kể.
  • Qua được test không có nghĩa là code hoàn toàn không có bug.
  • Tạo test không cẩn thận có hại nhiều hơn lợi, nên suy nghĩ cẩn thận về cách tạo test, và cách code có thể gặp bug. Test không cẩn thận sẽ khiến ta tự tin một cách sai lệch.
  • Tạo test mất thời gian, nên nói chuyện với team và cấp trên là bạn sẽ áp dụng TDD để họ hiểu và điều chỉnh thời gian dự án cho hợp lý.

Kết luận

Một nghiên cứu về phương pháp TDD với 3 team ở Microsoft và 1 team ở IBM cho thấy:

  • Các team cảm thấy sử dụng TDD tăng thời gian lập trình ban đầu từ 15% đến 30%.
  • Tuy nhiên so với không sử dụng TDD, sử dụng TDD sẽ giảm 40% đến 90% các lỗi, trước khi sản phẩm đến tay QA và khách hàng.

Sử dụng phương thức TDD đòi hỏi mất thêm nhiều thời gian lập trình ban đầu. Ngược lại nó giúp giảm lỗi và tăng độ tin cậy của code. Chúc các bạn may mắn trong sử dụng phương pháp TDD trong dự án.