Перейти до змісту

Тестування в деталях

Тест перевіряє результат певної поведінки та переконується, що результат відповідає очікуванням. "Поведінка" — це те, як деяка система реагує на конкретну ситуацію. Але те, як або чому щось зроблено, не так важливо, як те, що було зроблено.

Тест складається з наступних кроків:

Arrange/Setup — тут ми готуємо все для нашого тесту — об'єкти, запуск/зупинка сервісів, додавання записів у базу даних, генерація облікових даних для користувача, якого ще не існує.

Act — це дія, яка запускає поведінку, яку ми хочемо протестувати. Зазвичай це функція або метод.

Assert — тут ми дивимося на результат і перевіряємо, чи працює він так, як ми очікуємо.

Cleanup/Teardown — тут тест прибирає за собою, щоб інші тести не були випадково вплинуті.

Тест — це кроки Act та Assert, з кроком Arrange для підготовки контексту. Поведінка існує між Act та Assert. Arrange/Setup та Cleanup/Teardown є необов'язковими кроками.

Організація коду Unittest

Тут ми розглянемо, як реалізувати кроки Arrange та Cleanup у фреймворку unittest.

setUp() та tearDown()

Метод setUp() викликається для підготовки тесту. Він викликається перед тестовим методом. Будь-яке виключення (окрім AssertionError або SkipTest), викликане в setUp(), буде вважатися помилкою, а не провалом тесту.

Метод tearDown() викликається після виконання тестового методу і збереження результату. Він викликається навіть якщо тестовий метод викликає виключення, тому реалізація в підкласах може потребувати особливої обережності щодо перевірки внутрішнього стану.

Ось приклад використання методів setUp() та tearDown().

Файл main.py:

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"

Файл test_main.py:

from unittest import TestCase

from main import Person


class TestPerson(TestCase):
    def setUp(self) -> None:
        """цей код виконається перед кожним тестом"""
        print("test started")
        self.person = Person(name="John", surname="Smith", age=20)

    def tearDown(self) -> None:
        """цей код виконається після кожного тесту"""
        print("test finished")

    def test_adult_info(self):
        self.person.age = 15  # Коли ми змінюємо self.person.age тут, це не вплине на інші тести
        assert self.person.adult_info() == "John is not an adult"

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

Результат цих тестів:

============================= 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() та tearDownClass()

Метод setUpClass() викликається перед усіма тестами в окремому класі.

Метод tearDownClass() викликається після всіх тестів в окремому класі.

Зверніть увагу: обидва методи setUpClass() та tearDownClass() викликаються з класом як єдиним аргументом і повинні бути декоровані як classmethod()

Розглянемо приклад:

# test_main.py
from unittest import TestCase

from main import Person


class TestPerson(TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        """цей код виконається перед усіма тестами"""
        print("tests started")
        cls.person = Person(name="John", surname="Smith", age=20)

    @classmethod
        def tearDownClass(self) -> None:
            """цей код виконається після всіх тестів"""
            print("all tests are finished")

    def test_adult_info(self):
        self.person.age = 15  # Коли ми змінюємо self.person.age тут, test_personal_info не пройде
        assert self.person.adult_info() == "John is not an adult"

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

Результат цих тестів:

============================= 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 =========================

Зверніть увагу: цей приклад не є найкращою практикою, уникайте зміни даних з методу setUpClass().

Організація коду Pytest

Тут ми розглянемо, як реалізувати кроки Arrange та Cleanup у фреймворку pytest.

Fixture в Pytest

Ми можемо вказати pytest, що певна функція є fixture, декорувавши її з @pytest.fixture.

Ця fixture дозволяє визначити загальний крок підготовки, який може бути повторно використаний знову і знову, як звичайна функція. Два різних тести можуть запитати одну й ту саму fixture і отримати різні результати від неї. Так само, як і setUp() з unittest, але ви можете визначити, яку fixture потрібно прийняти для тестової функції.

Ось приклад:

# test_main.py
import pytest

from main import Person


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


# Щоб тест приймав fixtures, потрібно перелічити fixtures як параметри тестової функції.
def test_adult_info(person_template):
    person_template.age = 15
    assert person_template.adult_info() == "John is not adult"


 # Щоб тест приймав fixtures, потрібно перелічити fixtures як параметри тестової функції.
def test_personal_info(person_template):
    assert person_template.personal_info() == "John Smith (age: 20)"

Тести не обмежені однією fixture — вони можуть залежати від стількох fixtures, скільки потрібно, і fixtures можуть використовувати інші fixtures.

Cleanup/Teardown в Pytest

Fixtures у pytest пропонують зручну систему teardown, яка дозволяє визначити кроки для кожної fixture щодо прибирання за собою.

Для цього використовуйте оператор yield замість оператора return. Виконайте деякий код з цими fixtures і передайте об'єкт назад до запитуючої fixture або тесту, так само, як і з іншими fixtures. Відмінності:

return замінюється на yield;

будь-який код teardown для цієї fixture розміщується після yield.

Отже, як це працює? Спочатку pytest визначає порядок fixtures. Потім він запускає кожну з них до моменту return або yield. Нарешті, переходить до наступної fixture у списку і робить те саме.

Коли тест завершено, pytest повертається до списку fixtures, але у зворотному порядку. Він бере кожну, яка виконала yield, і запускає код всередині неї, який був після оператора yield. Наприклад:

# 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")  # Буде виведено після кожного тесту.


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="module" до виклику @pytest.fixture, щоб функція fixture person_template викликалася лише один раз на тестовий модуль (за замовчуванням scope="function" — один раз на тестову функцію). Тоді її поведінка стає такою ж, як методи setUpClass() та tearDownClass() в unittest. Можливі значення для scope: function, class, module, package або session.

Fixtures створюються при першому запиті тестом і знищуються на основі їхньої області видимості:

function: область за замовчуванням, fixture знищується в кінці тестової функції. class: fixture знищується при teardown останнього тесту в класі. module: fixture знищується при teardown останнього тесту в модулі. package: fixture знищується при teardown останнього тесту в пакеті. session: fixture знищується в кінці тестової сесії.

Розглянемо приклад:

# 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")   # Буде виведено після всіх тестів.


def test_adult_info(person_template):
    person_template.age = 15   # Коли ми змінюємо self.person.age тут, test_personal_info не пройде
    assert person_template.adult_info() == "John is not adult"


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