Testing in Python with pytest: from the basics to advanced techniques

ibobev1 pts0 comments

Testing in Python with pytest: from the basics to advanced techniques | Andros Fenollosa

I'm not going to convince you that testing is necessary — if you're reading this article, you already know that. Instead, I'll go straight to the point and show you how to work with Pytest to write unit tests, integration tests, functional tests, and more. I'll focus on various techniques and tools you can use in your Pythonic projects, though the same patterns apply to other languages.

Testing libraries in Python

Let's start with a list of essential libraries for testing in Python:

pytest: The main testing framework in Python. Supports unit, integration, and functional tests, among others. Highly extensible with plugins.

pytest-env: Manages environment variables in tests. Useful for configuring database connection strings, API keys, etc.

pytest-xdist: Runs tests in parallel across multiple CPU cores. Useful for speeding up the test suite.

pytest-asyncio: Runs tests for code that uses asyncio.

respx: Simulates HTTP requests made with the httpx library (compatible with async code).

time-machine: An alternative to freezegun. Faster and works better with async code.

faker: Generates fake data (names, emails, addresses, phone numbers, etc.) for tests.

polyfactory: A modern factory library with great support for Pydantic, dataclasses, and attrs.

syrupy: Snapshot testing for pytest.

dirty-equals: Flexible equality assertions like IsUUID(), IsDatetime(), IsPartialDict(). Ideal for verifying parts of responses.

hypothesis: The reference library for property-based testing. Automatically generates test cases from specifications.

We'll use many of these, and they complement each other, but everything relies on pytest as the main testing framework.

Anatomy of a test

Let's start with the basics: how to write a test.

You need to create a separate test file, usually with the test_ prefix or the _test.py suffix. Inside that file, you define test functions that must also follow the naming convention (starting with test_).

Each test function should contain one or more assertions that verify the expected behavior of the code you're testing.

For example, inside a file called test_sum.py, we could have the following test for a sum function:

def test_sum():<br>assert sum([1, 2, 3]) == 6<br>Use a descriptive function name for each test, and split the test into 3 parts: given (data setup), when (execution of the code under test) and then (result verification).

def is_tropic(latitude, longitude):<br>pass

def test_is_tropic():<br># Given<br>latitude = 0<br>longitude = 0

# When<br>result = is_tropic(latitude, longitude)

# Then<br>assert result #= True

def test_is_not_tropic():<br># Given<br>latitude = 45<br>longitude = 45

# When<br>result = is_tropic(latitude, longitude)

# Then<br>assert not result #= False<br>Factory fixtures or creating reusable test objects

Suppose that to test a function we need to create a complex object, such as a library that gives me the current weather depending on a location. Instead of creating that object every time we need it in our tests, we can use a pytest fixture to create a reusable test object.

This is how we'd do it without a fixture:

def test_get_current_weather_new_york():<br># Given<br>weather_service = WeatherService()

# When<br>weather = weather_service.get_current_weather(location="New York")

# Then<br>assert weather.temperature > 0

def test_get_current_weather_london():<br># Given<br>weather_service = WeatherService()

# When<br>weather = weather_service.get_current_weather(location="London")

# Then<br>assert weather.temperature > 0<br>Every time a test runs, the instantiation code is repeated, which violates the DRY principle and makes maintenance more expensive.

The DRY (Don't Repeat Yourself) principle tells us not to repeat code, but to abstract it into functions, classes, or fixtures so it's easier to maintain and reuse.

And this is how we'd do it using a fixture:

import pytest

@pytest.fixture<br>def weather_service():<br>return WeatherService()

def test_get_current_weather_new_york(weather_service):<br># Given<br># No need to create the WeatherService object, we already have it as an argument

# When<br>weather = weather_service.get_current_weather(location="New York")

# Then<br>assert weather.temperature > 0

def test_get_current_weather_london(weather_service):<br># Given<br># No need to create the WeatherService object, we already have it as an argument

# When<br>weather = weather_service.get_current_weather(location="London")

# Then<br>assert weather.temperature > 0<br>You can also use fixtures to create test objects with specific data, read test files (CSV, JSON, etc.), configuration constants, etc.

If you work with Pydantic models or dataclasses, polyfactory lets you generate test instances without having to fill in every field by hand:

from polyfactory.factories.pydantic_factory import ModelFactory<br>from pydantic import BaseModel

class WeatherData(BaseModel):<br>location: str<br>temperature_celsius: float<br>conditions: str<br>humidity: int

class...

test pytest testing tests weather weather_service

Related Articles