Now that our Game class is mostly functional, we can add some unit tests.
The first test I’ll write is just checking the game over functionality for a newly created game.
def test_game_with_unhit_ships_not_over(self):
ship_coords = {
(0,0): 'A',
(0,1): 'A'
}
game = Game(ship_coords, 5, 5)
self.assertFalse(game.game_over())
Next, I’ll verify attacking all the ship coordinates results in a game over. You’ll notice, I redefined ship_coords and created a new game object. You’ll usually want to create tests that are independent so you can run a single test or multiple tests without them affecting each other.
def test_game_with_all_ships_hit_game_over(self):
ship_coords = {
(0,0): 'A',
(0,1): 'A'
}
game = Game(ship_coords, 5, 5)
game.attack('a', '1')
game.attack('a', '2')
self.assertTrue(game.game_over())
You also usually want to test specific things. If you write a test that does too many things, it can become harder to understand and maintain.
def test_attacks_return_correct_result(self):
ship_coords = {
(0,0): 'A',
(0,1): 'A'
}
game = Game(ship_coords, 5, 5)
success = game.attack('a', '1')
missed = game.attack('b', '1')
out_of_bounds = game.attack('b', '100')
self.assertEqual(success, AttackResult.SUCCESS)
self.assertEqual(missed, AttackResult.MISSED)
self.assertEqual(out_of_bounds, AttackResult.INVALID)
It’s also a good idea to test failure cases, not just the happy path.
def test_game_with_invalid_input_throws_error(self):
ship_coords = {
(0,0): 'A',
(0,1): 'A'
}
game = Game(ship_coords, 5, 5)
with self.assertRaises(ValueError) as context:
game.attack('a', 'z')
Having our Game configurable for different parameters makes testing some cases a lot easier. i.e. we can create a much smaller board.
def test_get_board_layout_with_hits(self):
ship_coords = {
(0,0): 'A',
}
game = Game(ship_coords, 2, 2)
game.attack('a', '2')
game.attack('a', '1')
empty_coord = CoordInfo(CoordStatus.EMPTY, None)
hit_coord = CoordInfo(CoordStatus.SHIP_HIT, 'A')
missed_coord = CoordInfo(CoordStatus.SHIP_MISSED, None)
expected_layout = [
[hit_coord, missed_coord],
[empty_coord, empty_coord]
]
layout = game.get_board_layout()
self.assertEqual(expected_layout, layout)
A common practice for good unit testing is only testing the public api or the methods other classes directly use. Here it’s attack, game_over, get_board_layout. Private methods I’ve prefixed with an underscore i.e. _get_coord_status and _on_board.
We also usually don’t want to directly access internal fields of the class such as ship_coords.
This lets us update how we do things internally without affecting the users of this class. For instance, if we decided it makes sense to keep track of all the ships on a matrix instead of a dictionary, we could make that change and the classes that use Game don’t need to be changed.
Thoughts on Testing
Overall I think writing tests like this are worthwhile. I think they save you time in the long run because you don’t need to verify code works manually. If code is easily testable it tends to be organized well. Finally, as you make changes or build new functionality, it provides some level of safety since it verifies certain things continue to work as expected.
That being said, there are cases where you might not want to do this. If you’re building some small prototype that will be replaced or writing some kind of script that doesn’t have major consequences and won’t be reused, it might not be worth going through all this trouble. Also, if you’re just starting out and you’re not really sure how major parts of the code are going to be organized, you might want to wait until that shapes up a bit first.
Finally, unit tests won’t cover everything. Sometimes, there might be issues when multiple components work together or you might see certain things in real-life that you didn’t expect.
Project changeset.
Leave a reply to BattleShip Project Review – Level up SE Cancel reply