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
- Tạo test mới.
- Chạy tất cả test. Đảm bảo test mới thất bại.
- Viết code mới, để test mới đỗ (test pass).
- Chạy tất cả test. Đảm bảo tất cả test đều đỗ
- Refactor code
- 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)
Vì 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_guess
và player 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àiself.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ơiself.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.