Pourquoi les bugs async sont particuliers
Le code asynchrone introduit des comportements que l’on voit moins dans un script séquentiel : tâches qui continuent après le test, exceptions perdues, ordre d’exécution variable, timeout oublié, ressource non fermée. Ce ne sont pas forcément des bugs complexes, mais ils demandent une méthode.
Tester l’asynchrone revient à vérifier deux choses : le résultat métier et le comportement temporel. Une fonction doit retourner la bonne donnée, mais aussi respecter un timeout, annuler proprement ses tâches et fermer ses connexions.
Pour remettre ce sujet dans le parcours Python du site, vous pouvez aussi relire le langage Python, apprendre à coder en Python et les conseils pour écrire un code Python propre et lisible.
Tester une coroutine avec pytest-asyncio
import pytest
async def addition_async(a, b):
return a + b
@pytest.mark.asyncio
async def test_addition_async():
assert await addition_async(2, 3) == 5
Le plugin pytest-asyncio permet d’écrire des tests async def. Sans lui, pytest ne sait pas attendre correctement une coroutine.
Tester les timeouts
Un timeout est une règle importante, pas un détail technique. Il mérite un test.
import asyncio
import pytest
async def operation_lente():
await asyncio.sleep(10)
@pytest.mark.asyncio
async def test_timeout():
with pytest.raises(TimeoutError):
async with asyncio.timeout(0.1):
await operation_lente()
Ce type de test évite qu’une modification future transforme une opération bornée en attente infinie.
Repérer les coroutines oubliées
L’un des messages les plus fréquents est : RuntimeWarning: coroutine was never awaited. Il signifie qu’une coroutine a été créée mais jamais exécutée.
# Mauvais
resultat = fonction_async()
# Bon
resultat = await fonction_async()
Dans un projet sérieux, traitez ces warnings comme des erreurs. Ils signalent souvent un test qui passe sans avoir réellement exécuté le code important.
Journaliser avec le contexte de tâche
Les logs async peuvent s’entremêler. Ajoutez des identifiants utiles : URL, job_id, utilisateur, nom de tâche. L’objectif est de pouvoir reconstruire le déroulé sans deviner.
import logging
import asyncio
logger = logging.getLogger(__name__)
async def traiter(url):
task = asyncio.current_task()
logger.info("début", extra={"url": url, "task": task.get_name() if task else None})
await asyncio.sleep(1)
logger.info("fin", extra={"url": url})
Tester les erreurs réseau
Pour les clients HTTP asynchrones, évitez de dépendre du réseau dans les tests unitaires. Utilisez des mocks ou un transport de test. L’idée est de simuler une réponse lente, une erreur 500, un timeout et une réponse valide.
import httpx
async def get_status(client, url):
r = await client.get(url)
return r.status_code
# Dans vos tests, injectez un client contrôlé plutôt que de créer le client dans la fonction.
L’injection du client rend le code plus testable et évite les tests fragiles.
Fermer les ressources
Un test qui laisse un client HTTP, une connexion base de données ou une tâche ouverte peut provoquer des erreurs intermittentes. Utilisez async with, des fixtures async et des blocs finally pour garantir le nettoyage.
async def worker(queue):
try:
while True:
item = await queue.get()
# traitement
finally:
# fermer ressources, vider métriques, libérer handles
pass
Checklist de débogage async
- Chaque coroutine créée est-elle attendue ou planifiée ?
- Les tâches de fond sont-elles supervisées ?
- Les timeouts sont-ils définis près des appels externes ?
- Les ressources sont-elles fermées en cas d’annulation ?
- Les tests couvrent-ils succès, timeout, erreur réseau et annulation ?
Conclusion
Tester du code asynchrone demande surtout de la discipline. Utilisez pytest-asyncio, injectez vos clients externes, transformez les warnings en signaux sérieux et vérifiez les scénarios d’échec. Un code async bien testé reste lisible, prévisible et beaucoup plus simple à maintenir.
