Moving from `unittest` to `pytest`
In my two previous articles Unittesting in a Jupyter notebook and Mocking in unittests in Python I have discussed the use of unittest
and mock
to run tests for a simple Castle
and Character
class. For the code behind this article please check Github.
The classes
Let's recap the classes first.
Castle class
The Castle class has a name, boss and world property and a simple method to determine if a character has access bases on his powerup. Note that the classes have been cleaned up since the last article.
""" jj_classes/castle.py """
class Castle(object):
""" Defines the Castle class """
def __init__(self, name):
""" Initialize the class """
self._name = name
self._boss = "Bowser"
self._world = "Grass Land"
def has_access(self, character):
""" Check if character has access """
return character.powerup == "Super Mushroom"
def get_boss(self):
""" Returns the boss """
return self.boss
def get_world(self):
""" Returns the world """
return self.world
@property
def name(self):
""" Name of the castle """
return self._name
@property
def boss(self):
""" Boss of the castle """
return self._boss
@property
def world(self):
""" World of the castle """
return self._world
Character class
""" jj_classes/character.py """
class Character(object):
""" Defines the character class """
def __init__(self, name):
""" Initialize the class """
self._name = name
self._powerup = ""
def get_powerup(self):
""" Returns the powerup """
return self.powerup
@property
def name(self):
""" Name of the character """
return self._name
@property
def powerup(self):
""" Powerup of the character """
return self._powerup
@powerup.setter
def powerup(self, powerup):
""" Sets the powerup """
self._powerup = powerup
Unittests
In the previous articles, I've written two tests sets, one for Character and one for Character and Castle. Looking back at the tests now, I noticed that the Character/Castle testset is not very tidy so at the end I will simply have a set to test the Character
class and one to test the Castle
class.
""" tests/charactertestclass.py """
import unittest
import unittest.mock as mock
try:
from jj_classes.castle import Castle
except ModuleNotFoundError:
import sys, os
sys.path.insert(0, f"{os.path.dirname(os.path.abspath(__file__))}/../")
from jj_classes.castle import Castle
from jj_classes.character import Character
class CharacterTestClass(unittest.TestCase):
""" Defines the tests for the Character class """
def setUp(self):
""" Set the castle for the test cases """
self.castle = Castle("Bowsers Castle")
def test_mock_access_denied(self):
""" Access denied for star powerup """
mock_character = mock.Mock(powerup="Starman")
self.assertFalse(self.castle.has_access(mock_character))
def test_mock_access_granted(self):
""" Access granted for mushroom powerup """
mock_character = mock.Mock(powerup="Super Mushroom")
self.assertTrue(self.castle.has_access(mock_character))
def test_default_castle_boss(self):
""" Verifty the default boss is Bowser """
self.assertEqual(self.castle.get_boss(), "Bowser")
def test_default_castle_world(self):
""" Verify the default world is Grass Land """
self.assertEqual(self.castle.get_world(), "Grass Land")
# Mock a class method
@mock.patch.object(Castle, "get_boss")
def test_mock_castle_boss(self, mock_get_boss):
mock_get_boss.return_value = "Hammer Bro"
self.assertEqual(self.castle.get_boss(), "Hammer Bro")
self.assertEqual(self.castle.get_world(), "Grass Land")
# Mock an instance
@mock.patch(__name__ + ".Castle")
def test_mock_castle(self, MockCastle):
instance = MockCastle
instance.get_boss.return_value = "Toad"
instance.get_world.return_value = "Desert Land"
self.castle = Castle
self.assertEqual(self.castle.get_boss(), "Toad")
self.assertEqual(self.castle.get_world(), "Desert Land")
# Mock an instance method
def test_mock_castle_instance_method(self):
# Boss is still Bowser
self.assertNotEqual(self.castle.get_boss(), "Koopa Troopa")
# Set a return_value for the get_boss method
self.castle.get_boss = mock.Mock(return_value="Koopa Troopa")
# Boss is Koopa Troopa now
self.assertEqual(self.castle.get_boss(), "Koopa Troopa")
def test_castle_with_more_bosses(self):
multi_boss_castle = mock.Mock()
# Set a list as side_effect for the get_boss method
multi_boss_castle.get_boss.side_effect = ["Goomba", "Boo"]
# First value is Goomba
self.assertEqual(multi_boss_castle.get_boss(), "Goomba")
# Second value is Boo
self.assertEqual(multi_boss_castle.get_boss(), "Boo")
# Third value does not exist and raises a StopIteration
self.assertRaises(StopIteration, multi_boss_castle.get_boss)
def test_calls_to_castle(self):
self.castle.has_access = mock.Mock()
self.castle.has_access.return_value = "No access"
# We should retrieve no access for everybody
self.assertEqual(self.castle.has_access("Let me in"), "No access")
self.assertEqual(self.castle.has_access("Let me in, please"), "No access")
self.assertEqual(self.castle.has_access("Let me in, please sir!"), "No access")
# Verify the length of the arguments list
self.assertEqual(len(self.castle.has_access.call_args_list), 3)
if __name__ == "__main__":
unittest.main()
""" tests/charactercastletestclass.py """
import unittest
import unittest.mock as mock
try:
from jj_classes.castle import Castle
except ModuleNotFoundError:
import sys, os
sys.path.insert(0, f"{os.path.dirname(os.path.abspath(__file__))}/../")
from jj_classes.castle import Castle
from jj_classes.character import Character
class CharacterCastleTestClass(unittest.TestCase):
""" Defines the tests for the Character and Castle class together """
@mock.patch(__name__ + ".Castle")
@mock.patch(__name__ + ".Character")
def test_mock_castle_and_character(self, MockCharacter, MockCastle):
# Note the order of the arguments of this test
MockCastle.name = "Mocked Castle"
MockCharacter.name = "Mocked Character"
self.assertEqual(Castle.name, "Mocked Castle")
self.assertEqual(Character.name, "Mocked Character")
def test_fake_powerup(self):
character = Character("Sentinel Character")
character.powerup = mock.Mock()
character.powerup.return_value = mock.sentinel.fake_superpower
self.assertEqual(character.powerup(), mock.sentinel.fake_superpower)
def test_castle_with_more_powerups(self):
self.castle = Castle("Beautiful Castle")
multi_characters = mock.Mock()
# Set a list as side_effect for the get_boss method
multi_characters.get_powerup.side_effect = ["mushroom", "star"]
# First value is mushroom
self.assertEqual(multi_characters.get_powerup(), "mushroom")
# Second value is star
self.assertEqual(multi_characters.get_powerup(), "star")
# Third value does not exist and raises a StopIteration
self.assertRaises(StopIteration, multi_characters.get_powerup)
if __name__ == "__main__":
unittest.main()
Rewriting the tests to use pytest
In order to increase readability and reduce repetition, I favor pytest
over unittest
. PyTest offers some nice features to make writing tests faster and cleaner.
Main differences
Assert
With unittest
we always use self.assertEqual
and the other variations. With pytest
only assert
is used.
# unittest
self.assertEqual(5, "five")
# pytest
assert 5 == five
Capturing errors is easier with PyTest, you can even assert the raised message in the same go.
# unittest
self.assertRaises(StopIteration, multi_boss_castle.get_boss)
# pytest
with pytest.raises(StopIteration):
multi_boss_castle.get_boss()
expected_error = r"__init__\(\) missing 1 required positional argument: \'name\'"
with pytest.raises(TypeError, match=expected_error):
castle = Castle()
Mock
# unittest
# Mock a class method
@mock.patch.object(Castle, "get_boss")
def test_mock_castle_boss(self, mock_get_boss):
mock_get_boss.return_value = "Hammer Bro"
self.assertEqual(self.castle.get_boss(), "Hammer Bro")
Make sure that for the mock functionality in PyTest the package pytest-mock
is installed.
# pytest
# Mock a class method
def test_mock_castle_boss(self, mocker, castle):
mock_get_boss = mocker.patch.object(Castle, "get_boss")
mock_get_boss.return_value = "Hammer Bro"
assert castle.get_boss(), "Hammer Bro"
Fixtures
PyTest has the functionality to add fixtures to your tests. They are normally placed in conftest.py
in your tests folder where it will be automatically be picked up. For the sake of example, I have added the fixture to the same file as the test itself. In case of defining castle
in each test like for unittest
, we create a fixture for castle
once and add it as an argument to the tests.
# unittest
def test_get_boss_returns_bowser(self):
""" Test that the get_boss returns Bowser """
castle = Castle("My Fixture Castle")
assert castle.get_boss() == 'Bowser'
def test_get_world_returns_grass_land(self):
""" Test that the get_boss returns Grass Land """
castle = Castle("My Fixture Castle")
assert castle.get_world() == 'Grass Land'
# pytest
@pytest.fixture(scope='session')
def castle():
returns Castle("My Fixture Castle")
def test_get_boss_returns_bowser(self, castle):
""" Test that the get_boss returns Bowser """
assert castle.get_boss() == 'Bowser'
def test_get_world_returns_grass_land(self, castle):
""" Test that the get_boss returns Grass Land """
assert castle.get_world() == 'Grass Land'
Conclusion
In the end I have cleaned up my tests to only use pytest
and I have introduced the fixture file conftest.py
to reduce the complexity of the test files.
""" tests/conftest.py """
import pytest
CASTLE_NAME = "Castle Name"
CHARACTER_NAME = "Character Name"
from jj_classes.castle import Castle
from jj_classes.character import Character
@pytest.fixture(scope="class")
def castle():
return Castle(CASTLE_NAME)
@pytest.fixture(scope="class")
def character():
return Character(CHARACTER_NAME)
And the tests look like the following:
""" tests/test_castle_class.py """
import pytest
from jj_classes.castle import Castle
class TestCastleClass:
""" Defines the tests for the Castle class """
def test_init_sets_name(self):
""" Test that init sets the name """
castle = Castle('Test name')
assert castle.name == "Test name"
def test_init_error_when_no_name(self):
""" Test that init fails without the name """
expected_error = r"__init__\(\) missing 1 required positional argument: \'name\'"
with pytest.raises(TypeError, match=expected_error):
castle = Castle()
def test_has_access_true_with_super_mushroom(self, castle, character):
""" Test that has_access returns True for Super Mushroom """
character.powerup = 'Super Mushroom'
assert castle.has_access(character)
def test_has_access_false_without_super_mushroom(self, castle, character):
""" Test that has_access returns False for other powerups """
character.powerup = 'Not a mushroom'
assert castle.has_access(character) is False
def test_get_boss_returns_bowser(self, castle):
""" Test that the get_boss returns Bowser """
assert castle.get_boss() == 'Bowser'
def test_get_world_returns_grass_land(self, castle):
""" Test that the get_boss returns Grass Land """
assert castle.get_world() == 'Grass Land'
# Mock a class method
def test_mock_castle_boss(self, mocker, castle):
""" Test that the mocked get_boss returns overwritten value """
mock_get_boss = mocker.patch.object(Castle, "get_boss")
mock_get_boss.return_value = "Hammer Bro"
assert castle.get_boss(), "Hammer Bro"
# Mock an instance
def test_mock_castle(self, mocker):
""" Test that the mocked instance returns overwritten values """
instance = mocker.patch(__name__ + ".Castle")
instance.get_boss.return_value = "Toad"
instance.get_world.return_value = "Desert Land"
castle = Castle
assert castle.get_boss() == "Toad"
assert castle.get_world() == "Desert Land"
# Mock an instance method
def test_mock_castle_instance_method(self, mocker, castle):
""" Test that overwriting the instance method worked """
assert castle.get_boss() != "Koopa Troopa"
castle.get_boss = mocker.Mock(return_value="Koopa Troopa")
assert castle.get_boss() == "Koopa Troopa"
def test_castle_with_more_bosses(self, mocker):
""" Test that get_boss gets overwritten several times """
multi_boss_castle = mocker.Mock()
multi_boss_castle.get_boss.side_effect = ["Goomba", "Boo"]
assert multi_boss_castle.get_boss() == "Goomba"
assert multi_boss_castle.get_boss() == "Boo"
with pytest.raises(StopIteration):
multi_boss_castle.get_boss()
def test_calls_to_castle(self, mocker, castle):
""" Test that has_access gets called 3 times """
castle.has_access = mocker.Mock()
castle.has_access.return_value = "No access"
assert castle.has_access("Let me in") == "No access"
assert castle.has_access("Let me in, please") == "No access"
assert castle.has_access("Let me in, please sir!") == "No access"
assert len(castle.has_access.call_args_list) == 3
""" tests/test_character_class.py """
import pytest
from jj_classes.character import Character
class TestCharacterClass:
""" Defines the tests for the Character class """
def test_init_sets_name(self):
""" Test that init sets the name """
character = Character('Test name')
assert character.name == "Test name"
def test_init_error_when_no_name(self):
""" Test that init fails without the name """
expected_error = r"__init__\(\) missing 1 required positional argument: \'name\'"
with pytest.raises(TypeError, match=expected_error):
character = Character()
def test_get_powerup_returns_correct_value_when_not_set(self, character):
""" Test that the get_powerup returns the right value when not set """
assert character.get_powerup() == ""
def test_get_powerup_returns_correct_value_when_set(self, character):
""" Test that the get_powerup returns the right value when set """
character.powerup = "Fire Flower"
assert character.get_powerup() == "Fire Flower"
def test_fake_powerup(self, mocker, character):
""" Test that the powerup can be mocked """
character.powerup = mocker.Mock()
character.powerup.return_value = mocker.sentinel.fake_superpower
assert character.powerup() == mocker.sentinel.fake_superpower
def test_characters_with_more_powerups(self, mocker, castle):
""" Test that get_powerup gets overwritten several times """
multi_characters = mocker.Mock()
multi_characters.get_powerup.side_effect = ["mushroom", "star"]
assert multi_characters.get_powerup() == "mushroom"
assert multi_characters.get_powerup() == "star"
with pytest.raises(StopIteration):
multi_characters.get_powerup()
$ pytest -v
================================================================================================= test session starts ==================================================================================================
platform darwin -- Python 3.7.3, pytest-5.2.1, py-1.8.0, pluggy-0.13.0 -- /Users/jitsejan/.local/share/virtualenvs/blog-testing-KMgUXSdn/bin/python3.7m
cachedir: .pytest_cache
rootdir: /Users/jitsejan/code/blog-testing
plugins: mock-1.11.1
collected 17 items
tests/test_castle_class.py::TestCastleClass::test_init_sets_name PASSED [ 5%]
tests/test_castle_class.py::TestCastleClass::test_init_error_when_no_name PASSED [ 11%]
tests/test_castle_class.py::TestCastleClass::test_has_access_true_with_super_mushroom PASSED [ 17%]
tests/test_castle_class.py::TestCastleClass::test_has_access_false_without_super_mushroom PASSED [ 23%]
tests/test_castle_class.py::TestCastleClass::test_get_boss_returns_bowser PASSED [ 29%]
tests/test_castle_class.py::TestCastleClass::test_get_world_returns_grass_land PASSED [ 35%]
tests/test_castle_class.py::TestCastleClass::test_mock_castle_boss PASSED [ 41%]
tests/test_castle_class.py::TestCastleClass::test_mock_castle PASSED [ 47%]
tests/test_castle_class.py::TestCastleClass::test_mock_castle_instance_method PASSED [ 52%]
tests/test_castle_class.py::TestCastleClass::test_castle_with_more_bosses PASSED [ 58%]
tests/test_castle_class.py::TestCastleClass::test_calls_to_castle PASSED [ 64%]
tests/test_character_class.py::TestCharacterClass::test_init_sets_name PASSED [ 70%]
tests/test_character_class.py::TestCharacterClass::test_init_error_when_no_name PASSED [ 76%]
tests/test_character_class.py::TestCharacterClass::test_get_powerup_returns_correct_value_when_not_set PASSED [ 82%]
tests/test_character_class.py::TestCharacterClass::test_get_powerup_returns_correct_value_when_set PASSED [ 88%]
tests/test_character_class.py::TestCharacterClass::test_fake_powerup PASSED [ 94%]
tests/test_character_class.py::TestCharacterClass::test_characters_with_more_powerups PASSED [100%]
================================================================================================== 17 passed in 0.10s ==================================================================================================