I have a class like this:
class ToDoList:
def __init__():
self._bullet_points: list[str] = [] # manages a simple list of strings
def add_bullet_point(string: str):
check_something(string) # whatever, assume this is more complex
self._bullet_points.append(string)
def get_bullet_point(index: int):
return self._bullet_points[index]
def reverse_list():
self._bullet_points = list(reversed(self._bullet_points))
Please note that its methods build up on each other, so I could not test "get_bullet_point" without having called "add_bullet_point" before.
Now I want to test every method of my class using unittests with pytest. I have made myself this fixture (= setup method):
import pytest
@pytest.fixture
def todo_list():
return ToDoList()
But how should I design the actual tests? What is best practice and what are pros and cons?
(Look at this short, effective and efficient piece of code!)
def test_behavior(todo_list: ToDoList):
test_string_1 = 'This is a test.'
test_string_2 = 'This is another test.'
todo_list.add_bullet_point(test_string_1)
todo_list.add_bullet_point(test_string_2)
assert test_string_1 == todo_list.get_bullet_point(0)
assert test_string_2 == todo_list.get_bullet_point(1)
todo_list.reverse_list()
assert test_string_1 == todo_list.get_bullet_point(1)
assert test_string_2 == todo_list.get_bullet_point(0)
(This is how I though it should be done - But do I really have to instanciate ToDoList 3 times and repeat the same code again and again?)
def test_add_bullet_point(todo_list: ToDoList):
test_string = 'This is a test.'
todo_list.add_bullet_point(test_string)
assert test_string == todo_list._bullet_points
def test_get_bullet_point(todo_list: ToDoList):
test_string = 'This is a test.'
todo_list.add_bullet_point(test_string)
assert test_string == todo_list.get_bullet_point(0)
def test_reverse_list(todo_list: ToDoList): # In this case, this happens to be the exact same as the all-in-one solution above
test_string_1 = 'This is a test.'
test_string_2 = 'This is another test.'
todo_list.add_bullet_point(test_string_1)
todo_list.add_bullet_point(test_string_2)
assert test_string_1 == todo_list.get_bullet_point(0)
assert test_string_2 == todo_list.get_bullet_point(1)
todo_list.reverse_list()
assert test_string_1 == todo_list.get_bullet_point(1)
assert test_string_2 == todo_list.get_bullet_point(0)
(This needs the additional plugin "pytest-dependency", which leads to believe that this is not the common way to do it (?).)
First, change the fixture scope to "module" or "session":
import pytest
@pytest.fixture(scope="session")
def todo_list():
return ToDoList()
Then the tests:
def test_add_bullet_point(todo_list: ToDoList):
test_string = 'This is a test.'
todo_list.add_bullet_point(test_string)
assert test_string == todo_list._bullet_points
@pytest.mark.depends(on=['test_add_bullet_point'])
def test_get_bullet_point(todo_list: ToDoList):
assert 'This is a test.' == todo_list.get_bullet_point(0)
@pytest.mark.depends(on=['test_get_bullet_point'])
def test_reverse_list(todo_list: ToDoList):
todo_list.reverse_list()
assert test_string_1 == todo_list.get_bullet_point(1)
assert test_string_2 == todo_list.get_bullet_point(0)
I do not recommend using non-public attributes in your tests, but that's outside of your question's scope. Therefore, I kept the first test case.
I'd prefer approach 2 with fixtures:
@pytest.fixture
def todo_list() -> ToDoList:
return ToDoList()
@pytest.fixture
def bullet_points() -> list[str]:
return ["one", "two", "three"]
@pytest.fixture
def populated_list(todo_list: ToDoList, bullet_points: list[str]) -> ToDoList:
for bullet_point in bullet_points:
todo_list.add_bullet_point(bullet_point)
return todo_list
def test_add_bullet_point(todo_list: ToDoList):
test_string = 'This is a test.'
todo_list.add_bullet_point(test_string)
assert test_string == todo_list._bullet_points
def test_get_bullet_point(populated_list: ToDoList, bullet_points: list[str]):
for i, bullet_point in enumerate(bullet_points):
assert todo_list.get_bullet_point(i) == bullet_point
def test_reverse_list(populated_list: ToDoList, bullet_points: list[str]):
populated_list.reverse_list()
for i, bullet_point in enumerate(reversed(bullet_points)):
assert populated_list.get_bullet_point(i) == bullet_point
test_reverse_list
requires a populated todo_list to begin with. This setup can be extracted to a fixture. If you add more methods to your ToDoList, you'll probably need that fixture in a lot of tests. Additionally, you can parametrize the fixture if you want to test edge cases (only one item, multiple identical items, ...).
You can still use @pytest.mark.depends
if the test cases take a long time and won't succeed anyway.
Using fixtures adds the additional benefit that pytest distinguishes between errors in a fixture and the actual test function in its output. If the fixture fails, pytest will annotate it with ERROR
instead of FAILED
.