Quand votre application doit fonctionner sur une ménagerie de dizaines de téléphones réels, de différents fabricants, versions Android et résolutions d'écran, les tests manuels deviennent rapidement un cauchemar. Voici un pipeline Python qui découvre automatiquement tous les appareils connectés, installe l'APK sur tous en parallèle, exécute des tests instrumentés, enregistre une vidéo de chaque exécution, et assemble ces enregistrements en un seul rapport vidéo avec FFmpeg.
L'ensemble de la stack > Python + ADB + FFmpeg < est un outillage QA standard. Pas de magie, juste l'automatisation de la corvée.

Architecture du pipeline
1adb devices ──► liste des numéros de série2 │3 ▼4Installation APK (en parallèle sur tous les appareils, ThreadPoolExecutor)5 │6 ▼7pour chaque appareil :8 screenrecord (en arrière-plan) → am instrument (exécution des tests) → arrêt + récupération vidéo9 │10 ▼11FFmpeg : superposition du numéro de série sur chaque clip + concaténation ──► test_report.mp4
Ce dont vous aurez besoin
- ADB (Android Debug Bridge) depuis Android Platform Tools -> contrôle des appareils.
- Python 3.10+ -> orchestration (j'utilise list[str], tuple[...] sans from __future__).
- FFmpeg -> traitement et assemblage vidéo.
- Appareils avec le débogage USB activé, connectés en USB (ou via Wi-Fi avec adb tcpip).
Un principe qui traverse tout le code : je passe les arguments à subprocess sous forme de liste et sans shell=True. C'est plus sûr (pas d'injection via les noms de fichiers) et ça ne casse pas avec les espaces ou les caractères spéciaux dans les chemins.
1. Découverte des appareils

adb devices liste également les appareils en état non autorisé / hors ligne. Nous ne conservons que ceux qui sont réellement dans l'état "device".
1import subprocess23def get_devices() -> list[str]:4 """Retourne les numéros de série de tous les appareils dans l'état 'device'."""5 out = subprocess.run(6 ["adb", "devices"],7 capture_output=True, text=True, check=True,8 ).stdout910 serials: list[str] = []11 for line in out.splitlines()[1:]: # la première ligne est l'en-tête "List of devices"12 line = line.strip()13 if line.endswith("\tdevice"): # ignorer unauthorized / offline14 serials.append(line.split("\t")[0])15 return serials
2. Installation parallèle de l'APK
Installer sur 50 appareils un par un est lent. Nous répartissons le travail sur un pool de threads : chaque adb install est un processus séparé, donc les threads fonctionnent très bien ici (nous attendons des E/S, pas de calcul CPU).
1from concurrent.futures import ThreadPoolExecutor, as_completed23def install_apk(serial: str, apk_path: str) -> tuple[str, bool, str]:4 r = subprocess.run(5 ["adb", "-s", serial, "install", "-r", "-g", apk_path],6 capture_output=True, text=True,7 )8 ok = r.returncode == 0 and "Success" in r.stdout9 return serial, ok, (r.stdout + r.stderr).strip()1011def install_on_all(apk_path: str, serials: list[str]) -> None:12 with ThreadPoolExecutor(max_workers=len(serials) or 1) as pool:13 futures = [pool.submit(install_apk, s, apk_path) for s in serials]14 for f in as_completed(futures):15 serial, ok, log = f.result()16 print(f"[{'OK' if ok else 'FAIL'}] {serial}")17 if not ok:18 print(f" {log}")
Flags : -r -> réinstallation en conservant les données, -g -> accorder toutes les autorisations d'exécution immédiatement (pratique pour que les tests ne butent pas sur les dialogues d'autorisation).
3. Exécution des tests instrumentés
am instrument exécute les tests Espresso/JUnit sur l'appareil. Il affiche OK en cas de succès et FAILURES!!! en cas d'échec sur stdout -> c'est ainsi que nous déterminons le résultat.
1def run_instrumented_tests(2 serial: str,3 package: str,4 runner: str = "androidx.test.runner.AndroidJUnitRunner",5) -> tuple[str, bool]:6 r = subprocess.run(7 ["adb", "-s", serial, "shell", "am", "instrument", "-w",8 f"{package}/{runner}"],9 capture_output=True, text=True,10 )11 ok = "FAILURES!!!" not in r.stdout and r.returncode == 012 return serial, ok
package est l'ID du package de test, généralement com.example.app.test.
4. Enregistrement de l'écran pendant un test
screenrecord enregistre la vidéo directement sur l'appareil. Limitations à garder à l'esprit : une limite d'environ 3 minutes par fichier et pas d'audio. Nous démarrons l'enregistrement en arrière-plan, exécutons le test, puis l'arrêtons proprement et récupérons le fichier sur l'hôte.
La façon la plus fiable d'arrêter l'enregistrement n'est pas de signaler l'adb local, mais d'utiliser pkill sur l'appareil lui-même -> ainsi screenrecord finalise correctement le conteneur MP4.
1import time23def start_recording(serial: str, remote: str = "/sdcard/run.mp4") -> subprocess.Popen:4 return subprocess.Popen(5 ["adb", "-s", serial, "shell", "screenrecord", remote]6 )78def stop_recording(9 serial: str,10 proc: subprocess.Popen,11 remote: str = "/sdcard/run.mp4",12 local: str = "run.mp4",13) -> None:14 # SIGINT sur l'appareil permet à screenrecord de fermer le fichier correctement15 subprocess.run(["adb", "-s", serial, "shell", "pkill", "-SIGINT", "screenrecord"])16 proc.wait(timeout=10)17 time.sleep(1) # laisser un moment à l'appareil pour finaliser le conteneur18 subprocess.run(["adb", "-s", serial, "pull", remote, local], check=True)
5. Assemblage du rapport vidéo avec FFmpeg
Différents appareils ont différentes résolutions d'écran, donc vous ne pouvez pas simplement les concaténer avec -c copy. Nous normalisons chaque clip vers un format commun (1080×1920) et superposons le numéro de série via drawtext en cours de route. Après cela, tous les clips sont identiques et l'assemblage final est une concaténation rapide sans ré-encodage.
1def label_clip(src: str, dst: str, label: str) -> None:2 """Mettre à l'échelle en 1080x1920 et superposer un libellé (le numéro de série de l'appareil)."""3 vf = (4 "scale=1080:1920:force_original_aspect_ratio=decrease,"5 "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,"6 f"drawtext=text='{label}':x=20:y=20:fontsize=42:"7 "fontcolor=white:box=1:[email protected]"8 )9 subprocess.run(10 ["ffmpeg", "-y", "-i", src, "-vf", vf,11 "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", dst],12 check=True,13 )1415def concat_report(clips: list[str], out: str = "test_report.mp4") -> None:16 with open("concat_list.txt", "w") as f:17 for c in clips:18 f.write(f"file '{c}'\n")19 subprocess.run(20 ["ffmpeg", "-y", "-f", "concat", "-safe", "0",21 "-i", "concat_list.txt", "-c", "copy", out],22 check=True,23 )
6. Tout assembler
1def main() -> None:2 apk = "app-debug.apk"3 package = "com.example.app.test"4 runner = "androidx.test.runner.AndroidJUnitRunner"56 serials = get_devices()7 if not serials:8 print("Aucun appareil trouvé. Vérifiez l'USB et la sortie de 'adb devices'.")9 return1011 print(f"Appareils trouvés : {len(serials)}")12 install_on_all(apk, serials)1314 labeled: list[str] = []15 for serial in serials:16 proc = start_recording(serial)17 _, ok = run_instrumented_tests(serial, package, runner)18 stop_recording(serial, proc, local=f"{serial}.mp4")19 print(f"[{'PASS' if ok else 'FAIL'}] tests sur {serial}")2021 out = f"{serial}_labeled.mp4"22 label_clip(f"{serial}.mp4", out, serial)23 labeled.append(out)2425 concat_report(labeled, "test_report.mp4")26 print("Terminé : test_report.mp4")2728if __name__ == "__main__":29 main()
Ici, la boucle "enregistrement + test" s'exécute séquentiellement sur tous les appareils -> c'est plus lisible ainsi. Pour une vraie ferme, vous voudriez également encapsuler ce bloc dans un ThreadPoolExecutor, afin que tous les appareils soient testés en même temps ; la logique est la même que pour l'installation dans la section 2.
Ne réinventez pas la roue : outils prêts à l'emploi

- scrcpy -> miroir et contrôle en temps réel d'un appareil depuis votre PC. Indispensable pour déboguer des tests qui échouent.
- Appium / Espresso / UI Automator -> frameworks de test d'interface utilisateur complets ; le am instrument ci-dessus est leur moteur.
- Gradle Managed Devices -> exécutez des tests sur des émulateurs directement depuis la build, sans manipulation manuelle d'ADB.
- Firebase Test Lab / AWS Device Farm -> une flotte cloud d'appareils réels si vous préférez ne pas gérer votre propre matériel.
- GNU parallel -> si vous préférez orchestrer depuis bash plutôt que Python.
L'économie : ce que ça coûte et ce que ça économise
L'automatisation des tests ne consiste pas à "gagner de l'argent à partir de rien" -> il s'agit de réduire les deux postes de dépenses les plus importants : les heures-personnes et les minutes cloud. Voici une estimation pour trois échelles typiques. Les chiffres sont indicatifs et dépendent de la région, du fabricant d'appareils et des tarifs des fournisseurs ; vérifiez les tarifs en vigueur avant d'acheter.
Votre propre ferme -> investissement unique
Article
Pour 10 appareils
Pour 30 appareils
Pour 50 appareils
Téléphones Android d'occasion (~60 $ chacun)
~600 $
~1 800 $
~3 000 $
Hubs USB alimentés
~100 $
~250 $
~400 $
Mini-PC / hôte
~400 $
~400 $
~500 $
Câbles, rack, divers
~80 $
~150 $
~250 $
Total unique
~1 200 $
~2 600 $
~4 150 $
Électricité par mois
quelques centimes
~10–20 $
~20–40 $
C'est une dépense d'investissement : payez une fois, et la ferme fonctionne ensuite pendant des années à un coût quasi nul.
Cloud -> vous payez à la minute
Firebase Test Lab, AWS Device Farm, BrowserStack et autres facturent par minute-appareil -> environ 0,05 à 0,20 $ par minute-appareil. Une seule exécution de régression sur 30 appareils à 5 minutes chacun représente 150 minutes-appareil, soit environ 7,5 à 30 $ par exécution.
Multipliez maintenant par l'intensité de l'IC :
Fréquence d'exécution
Exécutions par mois
Coût (à ~15 $/exécution)
2× par jour
~44
~660 $/mois
10× par jour
~220
~3 300 $/mois
À chaque push (équipe active)
500+
7 500 $+/mois
Seuil de rentabilité : une ferme de 30 appareils (~2 600 $) s'amortit face au cloud en environ 4 mois à un rythme modeste de 2 exécutions par jour ; avec une IC active, en moins d'un mois. Après cela, la facture cloud continue de couler chaque mois, et la ferme, non.
Travail manuel -> ce qui est libéré
Une exécution manuelle d'un seul scénario de régression sur 30 appareils représente environ une journée de travail d'un ingénieur QA. Exécuter la régression deux fois par semaine représente environ 8 jours-personnes par mois. Pour un coût QA d'environ 1 600 à 2 700 $/mois, c'est une part significative d'un salaire que le pipeline libère pour un travail significatif au lieu de la corvée "connecter–installer–toucher–enregistrer" ×30.
Comment cela se traduit en argent
Il n'y a pas de revenu direct "du script" ici -> il y a trois mécanismes indirects mais très réels :
- Des versions plus rapides. La régression en minutes au lieu d'une journée → vous publiez des fonctionnalités plus souvent → vous répondez plus vite au marché. Pour un produit par abonnement, cela se traduit directement par la rétention et les revenus.
- Moins de bugs en production. Attraper un crash sur un Samsung spécifique avant la publication coûte quelques centimes ; le même crash atteignant les utilisateurs signifie des notes de magasin en baisse, du churn et des remboursements. Chaque bug attrapé tôt, ce sont quelques avis négatifs qui ne seront jamais écrits.
- Ça se vend comme un service. La mise en place d'une ferme d'appareils et de tests IC est un rôle très demandé en freelance et en sous-traitance. Le pipeline ci-dessus est un noyau prêt à l'emploi pour une telle offre.
La différence clé avec les "schémas de farming d'engagement" : là, l'argent vient de la tromperie des algorithmes et se termine par un bannissement. Ici, il vient des heures économisées et des pertes évitées. Le premier s'effondre ; le second est un cas d'affaires durable dont vous n'avez pas honte de faire la démonstration à un client.
Conclusion
Le résultat est un pipeline reproductible : une seule commande, et l'application est testée sur l'ensemble du parc d'appareils, avec un rapport vidéo à la fin qui montre le comportement sur chaque appareil spécifique.
![Comment Claude Fable 5 m'a rapporté 560 000 yens en 3 minutes [Prompt inclus]](/cdn-cgi/image/width=1920,quality=90,format=auto,metadata=none/https%3A%2F%2Fcms-assets.youmind.com%2Fmedia%2F1783534184712_mr5ds6_HMn0yn-bIAA9fiR.jpg)




