Тестування в деталях
Тест перевіряє результат певної поведінки та переконується, що результат відповідає очікуванням. "Поведінка" — це те, як деяка система реагує на конкретну ситуацію. Але те, як або чому щось зроблено, не так важливо, як те, що було зроблено.
Тест складається з наступних кроків:
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)"