The Python ecosystem for asynchronous programming continues to mature, and testing asynchronous code effectively is paramount. The pytest-asyncio plugin has long been a cornerstone for developers working with asyncio in their Pytest suites. With the landmark release of version 1.0.0 on May 25, 2025, pytest-asyncio introduces significant changes aimed at simplifying the API, improving performance, and aligning more closely with modern asyncio practices.
This comprehensive guide will explore the key features and breaking changes in pytest-asyncio 1.0, with a primary focus on the removal of the event_loop fixture. It will then provide a detailed, step-by-step migration path, complete with code examples, to help developers smoothly transition their existing test suites to this new version.
What’s New in pytest-asyncio 1.0? A Refined Approach to Async Testing
The 1.0.0 release of pytest-asyncio is more than just a version bump; it represents a thoughtful evolution of the library. The changes are designed to offer a more intuitive and efficient testing experience.
Key enhancements and changes include:
- Removal of the Deprecated
event_loopFixture: This is the most significant breaking change. Theevent_loopfixture, which was a common way to access and manage the asyncio event loop within tests and fixtures, has been completely removed. This change pushes towards a more standardized way of handling event loops, as will be detailed in the migration section. - New Event Loop Creation Strategy: Scoped event loops (e.g., module-scoped or class-scoped loops) are now created only once per scope, rather than potentially multiple times. This optimization can lead to reduced fixture setup overhead and faster test collection times, especially in large test suites with many scoped asynchronous fixtures or tests.
- Enhanced
loop_scopeFlexibility: Theloop_scopeargument for the@pytest.mark.asynciomarker has become more flexible. It no longer strictly requires a Pytest Collector (like a class or module node) to exist at the level of the specified scope. For instance, a test function can be marked with@pytest.mark.asyncio(loop_scope="class")even if it’s not part of a class, aligning its behavior more closely with thescopeargument ofpytest_asyncio.fixture. This consistency simplifies understanding and usage. - Preliminary Python 3.14 Support: Keeping pace with Python’s evolution, version 1.0.0 includes preliminary support for the upcoming Python 3.14.
- Bug Fixes: Several bugs have been addressed, including an error when using Pytest’s
--setup-planoption, issues with unsuppressed import errors when using--doctest-ignore-import-errors, and a “fixture not found” error related to package-scoped loops.
These changes collectively aim to provide a more robust, performant, and developer-friendly plugin for testing asynchronous Python applications. The removal of the event_loop fixture, while a breaking change, paves the way for a cleaner and more asyncio-native approach to test writing.
The Elephant in the Room: Removal of the event_loop Fixture
The most impactful change in pytest-asyncio 1.0 is undoubtedly the removal of the event_loop fixture. For years, developers relied on this fixture to:
- Run coroutines in synchronous tests using
event_loop.run_until_complete(). - Obtain a reference to the event loop in asynchronous tests and fixtures, often to pass it explicitly to legacy APIs or for fine-grained control.
- Define custom event loops with specific scopes or types by overriding the
event_loopfixture.
The rationale behind its removal is to streamline the API, reduce boilerplate, and encourage patterns more idiomatic to asyncio itself. The explicit event_loop fixture could sometimes lead to inconsistencies in loop management, especially when combined with Pytest’s own fixture scoping. The new approach centralizes loop control via markers and dedicated fixtures like event_loop_policy, offering a clearer and more robust system. The new approach relies on:
asyncio.get_running_loop(): For accessing the current event loop within anasyncfunction, this standard library function is now the preferred method.@pytest.mark.asyncio(loop_scope="..."): To control the lifecycle and scope of the event loop used for a test or a group of tests.@pytest_asyncio.fixture(loop_scope="..."): To control the lifecycle and scope of the event loop for asynchronous fixtures.event_loop_policyfixture: For scenarios requiring different types of event loops (e.g., usinguvloop), this fixture can be overridden.
This shift means that tests and fixtures no longer explicitly request an event_loop argument to get a loop instance. Instead, the loop is implicitly managed by pytest-asyncio based on markers and configuration, and async code can access the current loop via asyncio.get_running_loop() when necessary. This reduces boilerplate and makes the test code cleaner.
Migration Guide: Upgrading Your Tests to pytest-asyncio 1.0
Migrating to pytest-asyncio 1.0 requires careful attention, primarily due to the event_loop fixture’s removal. The official documentation provides guidance for migrating from older versions like v0.21, which forms the basis of these steps.
Prerequisites and Initial Considerations: asyncio_mode
Before diving into code changes, it’s important to understand pytest-asyncio’s operating modes: strict and auto. This is configured via the asyncio_mode option in your pytest.ini (or pyproject.toml, tox.ini) or via the --asyncio-mode command-line flag.
strictmode (default):- Async tests must be marked with
@pytest.mark.asyncio. - Async fixtures must be decorated with
@pytest_asyncio.fixture. - This mode is designed for projects that might use multiple asynchronous libraries (e.g.,
asyncioandtrio) and needpytest-asyncioto only handle explicitly marked items.
- Async tests must be marked with
automode:pytest-asyncioautomatically adds the@pytest.mark.asynciomarker to allasync deftest functions.- It also treats all
async deffixtures aspytest-asynciofixtures, even if they are decorated with the standard@pytest.fixture. This significantly simplifies fixture definition inautomode, as you no longer need to import and use@pytest_asyncio.fixturefor most async fixtures. - This mode is simpler for projects using
asyncioexclusively.
The migration steps below generally assume you’ll adapt to the requirements of your chosen mode. If you’re in auto mode, some explicit pytest_asyncio.fixture decorations might not be strictly necessary but can be good for clarity.
Step 1: Addressing event_loop Fixture Usage
This step focuses on tests and fixtures that directly used the event_loop argument.
- Convert Synchronous Users to Asynchronous: If you have synchronous tests (
def test_...) or synchronous fixtures (@pytest.fixture def my_fixture...) that used theevent_loopfixture (typically viaevent_loop.run_until_complete()), these must be converted to be asynchronous (async def).- Old (synchronous test using
event_loop):
- Old (synchronous test using
# conftest.py or test file
# (Illustrative - http_client not defined here)
# async def http_client(url):...
def test_http_client_sync(event_loop):
url = 'http://httpbin.org/get'
resp = event_loop.run_until_complete(http_client(url)) # Fictional http_client
assert b'HTTP/1.1 200 OK' in resp
pass # Placeholder
- New (migrated to asynchronous test):
import pytest
import asyncio
# async def http_client(url):...
@pytest.mark.asyncio
async def test_http_client_async():
url = 'http://httpbin.org/get'
resp = await http_client(url)
assert b'HTTP/1.1 200 OK' in resp
await asyncio.sleep(0) # Placeholder for actual async operation
- Adapt Asynchronous Users: For tests and fixtures that were already
async defbut tookevent_loopas an argument:- Remove the
event_loopargument from their definition. - If the loop instance is genuinely needed inside the function (e.g., for an API that doesn’t automatically use the running loop), obtain it using
loop = asyncio.get_running_loop(). Many modernasyncioAPIs (likeasyncio.sleep) use the running loop by default. - Old (asynchronous test explicitly using
event_loopargument):
import asyncio import pytest # pytestmark = pytest.mark.asyncio # Example of module-level marker @pytest.mark.asyncio async def test_example_old(event_loop): # event_loop injected await asyncio.sleep(0.01, loop=event_loop) # Explicitly passing loop- New (migrated asynchronous test):
import asyncio import pytest @pytest.mark.asyncio async def test_example_new(): # loop = asyncio.get_running_loop() # Get loop if needed for specific APIs await asyncio.sleep(0.01) # asyncio.sleep uses running loop by default - Remove the
The following table summarizes the common patterns for migrating event_loop usage:
Old Way (pre-1.0, using event_loop fixture) |
New Way (pytest-asyncio 1.0) | Notes |
|---|---|---|
def test_sync_needs_loop(event_loop): event_loop.run_until_complete(my_coro()) |
@pytest.mark.asyncio async def test_async_equivalent(): await my_coro() |
Convert test to be async. |
@pytest.mark.asyncio async def test_uses_loop_arg(event_loop): await func(loop=event_loop) |
@pytest.mark.asyncio async def test_uses_running_loop(): loop = asyncio.get_running_loop(); await func(loop=loop) (if func truly needs loop arg) |
Or simply await func() if func uses get_running_loop() internally or asyncio.sleep-like calls. |
@pytest.fixture def my_sync_fixture_using_loop(event_loop): event_loop.run_until_complete(...) |
@pytest_asyncio.fixture async def my_async_fixture(): await... (or @pytest.fixture in auto mode) |
Convert fixture to be async. |
@pytest.fixture(scope="module") def event_loop(): loop = MyCustomLoop(); yield loop; loop.close() |
(Removed) Define loop_scope="module" on tests/fixtures. Use event_loop_policy fixture for custom loop types. |
Loop creation/closing is handled by pytest-asyncio based on loop_scope and event_loop_policy. |
This table provides a quick reference for the most common transformations. The removal of direct event_loop injection simplifies test signatures and encourages reliance on asyncio’s own mechanisms for loop management where possible.
Step 2: Configuring Event Loop Scopes (Handling Custom event_loop Implementations)
If your test suite previously defined custom event_loop fixtures, often to change the loop’s scope (e.g., to module or session) or to use a specific loop implementation (like uvloop), these require specific migration steps.
- Identify Original Scope and Purpose: For each custom
event_loopfixture you had:- Determine its
scope(e.g.,"function","class","module","session"). - Determine if it was providing a custom loop type or just a different scope.
- Determine its
- Apply
loop_scopefor Scoped Loops: If the customevent_loopfixture was primarily for changing the scope:- For tests that relied on this custom-scoped loop, apply the
@pytest.mark.asyncio(loop_scope="your_original_scope")marker. - For asynchronous fixtures that relied on this custom-scoped loop, apply the
@pytest_asyncio.fixture(loop_scope="your_original_scope")decorator (or@pytest.fixture(scope="your_original_scope")inautomode, then also ensure the test using it has the correctloop_scope). - Example: Migrating a module-scoped loop setup:
- Old (custom module-scoped
event_loop):
- Old (custom module-scoped
# conftest.py import pytest import pytest_asyncio # Assuming older version or explicit decoration import asyncio @pytest.fixture(scope="module") def event_loop(request): # Custom module-scoped loop loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @pytest_asyncio.fixture(scope="module") # Async fixture using module-scoped loop async def my_module_fixture(event_loop): # Implicitly uses the above await asyncio.sleep(0.01, loop=event_loop) return "module_data" # test_module.py # (in older versions, test might implicitly use the module-scoped loop if fixture did) @pytest.mark.asyncio async def test_something_module_scope(my_module_fixture, event_loop): assert my_module_fixture == "module_data" await asyncio.sleep(0.01, loop=event_loop)- New (migrated using
loop_scope):
# conftest.py import pytest import pytest_asyncio # If in strict mode for fixtures import asyncio # The custom event_loop fixture for scope is REMOVED. # If a custom loop *type* was needed, use event_loop_policy (see below). @pytest_asyncio.fixture(loop_scope="module") # Explicitly declare loop_scope for the fixture async def my_module_fixture(): # loop = asyncio.get_running_loop() # Access if needed await asyncio.sleep(0.01) return "module_data" # test_module.py # To run all tests in this module with a module-scoped loop: # pytestmark = pytest.mark.asyncio(loop_scope="module") # or individually: @pytest.mark.asyncio(loop_scope="module") # Test now explicitly uses module-scoped loop async def test_something_module_scope(my_module_fixture): assert my_module_fixture == "module_data" # loop = asyncio.get_running_loop() # Access if needed await asyncio.sleep(0.01)- This new approach is more declarative. The
loop_scopeargument directly tellspytest-asynciohow to manage the loop for that specific test or fixture.
- For tests that relied on this custom-scoped loop, apply the
-
Use
event_loop_policyfor Custom Loop Types: If your customevent_loopfixture was providing a different event loop policy (e.g., to useuvloop), you should now override theevent_loop_policyfixture.# conftest.py import pytest import asyncio # import uvloop # If using uvloop class MyCustomEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # Or your specific policy # def new_event_loop(self): # return uvloop.new_event_loop() # Example for uvloop pass @pytest.fixture(scope="session") # Policy fixture scope should typically be broad def event_loop_policy(): return MyCustomEventLoopPolicy()pytest-asynciowill then use this policy to create event loops for all relevant scopes. -
Global Default Scopes (Alternative/Complementary): If many tests or fixtures in your suite share a common non-function scope, you can set global defaults in your Pytest configuration file (e.g.,
pytest.ini):# pytest.ini [pytest] asyncio_default_test_loop_scope = module asyncio_default_fixture_loop_scope = modulePossible values are
function,class,module,package,session. This can reduce the need for explicitloop_scopemarkers everywhere. -
Remove Old Custom
event_loopFixture Definitions: Once the scopes (and types, if applicable) are correctly handled byloop_scopearguments, default configurations, or theevent_loop_policyfixture, you can and should remove your old customevent_loopfixture definitions. - A Note on
asyncio_default_fixture_loop_scope: The migration guide for v0.21 suggests settingasyncio_default_fixture_loop_scope = functionif not already set, to silence a deprecation warning. Documentation for version 0.26.0 indicates that if this option is unset, it defaults to the fixture’s own scope, but will default tofunctionin future versions. This implies thatfunctionis the forward-looking default and a safe choice unless a broader scope is explicitly needed for many fixtures.
Step 3: Adapting Asynchronous Fixtures
Ensure your async def fixtures are correctly defined and scoped:
- Use Correct Decorators:
- In
strictmode (the default): Allasync deffixtures must be decorated with@pytest_asyncio.fixture. - In
automode: Standard@pytest.fixturecan be used forasync deffixtures, aspytest-asynciowill handle them automatically. Using@pytest_asyncio.fixtureinautomode is also fine and can be clearer.
- In
- Specify
loop_scope: If an asynchronous fixture requires a specific event loop scope (e.g., it was intended to share a module-scoped loop with tests), ensure itsloop_scopeis set in its decorator:@pytest_asyncio.fixture(loop_scope="module"). This was highlighted as a fix for issues where fixtures and tests could end up on different loops if scopes weren’t aligned. -
Accessing the Loop (If Needed): The example provided for
my_global_resourcecomments outloop = asyncio.get_running_loop(). To make this point more concrete, consider adding a small, separate example whereasyncio.get_running_loop()is actively used within a fixture. For example:import pytest_asyncio import asyncio @pytest_asyncio.fixture(loop_scope="session") # Example of a session-scoped async fixture async def my_global_resource(): # loop = asyncio.get_running_loop() # Get loop if needed # resource = await setup_global_resource(loop) resource = {"data": "initialized"} await asyncio.sleep(0.1) # Simulate async setup yield resource # await teardown_global_resource(resource, loop) await asyncio.sleep(0.1) # Simulate async teardown
Step 4: Final Review and Testing
After making these changes, a thorough run of your entire test suite is crucial.
- Execute all tests:
pytest - Watch for common errors:
RuntimeError: Event loop is closed: This can happen if there’s a mismatch in how loop lifecycles are expected versus how they are managed, or if an operation tries to use a loop thatpytest-asynciohas already cleaned up.RuntimeError: Task <Task pending name='...' coro=<...>> attached to a different loop: This indicates that parts of your test setup (e.g., a fixture) and the test itself might be running on different event loop instances. This was a known issue in older versions under certain conditions and is generally addressed by correctloop_scopeusage.- Unexpected changes in test behavior: Altered fixture setup/teardown timing due to different loop scoping can sometimes subtly affect tests.
- The experience of projects like
cockpit-project, which encountered “compatibility issues” requiring workarounds when upgrading (though specific code changes were not detailed in the snippet), underscores the necessity of comprehensive testing in your own environment.
Why Upgrade? The Benefits of pytest-asyncio 1.0
The migration effort is justified by the tangible improvements and a more modern approach offered by pytest-asyncio 1.0:
- ‘Streamlined API & Pytest Harmony: Enjoy a cleaner, more Pytest-idiomatic API. With
event_loopgone, loop management aligns beautifully with Pytest’s native markers and fixture scoping vialoop_scope, reducing cognitive load and boilerplate.’ The removal of the specializedevent_loopfixture and the consistent use ofloop_scopefor both tests and fixtures lead to a more declarative and Pytest-idiomatic way of managing event loop lifecycles. This reduces the learning curve and makes test suites easier to understand and maintain. - Improved Performance and Resource Management: The strategy of creating scoped event loops only once per scope is a key optimization. For test suites with many tests or fixtures sharing module, class, or session scopes, this can significantly speed up test collection and execution by reducing redundant loop setup and teardown operations.
- Better Alignment with Modern
asyncio: The plugin now encourages the use of standardasynciopatterns, such asasyncio.get_running_loop()for accessing the loop. This makespytest-asynciofeel less like a separate abstraction and more like a natural extension ofasynciofor testing purposes. - Future-Proofing: With preliminary support for Python 3.14 and ongoing maintenance by the
pytest-devteam, upgrading ensures your testing framework remains compatible with the latest Python advancements and continues to receive improvements and bug fixes. - Enhanced Flexibility in Loop Scoping: The
loop_scopeargument in@pytest.mark.asynciois now more flexible, no longer strictly requiring a Pytest collector to exist at the specified scope level. This offers more fine-grained control over loop management without imposing rigid structural requirements on your tests.
Collectively, these benefits contribute to a more efficient, maintainable, and robust asynchronous testing experience. The move away from the explicit event_loop fixture towards a more declarative scope-based management system represents a maturation of the library’s design.
Conclusion and Further Resources
pytest-asyncio 1.0 marks a significant step forward for testing asynchronous Python code. The primary change, the removal of the event_loop fixture, is replaced by a more streamlined and asyncio-native approach centered around loop_scope configuration and asyncio.get_running_loop(). While migration requires careful attention to how event loops were previously used and scoped, the process is manageable by following the steps outlined.
The benefits of upgrading—including a cleaner API, potential performance gains, and better alignment with modern Python—make it a worthwhile endeavor for any project relying on pytest-asyncio. Developers are encouraged to upgrade and explore the refined capabilities of this essential testing tool.
For more detailed information, refer to the official resources:
By embracing these changes, the Python community can continue to build and test robust asynchronous applications with greater ease and efficiency.