Skip to content

Testing in Details

A test looks at a particular behavior's result and ensures that the result aligns with what you expect. "Behavior" is how some systems act in response to a particular situation. But how or why something is done is not quite as important as what was done.

The test consists of the following steps:

Arrange/Setup — here we prepare everything for our test — objects, starting/killing services, entering records into a database, generating credentials for a user that doesn’t exist yet.

Act is the action that kicks off the behavior we want to test. Usually it is a function or a method.

Assert — here we look at the result and check if it works how we expect.

Cleanup/Teardown — here the test cleans after itself, so other tests aren’t accidentally influenced by it.

The test is the Act and Assert steps, with the Arranged step to prepare the context. Behavior exists between the Act and AssertArrange/Setup and Cleanup/Teardown are optional steps.

Organizing Unittest Code

Here we will consider how to realize the Arrange and Cleanup steps in the unittest framework.

setUp() and tearDown()

The setUp() method is called to prepare the test. It is called before the test method. Any exception (unlike the AssertionError or SkipTestexceptions) raised by the setUp() will be considered an error rather than a test failure.

The tearDown() method is called after the test method has been called and the result saved. It is called even if the test method raises an exception, so the implementation in subclasses may need to be particularly careful about checking the internal state.

Here’s an example of using the setUp() and tearDown() methods.

The main.py file:

class Person:
    def __init__(self, name: str, surname: str, age: int):
        self.name = name
        self.surname = surname
        self.age = age


    def personal_info(self) -> str:
        return f"{self.name} {self.surname} (age: {self.age})"

    def adult_info(self) -> str:
        if self.age < 18:
            return f"{self.name} is not an adult"
        return f"{self.name} is an adult"

The test_main.py file:

from unittest import TestCase

from main import Person


class TestPerson(TestCase):
    def setUp(self) -> None:
        """this code will run before each test"""
        print("test started")
        self.person = Person(name="John", surname="Smith", age=20)

    def tearDown(self) -> None:
        """this code will run after each test"""
        print("test finished")

    def test_adult_info(self):
        self.person.age = 15  # When we change self.person.age here, it will not touch other tests
        assert self.person.adult_info() == "John is not an adult"

    def test_personal_info(self):
        assert self.person.personal_info() == "John Smith (age: 20)"

The result of these tests is:

============================= test session starts =============================
collecting ... collected 2 items

test_main.py::TestPerson::test_adult_info 
test_main.py::TestPerson::test_personal_info 

============================== 2 passed in 0.03s ==============================

Process finished with exit code 0
PASSED                         [ 50%]test started
test finished
PASSED                        [100%]test started
test finished

setUpClass() and tearDownClass()

The setUpClass() method is called before all tests in an individual class have run).

The tearDownClass() method is called after all tests in an individual class have run.

Please note: both setUpClass() and tearDownClass() are called with the class as the only argument and must be decorated as a classmethod()

Let’s consider an example:

# test_main.py
from unittest import TestCase

from main import Person


class TestPerson(TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        """this code will run before all tests"""
        print("tests started")
        cls.person = Person(name="John", surname="Smith", age=20)

    @classmethod
        def tearDownClass(self) -> None:
            """this code will run after all tests"""
            print("all tests are finished")

    def test_adult_info(self):
        self.person.age = 15  # When we cange self.person.age here, test_personal_info will not pass
        assert self.person.adult_info() == "John is not an adult"

    def test_personal_info(self):
        assert self.person.personal_info() == "John Smith (age: 20)"

The result of these tests is:

============================= test session starts =============================
collecting ... collected 2 items

test_main.py::TestPerson::test_adult_info 
test_main.py::TestPerson::test_personal_info tests started
PASSED                         [ 50%]
FAILED                          [100%]
test_main.py:22 (TestPerson.test_personal_info)
'John Smith (age: 15)' != 'John Smith (age: 20)'
...
test_main.py:24: AssertionError
all tests are finished

========================= 1 failed, 1 passed in 0.16s =========================

Please note: this example is not the best practice, please avoid changing data from the setUpClass() method.

Organizing Pytest Code

Here we will consider how to realize the Arrange and Cleanup steps in the pytest framework.

Set Up Fixture in Pytest

We can tell the pytest that a particular function is a fixture by decorating it with the @pytest.fixture.

This fixture allows us to define a generic setup step that can be reused over and over, just like a normal function. Two different tests can request the same fixture and return different results from that fixture. The same as with the setUp() from the unittest, but you can define which fixture you need to accept to test the function.

Here’s an example:

# test_main.py
import pytest

from main import Person


@pytest.fixture()
def person_template():
    return Person(name="John", surname="Smith", age=20)


# To make the test accept fixtures, we have to list fixtures as parameters in the test function.
def test_adult_info(person_template):  
    person_template.age = 15
    assert person_template.adult_info() == "John is not adult"


 # To make the test accept fixtures, we have to list fixtures as parameters in the test function.
def test_personal_info(person_template):
    assert person_template.personal_info() == "John Smith (age: 20)"

Tests don’t have to be limited to a single fixture — they can depend on as many fixtures as you need, and fixtures can use other fixtures, as well.

Cleanup/Teardown in Pytest

Fixtures in the pytest offer an advantageous teardown system, which allows us to define the steps for each fixture to clean up after itself.

To do that, use the yield statement instead of the return statement. Run some code with these fixtures and pass an object back to the requesting fixture or test, just like with the other fixtures. The differences are:

the return is swapped out for the yield;

any teardown code for that fixture is placed after the yield.

So, how does it work? First, the pytest figures out an order for the fixtures. Then, it will run each one up until it returns or yields. Finally, it will move on to the next fixture in the list to do the same thing.

When the test is finished, the pytest will go back to the list of fixtures, but in the reverse order. It will take each one that yielded and run the code inside it that was after the yield statement. For example:

# test_main.py
import pytest

from main import Person


@pytest.fixture()
def person_template():
    yield Person(name="John", surname="Smith", age=20)
    print("test finished")  # Will be printed after each test.


def test_adult_info(person_template):
    person_template.age = 15
    assert person_template.adult_info() == "John is not adult"


def test_personal_info(person_template):
    assert person_template.personal_info() == "John Smith (age: 20)"

Fixture Scope

We can extend the previous example and add the scope="module"parameter to the @pytest.fixture calling to cause a person_templatefixture function to only be called once per test module (the default is scope="function" — once per test function). Then its behavior becomes the same as the setUpClass() and tearDownClass() methods in the unittest. Possible values for the scope are: functionclassmodulepackage, or session.

Fixtures are created when first requested by a test starts and are destroyed based on their scope:

function: the default scope, the fixture is destroyed at the end of the test function. class: the fixture is destroyed during the teardown of the last test in the class. module: the fixture is destroyed during the teardown of the last test in the module. package: the fixture is destroyed during the teardown of the last test in the package. session: the fixture is destroyed at the end of the test session.

Let’s consider an example:

# test_main.py
import pytest

from main import Person


@pytest.fixture(scope="module")
def person_template():
    yield Person(name="John", surname="Smith", age=20)
    print("all tests are finished")   # Will be printed after all tests.


def test_adult_info(person_template):
    person_template.age = 15   # When we change self.person.age here, test_personal_info will not pass
    assert person_template.adult_info() == "John is not adult"


def test_personal_info(person_template):
    assert person_template.personal_info() == "John Smith (age: 20)"