Testing is a core part of modern software development in 2026. Some developers write tests after implementation. Others prefer TDD, where the test comes first and the production code follows.
Either way, a healthy test suite helps catch bugs early, protects refactoring, and keeps projects from becoming fragile as they grow.
For Python, one of the best everyday testing tools is pytest: powerful, flexible, and pleasant enough for both small scripts and large applications.
This guide covers the essentials: first tests, parametrization, marks, fixtures, exception assertions, mocks, monkeypatching, and teardown patterns.
Why Automated Tests Matter
Automated tests do not guarantee that your software has no defects. They do reduce the chance of shipping obvious regressions and serious logic bugs.
Good tests also work as living documentation. They show how the code is supposed to behave in real scenarios.
Most importantly, tests make refactoring less scary. When the suite is reliable, you can change internals while keeping behavior intact.
Your First Pytest Test
Install Pytest:
pip install pytest
Create test_example.py:
def test_answer() -> None:
assert 2 + 2 == 4
Run it:
pytest .
You should see one passing test:
============ test session starts ============
collected 1 item
test_example.py . [100%]
============ 1 passed in 0.00s =============
If you change the assertion to assert 2 + 2 == 5, Pytest prints a useful failure message:
E assert 4 == 5
That clear assertion output is one reason Pytest feels so productive.
A More Useful Example: Factorial
Here is a small factorial function:
def factorial(n: int) -> int:
if n in [0, 1]:
return 1
return n * factorial(n - 1)
A single test works:
def test_factorial() -> None:
expected = 120
got = factorial(5)
assert expected == got
But 0, 1, and 5 are different cases. Writing separate tests is fine:
def test_factorial_return_one_if_zero() -> None:
assert factorial(0) == 1
def test_factorial_return_one_if_one() -> None:
assert factorial(1) == 1
def test_factorial_of_five() -> None:
assert factorial(5) == 120
The tests are almost identical, so parametrization is cleaner.
import pytest
@pytest.mark.parametrize(
("number", "expected"),
[
(0, 1),
(1, 1),
(5, 120),
],
)
def test_factorial(number: int, expected: int) -> None:
assert factorial(number) == expected
This single test function runs once for each input pair.
Useful Pytest Marks
@pytest.mark.parametrize groups multiple scenarios into one test. You can stack several parametrization decorators, but be careful: combinations grow quickly.
@pytest.mark.skip skips a test completely:
@pytest.mark.skip(reason="Awaiting fix in the next library release")
def test_some_function() -> None:
...
@pytest.mark.skipif skips a test only when a condition is true:
import sys
import pytest
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_unix_behavior() -> None:
...
@pytest.mark.xfail marks a known failure without breaking the entire build:
@pytest.mark.xfail(reason="Waiting for new data updates on the server")
def test_data_sync() -> None:
...
Use marks to make test intent explicit instead of hiding fragile behavior.
Fixtures for Setup and Teardown
Suppose a class retrieves prices from different data sources:
class PriceManager:
def __init__(self, x_price_source, y_price_source):
self.x_price_source = x_price_source
self.y_price_source = y_price_source
def get_price(self, product):
if product.type == "x":
return self.x_price_source.get(product)
if product.type == "y":
return self.y_price_source.get(product)
return None
Creating PriceManager inside every test quickly becomes repetitive. A fixture keeps setup in one place:
from decimal import Decimal
import pytest
@pytest.fixture()
def price_manager():
return PriceManager(
x_price_source=StubXPriceSource(Decimal("150.00")),
y_price_source=StubYPriceSource(Decimal("220.00")),
)
def test_get_price_x(price_manager):
product = Product(type="x")
assert price_manager.get_price(product) == Decimal("150.00")
Fixtures reduce duplication, make setup reusable, and can depend on other fixtures. They can also run at different scopes: function, class, module, package, or session.
Parametrizing Fixtures
Fixtures can be parametrized too:
import pytest
@pytest.fixture(params=[
{"name": "Rashid", "age": 27},
{"name": "Mamed", "age": 31},
])
def user(request):
user_data = request.param
user = UserModel(name=user_data["name"], age=user_data["age"])
user.save()
yield user
user.delete()
def test_user_presentation(user):
print(f"{user.name}, {user.age}")
Pytest will run the test twice, once for each fixture value.
Checking Exceptions with pytest.raises
Use pytest.raises when the behavior you want is an exception:
import pytest
def test_get_raises_error_when_not_found(my_repo):
with pytest.raises(EntityDoesNotExistError) as exc_info:
my_repo.get(999)
assert "999" in str(exc_info.value)
This makes failure behavior explicit and testable.
Mocking External Dependencies
Mocks let you isolate the code under test from remote services, databases, or slow APIs.
def test_service_auth(mocker):
transport_mock = mocker.Mock()
transport_mock.get.side_effect = [
HTTPAuthError("Unauthorized"),
None,
[{"name": "Alice"}, {"name": "Bob"}],
]
client = MyHttpClient(transport=transport_mock)
result = client.get_all_users()
assert len(result) == 2
If dependency injection is not available, monkeypatching can help:
def test_read_file(mocker):
file_mock = mocker.mock_open(read_data="Hello, World!")
mocker.patch("my_module.open", file_mock)
got = FileReader.read("dummy.txt")
assert got == "Hello, World!"
Use patching carefully. It is powerful, but tests become harder to understand when too many internals are replaced.
Yield Fixtures for Cleanup
Fixtures can clean up resources after the test:
@pytest.fixture()
def open_file():
file_obj = open("test.txt", "w")
yield file_obj
file_obj.close()
For more complex teardown logic, use request.addfinalizer:
@pytest.fixture()
def create_user(request):
user = User(name="TempUser")
user.save()
def remove_user():
user.delete()
request.addfinalizer(remove_user)
return user
yield fixtures are usually easier to read, but finalizers are useful when cleanup needs to be registered dynamically.
Key Takeaways
Write tests frequently so bugs do not pile up.
Use fixtures to remove repeated setup and make tests easier to scan.
Use marks to skip platform-specific tests, document known failures, and parametrize coverage.
Mock external dependencies when real services would make tests slow, flaky, or expensive.
Pytest remains one of the most flexible testing tools in the Python ecosystem. In 2026, it is still a great default for teams that want clear, maintainable, confidence-building tests.


