Tutorial: Async TDD with pytest-asyncio

,

In this tutorial, we’ll walk through how to apply Test-Driven Development (TDD) to an asynchronous service to fetch customers from a database using Python’s pytest and pytest-asyncio.

Along the way, you’ll use:

  • Pydantic for modeling data
  • pytest-asyncio for async test writing

This is ideal if you’re:

  • Working with async systems (e.g., FastAPI, async database drivers)
  • Wanting to practice clean async architecture
  • Learning how to write tests before implementation (TDD-style)

Intro

Test-Driven Development (TDD)

TDD is a development approach where you write tests before writing the actual implementation. The basic cycle is:

  1. Write a test that defines a small expected behavior.
  2. Run the test — it should fail (since you haven’t written the feature yet).
  3. Write the minimal code to make the test pass.
  4. Refactor the implementation while keeping the test green.
  5. Repeat 🔁

This helps ensure code quality, encourages modular design, and gives you immediate feedback as you build.

Asynchronous Programming in Python

Async programming allows you to write non-blocking code using the async/await syntax. It’s ideal for:

  • I/O-bound operations (e.g., reading files, making HTTP calls)
  • High-concurrency scenarios (many tasks running in parallel)

Instead of waiting for each task to complete before starting the next, async code can switch between tasks while waiting — leading to better performance and responsiveness.

Imagine we have three tasks: read file, process data, and write file — each takes 2 seconds.

🐌 Synchronous Code (runs one step after another)

Each task blocks the next one.

🚀 Asynchronous Code (overlaps I/O waits)

Tasks start as soon as the previous one yields control, making use of idle waiting time (e.g., I/O delays). This overlapping behavior is what makes async code efficient.

Why Combine TDD and Async?

  • Most modern Python web frameworks and data pipelines (like FastAPI, async DB drivers, etc.) are async-first.
  • Writing async code safely is easier when it’s test-driven — you define exactly how your functions should behave before building them.
  • Libraries like pytest-asyncio let you write clean, readable async tests without boilerplate.

In this tutorial, you’ll use TDD to build an async service from the ground up, learning to test and implement features incrementally and cleanly.

Let me know when you want to move on to the service logic!

Step 1: Project Setup

Environment

When working with Python always use an environment management so we don’t mess up the Python system installed in the OS. In this case, I recommend using Poetry.

Once you have created the main project directory, first install Poetry globally. Open the terminal in the root directory and do:

Create/open the project’s directory and do:

You’ll be asked several questions to set up the project, feel free to choose the simplest options, and keep going.

A pyproject.toml file will be created in the root directory. This file defines the project’s dependencies and environment configuration.

Now let’s add the necessary dependencies

Here’s a brief explanation of each package:

pydantic
Used to define and validate data models (BaseModel). Essential for transforming and validating structured data.

pytest
The core testing framework used to write and run tests.

pytest-asyncio
A plugin that allows you to write async def test functions using pytest. Required for testing asynchronous code.

pytest-mock
Provides an easy way to use unittest.mock within pytest. Helpful for mocking I/O operations, database calls, or isolating service logic during TDD

Project Structure

Now let’s create a project structure inspired by Clean Architecture:

Step 2: Test Driven Development Workflow

Let’s create a service for generating a monthly account statement:

  • Fetch user transactions in a given month (using a repository)
  • Aggregate the net balance
  • Generate and Monthly Statement including:
    • User ID
    • Account Number
    • Period (month/year)
    • Final balance
    • List of transactions

We’re going to use a functional approach, so the service is a plain function that consumes the repository and makes the calculations.

Let’s start with a base test:

Good, our test looks quite simple, but is essentially expecting the statement to not be None and to have a specific value. Now the intention is that our generate_monthly_statement function must be asynchronous, so when calling the service the main execution of the app is not blocked. In that case, the test should support async behavior, and to achieve that we’ll use the following

  • Use the built-in decorator pytest.mark.asyncio
  • Use async for the test function
  • Assume the async function generate_monthly_statement should be implemented

Inside the tests directory let’s create a test_generate_statement_service.py file with the next code:

Now let’s fire the first test, go to the console:

Using poetry you ensure the current environment it’s going to be applying

Hopefully, tests were discovered correctly. If not, ensure things like file names start with “test_“, the __init:__.py file is in the tests folder, keep trying until you get your first test really executed.

Error: Function “generate_monthly_statement” not found.

That’s the idea of the TDD, you create what is missing or fix what is wrong, not more than that.

So go to the services directory and create a file generate_statement.py with the following code.

It seems a useless thing, however when doing this step we are ensuring our packages are discoverable and well routed. Let’s run the tests.

RED: Assertion error, the test failed because statement is None.

These are real testing fails (no python errors). So let’s fix it, with minimum effort:

GREEN: Now test has passed.

I used this empty dictionary on purpose to allow me refine the test. We don’t want an empty dictionary, or even a dictionary. We want to have an data-object with the statement information, so let’s update the test:

Error: MonthlyStatement class not found.

Clearly we need to create the model so let’s go to the path app/models and create the monthly_statement.py file with this content:

Is not in the scope to test the models, so I’m creating them for you.

RED: Assertion error, the test failed because statement is still an empty dictionary.

Let’s update the service, to return a MonthlyStatement object:

GREEN: With this tiny adjustment we pass.

But something is off, we are not yet ensuring that we’re actually retrieving data and making calculations. Here comes something interesting, we have to test two things in the service:

  • The data is being fetched from a repository
  • The monthly statement is calculated correctly

So we get to assume generate_monthly_statement is calling a repository function we’ll call fetch_monthly_transactions_by_user_id.

Testing (even implementing) the repository is something we want to avoid, we need to skip the real execution, especially if it depends on a database or external source. Then we want to do something called mocking: replacing the real function with a fake one that returns predictable data.

When it comes to mocking in tests we basically have two options:

  1. Use unittest.mock.patch, which is part of Python’s standard library
  2. Use mocker, a fixture provided by the pytest-mock plugin

Using patch can be more difficult. It requires you to write the full import path of the function you want to mock, which can easily lead to mistakes. It also needs more boilerplate and does not handle async functions as easily.

So, for this tutorial we are choosing mocker because is simpler, integrates directly into pytest, and works smoothly with async functions. It helps keep the test code clean, readable, and easier to write.

Here’s the test updated.

Good, Now we’re trying to test something more solid. let’s go over this:

  • mock_repo is an object that “disconnect” the real repository function and “plugs” an interface that returns the list of transactions we artificially created to test the service
  • We’re guessing our service now is requiring some arguments (account_number, month, and year), their values should match the fake results.
  • We’re asserting the statement fields to be included or calculated (balance) correctly.

Let’s keep playing. And test again:

Error: To many arguments to the generate_monthly_statement function.

Fixing…

Testing again…

Error: Function fetch_transactions_by_account not found.

Yes, it doesn’t exists let’s do the minimal thing to fix that creating the file transaction_repository.py inside the app/repositories directory:

IMPORTANT

Keep in mind: When mocking the repository, we’ don’t target its original file path (app/repositories/transaction_repository). Instead, we mock it based on how it was imported in the service module into the file being tested (app/services/generate_statement).

This distinction is crucial. Your mock must target the function in the context where it’s used, not where it’s originally defined. It’s a common gotcha, especially when dealing with nested modules, shared utilities, or layered services.

Always decide what to mock based on the calling context, not just the source location.

Ok, test again…

RED: Assertion error, account_number doesn’t match with “1234”

Let’s fix it:

Testing….

RED: Assertion error, balance doesn’t match.

At this point, you have understood that when I say “minimal change to pass” I’m being really serious, so you may copy all the values from the test and paste on the service to pass the test, it may seem naive, but that’s the method.

We need to figure out how to avoid this and really ensure our service is fetching data and making calculations.

pytest-mock give us a fancy way to do it.

The mock_repo object has this method “assert_awaited_once_with“, that ensures that the fetch function is called asynchronously and on top of that with the given arguments.

Let’s update the test:

RED: Assertion error, mock_repo wasn’t awaited.

Now we’re talking we need to implement things, let’s update the service

GREEN: Passed!!

If no typos or anything else, you’re done. If not. it’s a great opportunity to practice your debugging skills.

When everything is working, you could move on to the Refactor step to clean up things. Please don’t be shy go ahead.

Wrapping Up

In this tutorial, we focused on practicing Test-Driven Development (TDD) in an asynchronous Python setting by building and testing a simple, isolated service layer.

We kept things lean and focused on core testing skills:

  • Wrote async tests using pytest and pytest-asyncio
  • Used pytest-mock to replace dependencies like repositories
  • Modeled business logic in isolation (e.g., calculating a monthly account statement)
  • Structured code in a way that reflects real-world application layering: models, repositories, and services

By mocking the repository layer, we were able to test the service logic directly ensuring it correctly filtered transactions, calculated balances, and returned the expected output, all without implementing or depending on a real data backend.

This pattern of testing logic in isolation while mocking external dependencies is key to scalable and maintainable software development.

You’re now better equipped to apply TDD to async codebases, especially when dealing with service layers, domain logic, and external dependencies. This is a solid foundation you can build on as you expand to include actual I/O (like database or file handling) or async APIs in your projects.


Leave a Reply

Your email address will not be published. Required fields are marked *