Search code examples
pythonpython-3.xunit-testingpytest

Unittest best practice for class methods that depend on each other?


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?

Approach 1: All in one (efficient, but not split into "units")

(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)

Approach 2: Separate (inefficient, but split into "units")

(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)

Approach 3: Shared object (efficient AND split into "units")

(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)

Approach 4: Any other?


Solution

  • 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.