Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›pytest & Testing›Testing Async Code — pytest-asyncio

Advanced Testing

Testing Async Code — pytest-asyncio

The pytest-asyncio plugin bridges the gap between the synchronous nature of standard pytest and the asynchronous execution model of modern applications. It provides the necessary event loop management to handle coroutines that would otherwise remain unexecuted or crash when called from a standard test function. Mastering this plugin is essential for testing I/O-bound systems, database interactions, and web services that rely on non-blocking concurrency.

The Challenge of Synchronous Test Runners

Standard pytest test functions are executed synchronously by default, which presents a significant architectural mismatch when testing code built on coroutines. When you call an async function directly in a normal test, the execution returns a coroutine object instead of waiting for the code inside to complete. Because pytest expects the function to resolve immediately, the coroutine is never scheduled on an event loop, leaving the actual logic inside your function completely unexecuted. pytest-asyncio solves this by hooking into the test collection and execution lifecycle to detect these coroutines. It automatically creates, runs, and closes an event loop for every marked test. This mechanism ensures that the test runner suspends execution at 'await' points, allowing the underlying I/O tasks to progress and eventually resume, mirroring the real-world production environment where these functions reside and operate within a managed event loop.

import asyncio
import pytest

# A simple async task
async def fetch_data():
    await asyncio.sleep(0.01)
    return "data"

@pytest.mark.asyncio
async def test_simple_async():
    # Without the decorator, this would fail to execute the coroutine body
    result = await fetch_data()
    assert result == "data"

Managing Event Loop Scopes

A core aspect of testing asynchronous systems is managing the lifecycle of the event loop. By default, pytest-asyncio provides a 'function' scope for the event loop, meaning a fresh loop is created and destroyed for every single test case. This behavior is ideal for ensuring test isolation, as it prevents lingering tasks or state from one test from impacting another. However, as test suites grow, creating a new loop repeatedly can become a performance bottleneck. To optimize this, the plugin allows you to configure a broader 'module' or 'session' scope for the event loop. When you set this, the plugin keeps the same event loop active for all tests within that defined scope. You must be careful with this approach, however, because it requires you to ensure that your tests clean up their own resources, such as closing open database connections or cancelling background tasks, to avoid polluting the state for subsequent test execution.

import pytest

# Configure event loop scope via pytest configuration
# In conftest.py: pytest_asyncio_loop_scope = "module"

@pytest.fixture(scope="module")
def event_loop():
    """Overrides the default loop creation to share it across module tests."""
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

Asynchronous Fixtures

Fixtures are the cornerstone of modular test design, and they are fully supported in asynchronous testing. An async fixture is defined by marking it with the @pytest_asyncio.fixture decorator. When a test requests an async fixture, the testing framework will await the fixture's setup code before passing the result to the test function. This is critical for scenarios like connecting to an asynchronous message queue or initializing a database connection pool. The framework effectively orchestrates the dependency injection by resolving the promise returned by the fixture. If the fixture contains a yield statement, the code following the yield serves as the teardown. pytest-asyncio correctly waits for the teardown to complete as well, ensuring that asynchronous cleanup code is executed before the event loop is shut down. This consistency allows you to build complex, multi-stage integration tests that manage shared infrastructure without blocking the overall test runner.

import pytest_asyncio

@pytest_asyncio.fixture
async def async_resource():
    # Perform setup asynchronously
    await asyncio.sleep(0.01)
    resource = {"status": "active"}
    yield resource
    # Perform cleanup asynchronously
    await asyncio.sleep(0.01)

@pytest.mark.asyncio
async def test_with_async_fixture(async_resource):
    assert async_resource["status"] == "active"

Handling Timeouts and Exceptions

Asynchronous code is notorious for introducing infinite hangs if a network request or internal state transition fails to complete. Testing these scenarios is vital, and pytest-asyncio provides mechanisms to enforce constraints on your coroutines. By using standard library tools like asyncio.wait_for, you can wrap your calls to ensure that a test fails explicitly if it exceeds a reasonable duration, preventing the entire test suite from hanging indefinitely. Furthermore, asserting that your async code raises the expected exceptions requires careful syntax; you must ensure the exception is awaited within the context manager so the event loop actually drives the execution of the fault-prone code. If you try to catch an exception before the coroutine has started execution, the test will incorrectly pass even if the underlying code is broken. Always check the stack trace against the awaited call to verify the failure occurred within the expected async boundary.

import pytest
import asyncio

@pytest.mark.asyncio
async def test_timeout_and_exception():
    # Enforce a strict time limit
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(asyncio.sleep(1), timeout=0.01)
    
    # Properly catch exceptions in async code
    with pytest.raises(ValueError):
        raise ValueError("Async failure")

Integration with Mocking Libraries

Mocking asynchronous dependencies follows the same principle as mocking synchronous ones, but with the added requirement that the mock object itself must be a coroutine. When you mock an async method, it is not sufficient to simply return a value; you must return a coroutine that resolves to that value. The most straightforward way to achieve this is using AsyncMock, which is specifically designed to simulate asynchronous interfaces. When you replace a method with an AsyncMock, any calls to it return an awaitable object. This is essential for isolating the logic of your service layer from external network calls. Because these mocks are themselves awaitable, they integrate seamlessly with the event loop managed by pytest-asyncio, allowing you to test complex control flow, verify call arguments, and define specific return sequences without needing a live network connection or heavy external dependencies.

from unittest.mock import AsyncMock
import pytest

@pytest.mark.asyncio
async def test_mocking_async_call():
    # Create a mock that acts like an async function
    mock_client = AsyncMock()
    mock_client.get_data.return_value = "mocked_response"
    
    result = await mock_client.get_data()
    
    assert result == "mocked_response"
    mock_client.get_data.assert_called_once()

Key points

  • The pytest-asyncio plugin is necessary to bridge the gap between synchronous test runners and asynchronous coroutines.
  • The @pytest.mark.asyncio decorator instructs the framework to run a test function inside an event loop.
  • Event loops can be scoped to individual functions, modules, or the entire test session to balance isolation and speed.
  • Asynchronous fixtures allow for complex setup and teardown of non-blocking infrastructure using the yield statement.
  • Always use 'await' when testing exceptions to ensure the coroutine actually executes within the test context.
  • Testing for timeouts is crucial to prevent asynchronous hangs from blocking the execution of the entire test suite.
  • The AsyncMock class is specifically required when mocking asynchronous methods to ensure they remain awaitable.
  • Properly closing resources in async teardown logic is vital to prevent memory leaks and dangling event loop tasks.

Common mistakes

  • Mistake: Forgetting to mark an async test function with @pytest.mark.asyncio. Why it's wrong: pytest treats it as a standard synchronous function, which may cause it to finish immediately without actually awaiting the coroutine. Fix: Ensure every async test function is decorated with @pytest.mark.asyncio.
  • Mistake: Misconfiguring the event loop scope. Why it's wrong: If tests share a scope incorrectly, state may leak between tests leading to flaky results. Fix: Use the 'asyncio_default_fixture_loop_scope' configuration option in your pytest.ini to explicitly set the loop scope.
  • Mistake: Using 'time.sleep()' inside an async test. Why it's wrong: This blocks the entire event loop, preventing other tasks from running and defeating the purpose of asynchronous testing. Fix: Use 'await asyncio.sleep()' instead.
  • Mistake: Not awaiting an async fixture that returns a value. Why it's wrong: You will receive a coroutine object instead of the actual data, causing assertions to fail unexpectedly. Fix: Ensure that any dependency injected async fixture is awaited in the test setup or test body.
  • Mistake: Failing to handle database connections or sockets properly in async teardown. Why it's wrong: Async resources might stay open after a test completes, causing 'resource leaked' warnings or hanging the test suite. Fix: Use 'yield' in async fixtures and perform cleanup code after the yield statement.

Interview questions

What is the primary purpose of the pytest-asyncio plugin when testing asynchronous code?

The pytest-asyncio plugin is essential because standard pytest is designed to execute synchronous test functions by default. When you mark a function as async, pytest cannot natively handle the event loop management required to execute that coroutine. This plugin provides the necessary infrastructure to discover, handle, and execute async test functions. It automatically manages the event loop, ensuring that each test is run within its own loop scope, which allows developers to use the 'await' keyword directly inside test functions, making testing asynchronous APIs or database interactions as simple as testing synchronous logic.

How do you mark an asynchronous test function so that pytest-asyncio recognizes and runs it correctly?

To ensure pytest-asyncio runs an asynchronous test, you must use the '@pytest.mark.asyncio' decorator on your test function. When you define a function with 'async def', pytest needs this specific marker to understand that the function should be treated as a coroutine that must be awaited. Without this marker, pytest might either skip the test or execute it incorrectly because it wouldn't know how to drive the event loop for that specific test case, leading to errors or warnings indicating that the coroutine was never awaited.

Can you explain how to define and use an async fixture in a pytest project?

Defining an async fixture is straightforward: you use the '@pytest.fixture' decorator, but you define the function as 'async def'. When other test functions or fixtures request this fixture, pytest-asyncio takes care of awaiting the fixture before providing the result to the test. This is incredibly powerful for setting up asynchronous dependencies, such as connecting to an async database or initializing an HTTP client. Because it is handled by the plugin, the lifecycle of the fixture—including teardown—is properly managed, ensuring the event loop remains active and clean throughout the setup and cleanup process.

What is the difference between setting the asyncio_mode to 'auto' versus 'strict' in your pytest configuration?

In 'strict' mode, pytest-asyncio requires every test or fixture that is asynchronous to be explicitly marked with the '@pytest.mark.asyncio' decorator. This is a safer approach for larger codebases where you want to avoid accidental execution of async code. Conversely, in 'auto' mode, pytest-asyncio will automatically treat all 'async def' functions as tests, even without the explicit decorator. While 'auto' is more convenient for developers by reducing boilerplate code, it can lead to unexpected behavior if you have helper async functions that are not intended to be executed as standalone tests by the discovery mechanism.

How would you compare using 'pytest-asyncio' versus using 'anyio' for testing asynchronous code in pytest?

While both aim to solve asynchronous testing, 'pytest-asyncio' is specifically tailored for asyncio-based projects and integrates deeply with the standard library event loop. It is the go-to standard for pure asyncio applications. 'Anyio', however, provides a higher-level abstraction that allows your tests to be backend-agnostic. This means you can write tests that work across different event loop implementations like trio or asyncio. 'Anyio' is better if you need multi-backend support, but 'pytest-asyncio' is generally more performant and familiar when your entire stack is built specifically on the asyncio paradigm, making it the more common choice for standard services.

How does the scope of an event loop fixture affect performance and state isolation in a large suite of async tests?

The scope of the event loop is a critical architectural decision. If you set the event loop scope to 'function', the loop is created and destroyed for every single test. While this provides perfect state isolation, it introduces overhead that can significantly slow down your suite. If you set it to 'session' or 'module', the loop persists across multiple tests, which is much faster. However, this creates a risk: if one test modifies shared state or leaks objects, it could cause non-deterministic failures in subsequent tests. Balancing performance requires careful cleanup in your fixtures to ensure the loop remains stable for the entire session.

All pytest & Testing interview questions →

Check yourself

1. What is the primary function of the @pytest.mark.asyncio decorator?

  • A.It converts a synchronous function into an asynchronous one.
  • B.It instructs the pytest-asyncio plugin to execute the function as a coroutine.
  • C.It tells the operating system to allocate more memory to the test process.
  • D.It forces the test to run in a separate thread instead of the event loop.
Show answer

B. It instructs the pytest-asyncio plugin to execute the function as a coroutine.
Option 2 is correct because the decorator registers the test with the plugin, which handles the event loop setup. Option 1 is wrong because it doesn't change the function type. Option 3 is unrelated to memory allocation. Option 4 is wrong because async tests run within the event loop, not necessarily a separate thread.

2. If you have an async fixture, how should you use it in an async test?

  • A.By calling the fixture function as a standard synchronous function.
  • B.By manually creating a new event loop inside the test function.
  • C.By requesting the fixture as an argument in the test function signature.
  • D.By wrapping the test function in a try-finally block to manage the fixture.
Show answer

C. By requesting the fixture as an argument in the test function signature.
Option 3 is correct because pytest handles the injection and awaiting of async fixtures automatically. Option 1 fails because calling it synchronously returns the coroutine object. Option 2 is redundant as the plugin handles the loop. Option 4 is unnecessary boilerplate code.

3. Why might a test pass locally but fail in CI when testing async code?

  • A.Because CI environments often run tests in alphabetical order.
  • B.Because of race conditions caused by implicit event loop dependencies.
  • C.Because pytest-asyncio requires a specific browser to be installed.
  • D.Because async code is not supported by standard CI runners.
Show answer

B. Because of race conditions caused by implicit event loop dependencies.
Option 2 is correct; asynchronous tests often rely on implicit shared state or loop settings that differ in varied environments, causing race conditions. Option 1 is standard behavior for all tests. Option 3 is incorrect as the plugin is framework-agnostic. Option 4 is false, as CI environments support async test execution.

4. When using the 'asyncio_mode = auto' configuration, what happens to async functions?

  • A.They are automatically ignored by the test collection process.
  • B.They must be explicitly marked with @pytest.mark.asyncio or they will fail.
  • C.They are automatically treated as async tests without needing the decorator.
  • D.They are converted to run on a multi-threaded pool by default.
Show answer

C. They are automatically treated as async tests without needing the decorator.
Option 2 is correct because 'auto' mode detects 'async def' functions and treats them as asyncio tests automatically. Option 1 is wrong as they are collected. Option 3 is a common misinterpretation; the mode removes the need for the decorator. Option 4 is incorrect as pytest-asyncio does not default to thread pooling for async tests.

5. What happens if you use 'time.sleep(1)' inside an async test method?

  • A.The event loop continues to process other tasks during the sleep.
  • B.The test will fail immediately with a TimeoutError.
  • C.The entire test process blocks, pausing all other concurrent tasks.
  • D.The test automatically converts the sleep to an awaitable operation.
Show answer

C. The entire test process blocks, pausing all other concurrent tasks.
Option 2 is correct; 'time.sleep' is a blocking call that halts the thread, which stops the event loop from advancing any other pending tasks. Option 1 is incorrect because it is blocking. Option 3 is incorrect because the test doesn't necessarily time out. Option 4 is incorrect as standard libraries are not magically patched by pytest.

Take the full pytest & Testing quiz →

← PreviousTesting Exceptions — pytest.raisesNext →Coverage with pytest-cov

pytest & Testing

25 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app