ASK KNOX
beta
LESSON 179

Async Testing Patterns: From Band-Aid to Battle-Tested

35 tests fail overnight. The code is unchanged. A Python version bump broke a pattern your team trusted for two years. Here is why it happened and how to never let it happen again.

9 min read·Quality Engineering Mastery

CI goes red across multiple PRs simultaneously. You check the diff. The code is unchanged. What changed is the Python version — a routine bump from 3.11 to 3.12 as part of a dependency update.

The error is everywhere:

RuntimeError: There is no current event loop in thread 'MainThread'

35 tests fail. A pattern your team has trusted for two years broke overnight. Nobody changed the tests. Nobody changed the application code. A version bump you considered safe turned a DeprecationWarning nobody was watching into a hard failure that took down your entire test suite.

This is not a freak accident. It is exactly what deprecation warnings exist to prevent — and exactly what happens when you ignore them.

Why asyncio.get_event_loop() Broke

The root cause is a behavior change that spans three Python releases.

Pre-3.10: asyncio.get_event_loop() has a convenience behavior: if no loop exists in the current thread, it creates one automatically. Calling it in the main thread with no running loop silently gives you a working event loop. This made get_event_loop().run_until_complete(coro()) a simple, copy-paste pattern that worked reliably.

Python 3.10: The auto-creation behavior is deprecated. Python starts emitting DeprecationWarning: There is no current event loop in cases where a loop would previously have been created implicitly. The code still works — the warning is advisory.

Python 3.12: The implicit creation is gone. get_event_loop() called with no running loop in the main thread raises RuntimeError instead of creating a loop. The warning became a hard error.

Most teams missed this because their CI was not configured to fail on DeprecationWarnings. The warnings appeared in test output, were scrolled past, and accumulated silently until the behavior that generated them was removed.

The Quick Fix: asyncio.run()

The immediate fix is mechanical. Replace every call to asyncio.get_event_loop().run_until_complete(coro()) with asyncio.run(coro()). It restores a working test suite in minutes.

# Before (broken in 3.12+)
asyncio.get_event_loop().run_until_complete(some_coro())

# After (works but wasteful)
asyncio.run(some_coro())

asyncio.run() creates a fresh event loop, runs the coroutine to completion, tears the loop down, and returns the result. It has no dependency on any pre-existing loop state. It works in Python 3.7+ and will continue to work.

This is the right fix for a CI emergency at 11pm. It is not the right architecture for a test suite.

Why this matters: each asyncio.run() call pays the cost of creating and destroying an event loop. In a test file with 38 async calls across multiple test methods, that is 38 loop create/destroy cycles. It is architectural duct tape — each piece holds, but you are papering over the real problem rather than fixing it.

The real problem is that you are managing the event loop by hand inside your tests. That is the framework's job.

The Proper Pattern: pytest-asyncio

The correct fix moves event loop management to the test framework. pytest-asyncio makes your test functions natively async and manages the event loop lifecycle per test.

# Before — event loop managed by hand, loop creates and destroys each call
def test_something(self):
    asyncio.run(logger.log_skip(...))
    count = asyncio.run(_count_rows(db))
    assert count == 1

# After — event loop managed by the framework, one loop per test
@pytest.mark.asyncio
async def test_something(self):
    await logger.log_skip(...)
    count = await _count_rows(db)
    assert count == 1

The transformation is mechanical: the method becomes async def, asyncio.run(x) becomes await x, and @pytest.mark.asyncio tells the framework to manage the loop.

But the significance is architectural. You are no longer wrestling with event loop plumbing inside test code. You write async code the way async code is supposed to be written — with await — and the framework handles everything else.

The additional setup is minimal. Install pytest-asyncio, then configure it in pytest.ini or pyproject.toml:

# pytest.ini
[pytest]
asyncio_mode = auto

With asyncio_mode = auto, every async def test_* function is treated as an async test automatically — no decorator required on each individual test.

When Each Pattern Is Appropriate

Not every situation calls for pytest-asyncio. The right tool depends on context.

Use asyncio.run() when you are at the sync-to-async boundary — the exact edge where synchronous code must call into async code a single time. CLI entry points are the canonical example:

if __name__ == "__main__":
    asyncio.run(main())

One-off scripts, test helper utilities that bridge sync callers into async code exactly once, and setUp methods in unittest.TestCase classes (which cannot be async def) are also appropriate. The pattern is correct when there is exactly one boundary crossing.

Use pytest-asyncio any time you have a test file with more than two or three async calls. This is the default choice for any test file that touches async application code. It is what the framework is for.

Use raw event loop manipulation almost never. Low-level framework code implementing custom event loop policies, asyncio itself, and library internals are the rare exceptions. Application code and test code do not belong in this category.

The Migration Playbook

Converting a test file from asyncio.run() to pytest-asyncio takes about ten minutes and follows a predictable sequence.

Step 1: Identify all asyncio.run() calls. Search the file for asyncio.run(. Every occurrence is a candidate for conversion.

Step 2: Convert affected test methods to async def. If a test method calls asyncio.run(), the method itself should become async def. You cannot await inside a synchronous function.

Step 3: Add @pytest.mark.asyncio (or configure auto mode). Either decorate each async test, or set asyncio_mode = auto in your pytest config to handle the entire test session.

Step 4: Replace asyncio.run(x) with await x. Each asyncio.run(some_coro()) becomes await some_coro().

Step 5: Verify import asyncio is still needed. After conversion, scan the file. If asyncio is only referenced in the now-removed asyncio.run() calls, the import is dead. Clean it up.

# Before migration
import asyncio
from myapp import logger, _count_rows

class TestLogger:
    def test_log_skip(self, db):
        asyncio.run(logger.log_skip(db, market_id="abc", reason="low_volume"))
        count = asyncio.run(_count_rows(db, "skips"))
        assert count == 1

    def test_log_position(self, db):
        asyncio.run(logger.log_position(db, market_id="abc", size=100))
        result = asyncio.run(_get_latest(db, "positions"))
        assert result["market_id"] == "abc"

# After migration
import pytest

from myapp import logger, _count_rows

class TestLogger:
    @pytest.mark.asyncio
    async def test_log_skip(self, db):
        await logger.log_skip(db, market_id="abc", reason="low_volume")
        count = await _count_rows(db, "skips")
        assert count == 1

    @pytest.mark.asyncio
    async def test_log_position(self, db):
        await logger.log_position(db, market_id="abc", size=100)
        result = await _get_latest(db, "positions")
        assert result["market_id"] == "abc"

The logic is identical. The structure is correct. The test framework now owns the event loop, which is exactly where ownership belongs.

Deprecation Warnings Are Deadlines

The broader lesson from this incident is not about asyncio. It is about how you treat warnings.

Python's deprecation cycle is explicit and well-documented. When the interpreter emits DeprecationWarning, it is communicating a specific contract: "This behavior will be removed. Here is the version when it was deprecated. Plan accordingly."

The 35-test failure was predictable. The Python 3.10 release notes described the change. The warnings appeared in test output. The migration path was documented in the asyncio release notes. The only thing missing was a CI configuration that treated those warnings as the failures they predicted.

# pytest.ini — treat deprecation warnings as errors
[pytest]
filterwarnings =
    error::DeprecationWarning
    error::PendingDeprecationWarning

With this configuration, the 3.10 upgrade would have turned the DeprecationWarning into a test failure immediately — at the moment of the Python bump, not two years later when 3.12 removed the behavior. You would have fixed 35 tests across one version bump instead of across a chaotic multi-PR CI failure.

The pattern generalizes beyond Python. Libraries signal breaking changes through deprecation mechanisms across every ecosystem. The teams that catch these early treat warnings as first-class failures in CI. The teams that do not catch them early scroll past orange text in test output and ship the problem forward until it becomes an emergency.

The 35-test failure was an emergency that did not have to be one.

Bottom Line

asyncio.get_event_loop() broke in Python 3.12 because the implicit loop creation it depended on was deprecated in 3.10 and removed in 3.12. The warning told you exactly what was coming. asyncio.run() is the right emergency fix and the right permanent solution for sync-to-async boundary crossings. pytest-asyncio is the right architecture for any test file with meaningful async coverage — it moves event loop management to the framework and lets test code focus on testing. Treating DeprecationWarnings as errors in CI converts two-year time bombs into immediate, addressable failures. The test suite is not the problem. The process that let warnings accumulate unaddressed is the problem. Fix the process first.