Use pytest-asyncio to Run Tests Faster
Learn how pytest-asyncio loop scopes and modes affect test runtime, then benchmark changes safely without sacrificing isolation.
Short answer: pytest-asyncio does not make test cases run concurrently just because they are asynchronous. To reduce overhead, measure your suite first, then benchmark a broader event-loop scope if creating a fresh loop per test is a meaningful cost. A session-scoped loop can reduce repeated loop setup, but it changes test isolation: use it only when tests and async fixtures are safe to share state across that loop.
The plugin’s current documentation describes loop scopes and configuration, but does not promise a universal speedup or publish a benchmark percentage. Treat scope changes as measured experiments. The examples below use the configuration documented for pytest-asyncio 1.4.0; check your installed version and current project settings before applying them.
1. Measure before changing configuration
Start with a baseline in the same environment and with the same test selection you will use for the comparison. Run the suite several times and record the results; do not compare a warm run with a cold run or attribute unrelated changes to the loop setting.
python -m pytest
python -m pytest --durations=20
--durations=20 reports the slowest test durations. It helps find slow test work, but it does not prove that event-loop setup is the cause. Look for repeated async fixture setup, external service waits, expensive database or client initialization, and tests that perform avoidable sequential I/O. Fix the dominant cost before tuning loop scope.
- Run the relevant tests in a stable environment and save the baseline duration.
- Repeat the run enough times to spot normal variation. Keep the same command, dependencies, machine conditions, and test selection.
- Change one setting at a time.
- Compare the repeated results, then keep the change only if it produces a useful improvement and the tests retain the intended isolation.
2. Understand the event-loop scope tradeoff
By default, each async test uses its own event loop; the default test-loop scope is function. pytest-asyncio supports function, class, module, package, and session scopes. A broader scope means more tests may use the same loop, reducing loop creation opportunities while increasing the amount of state that can be shared. See the [pytest-asyncio marker reference](https://pytest-asyncio.readthedocs.io/en/stable/reference/markers/) and [configuration reference](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html).
| Scope | Loop is shared across | Consider it when | Tradeoff |
|---|---|---|---|
| function | One test | You want the strongest default isolation | More loop setup if many tests run |
| class | Tests in a class | Related tests can safely share a loop | State can leak between methods |
| module | Tests in a module | Module tests and fixtures are compatible with one loop | One test can affect another in the module |
| package | Tests in a package | A package has intentional shared async infrastructure | Broader shared state requires more care |
| session | The test session | You have measured loop setup overhead and checked suite-wide compatibility | Most opportunity for cross-test state leakage |
Do not choose the broadest scope simply because it sounds fastest. It can change assumptions about loop-bound resources, pending tasks, clients, and fixtures. If a fixture or library object is bound to a particular loop, using it from a test on another loop can fail. If tests mutate shared state or leave tasks behind, a shared loop can make failures order-dependent.
3. Try a session-scoped loop as a controlled experiment
For a project where loop setup appears significant, add the setting to pyproject.toml and benchmark again:
[tool.pytest.ini_options]
asyncio_default_test_loop_scope = "session"
This sets the default loop scope for tests. It is an experiment, not a guaranteed optimization. The official [guide to changing the default test-loop scope](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_default_test_loop.html) documents this setting.
You can use other scopes by replacing session with function, class, module, or package. Start narrower if only one related group needs a shared loop. Re-run the tests that use async fixtures and loop-bound resources, as well as the full suite, before keeping a broader default.
Check fixture scope separately
Fixture caching scope and event-loop scope are related but distinct choices. A session-scoped async fixture may need a loop with a compatible lifetime; broadening the test loop alone does not make every fixture safe to share. Review the fixture and loop-scope guidance for your installed plugin version, then align fixture lifetime with the loop it uses. Avoid retaining clients, tasks, or transports beyond the loop that owns them.
4. Select strict or auto mode deliberately
pytest-asyncio supports strict and auto modes. Current configuration documentation says the default is strict when no mode is configured. Confirm the behavior against your installed version and configuration. In strict mode, async tests and fixtures need explicit pytest-asyncio ownership; auto mode is convenient when asyncio is the project’s only async test framework. Strict mode is the clearer choice when multiple async frameworks or plugins need to coexist. The older [concepts page](https://pytest-asyncio.readthedocs.io/en/v0.20.3/concepts.html) explains the mode rationale; use current configuration docs for current behavior.
To opt into auto mode in pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
Or select the mode for one run:
python -m pytest --asyncio-mode=auto
For explicit strict mode, configure asyncio_mode = "strict" or pass --asyncio-mode=strict. Mode controls plugin discovery and ownership; it is not a speed switch. Do not combine a mode change with a loop-scope change in the same benchmark, since then you cannot tell which change affected the result.
5. Async tests are not automatically parallel
An async def test can await concurrent operations inside that test, but that does not mean pytest schedules separate test cases concurrently. pytest-asyncio’s guide says parametrized async cases still run sequentially. See [parametrizing asynchronous tests](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/parametrize_with_asyncio.html).
import asyncio
import pytest
@pytest.mark.asyncio
@pytest.mark.parametrize("value", [1, 2, 3])
async def test_fetch_value(value):
result = await fetch_value(value)
assert result == value
@pytest.mark.asyncio
async def test_fetches_independently():
results = await asyncio.gather(
fetch_value(1),
fetch_value(2),
fetch_value(3),
)
assert results == [1, 2, 3]
The first test runs once per parameter as separate test cases. The second starts the three awaitables together within one test. Use concurrency only when the operations are independent and the code under test supports it; it does not reduce CPU-bound work automatically, and shared services may impose their own limits.
6. A repeatable benchmark and safety checklist
- Keep Python, pytest, pytest-asyncio, dependency versions, test selection, and machine conditions constant.
- Record several baseline and changed runs; compare the distribution or typical duration, not a single best run.
- Use the same cache and service state for both configurations.
- Run tests in different orders if the suite supports it, and investigate order-dependent failures.
- Check for pending tasks, unclosed clients, loop-bound fixtures, and state that survives between tests.
- Keep the setting only if the improvement is repeatable and the loss of isolation is acceptable.
- Do not report a generic percentage: results depend on the suite and environment.
7. Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
| Async test is not handled by pytest-asyncio | Strict mode is active, and the test is not explicitly marked or assigned to the plugin | Add @pytest.mark.asyncio as appropriate, or choose auto mode only if asyncio is the project’s sole async framework. Check the plugin’s discovery rules. |
| Fixture is reported as having the wrong loop or scope | Fixture lifetime and event-loop lifetime do not match, or plugin versions/configuration differ | Review fixture scope and loop scope together. Use a supported scope and consult the installed version’s documentation. |
| “Future attached to a different loop” or similar loop-affinity error | A future, client, or other loop-bound object was created on one loop and used on another | Create and close it within the owning loop’s lifetime. Avoid sharing it across tests that use different loops. |
| Tests pass alone but fail in the full suite | Shared loop state, unfinished tasks, or fixture cleanup leaks between tests | Return affected tests to a narrower scope, ensure cleanup cancels and awaits tasks, and remove mutable shared state. |
| Broader scope does not make the suite faster | Loop creation was not a significant part of runtime, or other work dominates | Keep the simpler isolation setting and profile slow tests, fixture setup, I/O, and repeated work instead. |
| Older event-loop customization recipe emits a deprecation warning | The recipe overrides the deprecated event_loop_policy fixture |
Follow the current [multiple-loop guide](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/multiple_loops.html), which recommends the pytest_asyncio_loop_factories hook. |
| Parametrized async tests still run one after another | Parametrization creates separate cases; it is not concurrent scheduling | Use concurrency inside a test for independent operations, or investigate a compatible test-parallelization approach separately. |
8. Performance, reliability, and cost
Performance: broader loop scope can reduce repeated setup only when that setup is a meaningful share of runtime. Slow network calls, database work, sleeps, and application logic may dominate. Measure the real suite before and after.
Reliability: function scope gives each test a fresh loop by default and makes accidental state sharing less likely. Broader scopes can be correct, but require deliberate cleanup and fixture design. Keep isolation for tests that depend on fresh loop state.
Cost: pytest-asyncio configuration has no per-test service charge. The relevant cost is developer and CI time: avoid spending effort on a loop-scope change that does not affect measured runtime. If tests call paid external services, their own usage may affect cost; this plugin does not remove those charges.
9. Or skip the browser setup
If your async tests need website screenshots, you can either manage a browser, its dependencies, waits, and cleanup in your test suite, or make a single request to ScreenshotNeo, a website screenshot API and MCP server from Yorker Media. Its parameters include full-page capture, device and viewport settings, waits, custom headers, cookies, and JavaScript; see the ScreenshotNeo API documentation.
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card required.
10. Frequently asked questions
Does pytest-asyncio run tests in parallel?
No. Async tests and parametrized cases run sequentially under pytest-asyncio; use concurrency inside a test for independent awaitables.
Should every project use a session-scoped loop?
No. Use it only when benchmarking shows loop setup matters and tests and fixtures are safe to share the loop.
Which mode should a project choose?
Auto is convenient for asyncio-only projects. Strict gives explicit plugin ownership and better fit when async frameworks or plugins coexist. Verify the default for the installed version.
Can I mix loop scopes?
Yes, where the plugin’s supported markers and fixture configuration allow it. Choose the narrowest scope that fits each group and verify compatibility against current documentation.


