Tester du code Python sur systèmes embarqués et capteurs : mocker GPIO, I2C, UART et stratégies de test sans matériel.
Quand ton code parle à des capteurs, des GPIO ou des bus I2C, tu ne peux pas dépendre du matériel pour tes tests. Cette page montre comment mocker toute la couche électronique pour des tests fiables, rapides et portables.
Les tests de code embarqué posent des défis uniques : le matériel n'est pas toujours disponible, les capteurs peuvent retourner des valeurs imprévisibles, et certains scénarios (capteur en panne, valeur hors plage) sont difficiles à reproduire physiquement.
Les tests s'exécutent sur n'importe quelle machine — CI/CD, laptop Windows, macOS. Pas besoin du hardware.
Tester ce qui se passe quand un capteur retourne des valeurs erronées, se déconnecte, ou sort de sa plage de mesure.
Pas d'attente pour stabiliser un capteur de température ou pour une mesure ultrasonique. Les tests sont déterministes et rapides.
Les mocks retournent exactement les mêmes valeurs à chaque run. Plus de tests flottants dus aux variations de l'environnement.
Stratégie : Séparer toujours la logique métier (calculs, décisions, alertes) des appels matériels (lire GPIO, écrire sur I2C). Les tests unitaires couvrent la logique ; les tests d'intégration, eux, nécessitent le vrai matériel.
RPi.GPIO n'existe que sur Raspberry Pi. Pour tester sur d'autres machines, on le remplace entièrement par un mock. C'est le cas de mock le plus courant en électronique Python.
Bonne pratique : Ne jamais appeler RPi.GPIO directement dans la logique métier. Créer une classe GpioDriver qui encapsule tous les accès matériels. Les tests mockent cette classe, pas GPIO directement.
# gpio_driver.py — encapsule tous les accès GPIO try: import RPi.GPIO as GPIO except ImportError: GPIO = None # Mode développement (pas de RPi) class GpioDriver: BCM = 11 # constantes GPIO OUT = 0 IN = 1 HIGH = True LOW = False def __init__(self): if GPIO: GPIO.setmode(GPIO.BCM) GPIO.setwarnings(False) def configurer_pin(self, pin: int, mode: int) -> None: if GPIO: GPIO.setup(pin, mode) def ecrire(self, pin: int, etat: bool) -> None: if GPIO: GPIO.output(pin, etat) def lire(self, pin: int) -> bool: if GPIO: return GPIO.input(pin) raise RuntimeError("GPIO non disponible") def cleanup(self) -> None: if GPIO: GPIO.cleanup() # systeme_led.py — logique métier (testable) class SystemeLED: def __init__(self, gpio: GpioDriver, pin_led: int): self.gpio = gpio self.pin = pin_led gpio.configurer_pin(pin_led, GpioDriver.OUT) def allumer(self) -> None: self.gpio.ecrire(self.pin, GpioDriver.HIGH) def eteindre(self) -> None: self.gpio.ecrire(self.pin, GpioDriver.LOW) def basculer(self) -> None: etat_actuel = self.gpio.lire(self.pin) self.gpio.ecrire(self.pin, not etat_actuel)
import pytest from unittest.mock import MagicMock, patch, call # ── Fixture : GPIO mocké ─────────────────────────── @pytest.fixture def gpio_mock(): """GPIO driver complètement simulé.""" mock = MagicMock(spec=GpioDriver) mock.lire.return_value = False # LOW par défaut return mock @pytest.fixture def led(gpio_mock): return SystemeLED(gpio_mock, pin_led=17) # ── Tests ───────────────────────────────────────── def test_init_configure_pin(gpio_mock, led): # La pin doit être configurée en sortie à l'init gpio_mock.configurer_pin.assert_called_once_with(17, GpioDriver.OUT) def test_allumer_envoie_high(gpio_mock, led): led.allumer() gpio_mock.ecrire.assert_called_once_with(17, GpioDriver.HIGH) def test_eteindre_envoie_low(gpio_mock, led): led.eteindre() gpio_mock.ecrire.assert_called_once_with(17, GpioDriver.LOW) def test_basculer_depuis_low(gpio_mock, led): gpio_mock.lire.return_value = False # LED éteinte led.basculer() gpio_mock.ecrire.assert_called_once_with(17, True) # doit s'allumer def test_basculer_depuis_high(gpio_mock, led): gpio_mock.lire.return_value = True # LED allumée led.basculer() gpio_mock.ecrire.assert_called_once_with(17, False) # doit s'éteindre # ── Mocker RPi.GPIO directement (alternative) ───── def test_rpi_gpio_direct(): # Pour du code qui importe RPi.GPIO directement with patch("systeme_led.GPIO") as mock_gpio: mock_gpio.BCM = 11 mock_gpio.OUT = 0 mock_gpio.input.return_value = 0 # LOW # … instancier et tester
Température, humidité, distance ultrasonique — chaque capteur a ses caractéristiques, sa plage valide et ses modes de défaillance. Les mocks permettent de simuler tous ces scénarios sans matériel.
# ── capteur_dht.py ──────────────────────────────── import adafruit_dht import board class LecteurDHT22: """Lecture température et humidité via DHT22.""" TEMP_MIN, TEMP_MAX = -40.0, 80.0 # plage capteur HUM_MIN, HUM_MAX = 0.0, 100.0 def __init__(self, pin=board.D4): self._dht = adafruit_dht.DHT22(pin) def lire(self) -> dict: temp = self._dht.temperature hum = self._dht.humidity if temp is None or hum is None: raise IOError("Lecture DHT22 échouée (None)") return {"temperature": temp, "humidite": hum} def est_valide(self, lecture: dict) -> bool: t = lecture["temperature"] h = lecture["humidite"] return (self.TEMP_MIN <= t <= self.TEMP_MAX and self.HUM_MIN <= h <= self.HUM_MAX) # ── test_capteur_dht.py ─────────────────────────── from unittest.mock import patch, MagicMock, PropertyMock import pytest @pytest.fixture def mock_dht(): with patch("capteur_dht.adafruit_dht.DHT22") as mock_cls: instance = mock_cls.return_value # DHT22 expose temperature et humidity comme propriétés type(instance).temperature = PropertyMock(return_value=22.5) type(instance).humidity = PropertyMock(return_value=55.0) yield instance def test_lecture_normale(mock_dht): capteur = LecteurDHT22() lecture = capteur.lire() assert lecture["temperature"] == 22.5 assert lecture["humidite"] == 55.0 def test_lecture_echouee_none(mock_dht): # Simuler une lecture ratée (common avec DHT22) type(mock_dht).temperature = PropertyMock(return_value=None) capteur = LecteurDHT22() with pytest.raises(IOError, match="None"): capteur.lire() @pytest.mark.parametrize("temp, hum, valide", [ (22.5, 55.0, True), # normal (-40.0, 0.0, True), # limite basse exacte (80.0, 100.0, True), # limite haute exacte (-40.1, 50.0, False), # temp trop basse (80.1, 50.0, False), # temp trop haute (25.0, -0.1, False), # humidité négative (25.0, 100.1, False), # humidité > 100% ]) def test_validation_limites(mock_dht, temp, hum, valide): type(mock_dht).temperature = PropertyMock(return_value=temp) type(mock_dht).humidity = PropertyMock(return_value=hum) capteur = LecteurDHT22() lecture = {"temperature": temp, "humidite": hum} assert capteur.est_valide(lecture) is valide
# ── capteur_distance.py ─────────────────────────── import time import from gpiozero import DistanceSensor class CapteurUltrason: DISTANCE_MAX_CM = 400.0 DISTANCE_MIN_CM = 2.0 SEUIL_ALERTE_CM = 30.0 def __init__(self, trigger_pin=23, echo_pin=24): self._sensor = DistanceSensor(echo=echo_pin, trigger=trigger_pin, max_distance=4) def distance_cm(self) -> float: # DistanceSensor.distance retourne des mètres return self._sensor.distance * 100 def objet_detecte(self) -> bool: return self.distance_cm() <= self.SEUIL_ALERTE_CM def mesure_moyenne(self, n: int = 5) -> float: mesures = [self.distance_cm() for _ in range(n)] return sum(mesures) / n # ── test_distance.py ────────────────────────────── from unittest.mock import patch, MagicMock, PropertyMock @pytest.fixture def mock_sensor(): with patch("capteur_distance.DistanceSensor") as mock_cls: instance = mock_cls.return_value # distance est une propriété → PropertyMock type(instance).distance = PropertyMock(return_value=0.25) # 25cm yield instance def test_distance_cm_conversion(mock_sensor): capteur = CapteurUltrason() assert capteur.distance_cm() == 25.0 # 0.25m → 25cm def test_objet_detecte_sous_seuil(mock_sensor): type(mock_sensor).distance = PropertyMock(return_value=0.20) # 20cm capteur = CapteurUltrason() assert capteur.objet_detecte() is True # 20 < 30 → détecté def test_objet_non_detecte_sur_seuil(mock_sensor): type(mock_sensor).distance = PropertyMock(return_value=0.30) # 30cm capteur = CapteurUltrason() assert capteur.objet_detecte() is True # 30 <= 30 → détecté def test_mesure_moyenne_5_lectures(mock_sensor): # Simule 5 lectures différentes valeurs = [0.23, 0.25, 0.24, 0.26, 0.22] type(mock_sensor).distance = PropertyMock(side_effect=valeurs) capteur = CapteurUltrason() moyenne = capteur.mesure_moyenne(n=5) assert moyenne == pytest.approx(24.0, abs=0.01) # (23+25+24+26+22)/5
I2C, SPI et UART sont les bus de communication de l'électronique embarquée. Chaque protocole a sa bibliothèque Python et ses particularités à mocker.
Bus série 2 fils (SDA + SCL). Adresse 7 bits par esclave. Couramment utilisé pour BMP280 (pression), SSD1306 (écran OLED), MPU6050 (gyroscope).
Bus 4 fils (MOSI, MISO, CLK, CS). Plus rapide qu'I2C. Utilisé pour MCP3008 (ADC), MAX31855 (thermocouple), écrans TFT.
# ── capteur_bmp280.py ───────────────────────────── import smbus2, bmp280 class CapteurBMP280: ADRESSE_I2C = 0x76 PRESSION_MER_HPA = 1013.25 def __init__(self, bus_num=1): self._bus = smbus2.SMBus(bus_num) self._bmp = bmp280.BMP280(i2c_dev=self._bus) def lire(self) -> dict: return { "temperature": self._bmp.get_temperature(), "pression": self._bmp.get_pressure(), } def altitude(self) -> float: # Formule barométrique simplifiée p = self._bmp.get_pressure() return 44330 * (1.0 - (p / self.PRESSION_MER_HPA) ** (1 / 5.255)) # ── test_bmp280.py ──────────────────────────────── from unittest.mock import patch, MagicMock @pytest.fixture def mock_bmp(): with patch("capteur_bmp280.smbus2.SMBus"), \ patch("capteur_bmp280.bmp280.BMP280") as mock_cls: instance = mock_cls.return_value instance.get_temperature.return_value = 23.4 instance.get_pressure.return_value = 1013.25 yield instance def test_lecture_bmp280(mock_bmp): capteur = CapteurBMP280() lecture = capteur.lire() assert lecture["temperature"] == 23.4 assert lecture["pression"] == 1013.25 def test_altitude_au_niveau_mer(mock_bmp): mock_bmp.get_pressure.return_value = 1013.25 # pression mer capteur = CapteurBMP280() assert capteur.altitude() == pytest.approx(0.0, abs=0.1) @pytest.mark.parametrize("pression_hpa, altitude_attendue_m", [ (1013.25, 0), # niveau de la mer (954.6, 500), # 500m d'altitude (899.3, 1000), # 1000m (795.0, 2000), # 2000m — Alpes ]) def test_altitude_calcul(mock_bmp, pression_hpa, altitude_attendue_m): mock_bmp.get_pressure.return_value = pression_hpa capteur = CapteurBMP280() assert capteur.altitude() == pytest.approx(altitude_attendue_m, abs=5)
smbus2 directement# ── Mocker des lectures de registres I2C bruts ──── # Code : bus.read_i2c_block_data(addr, reg, 6) # Retourne 6 octets bruts depuis un registre from unittest.mock import patch, MagicMock def test_lecture_registres_bruts(): with patch("smbus2.SMBus") as mock_bus_cls: mock_bus = mock_bus_cls.return_value.__enter__.return_value # Simuler 6 octets de données brutes (réponse capteur) mock_bus.read_i2c_block_data.return_value = [ 0x59, 0xD8, # temp MSB/LSB 0x4E, 0x20, # pression MSB/LSB 0x00, 0x00 # réservé ] with smbus2.SMBus(1) as bus: data = bus.read_i2c_block_data(0x76, 0xF7, 6) assert data[0] == 0x59 mock_bus.read_i2c_block_data.assert_called_with(0x76, 0xF7, 6) def test_ecriture_registre_config(): with patch("smbus2.SMBus") as mock_bus_cls: mock_bus = mock_bus_cls.return_value configurer_capteur(0x76) # écrit dans le registre 0xF5 # Vérifier que la bonne config a été envoyée mock_bus.write_byte_data.assert_called_with(0x76, 0xF5, 0xA0)
# ── adc_spi.py ──────────────────────────────────── import spidev class MCP3008: """ADC 10 bits SPI — 8 canaux, 0-1023""" RESOLUTION = 1023 VREF = 3.3 def __init__(self, bus=0, device=0): self._spi = spidev.SpiDev() self._spi.open(bus, device) self._spi.max_speed_hz = 1_350_000 def lire_canal(self, canal: int) -> int: if not 0 <= canal <= 7: raise ValueError(f"Canal {canal} invalide (0-7)") cmd = [0x01, (0x08 | canal) << 4, 0x00] reponse = self._spi.xfer2(cmd) return ((reponse[1] & 0x03) << 8) | reponse[2] def tension_v(self, canal: int) -> float: brut = self.lire_canal(canal) return (brut / self.RESOLUTION) * self.VREF # ── test_adc.py ─────────────────────────────────── @pytest.fixture def mock_spi(): with patch("adc_spi.spidev.SpiDev") as mock_cls: instance = mock_cls.return_value # xfer2 simule la réponse SPI : valeur brute 512 (≈1.65V) instance.xfer2.return_value = [0x00, 0x02, 0x00] # → 512 yield instance def test_lecture_canal_0(mock_spi): adc = MCP3008() valeur = adc.lire_canal(0) assert valeur == 512 mock_spi.xfer2.assert_called_once() def test_tension_milieu_plage(mock_spi): adc = MCP3008() tension = adc.tension_v(0) assert tension == pytest.approx(1.65, abs=0.01) # 512/1023 * 3.3 def test_canal_invalide(mock_spi): adc = MCP3008() with pytest.raises(ValueError, match="invalide"): adc.lire_canal(8) # canal 8 n'existe pas sur MCP3008
pyserial# ── uart_gps.py ─────────────────────────────────── import serial class LecteurGPS: """Lit les trames NMEA depuis un GPS sur UART.""" def __init__(self, port="/dev/ttyAMA0", baudrate=9600): self._serial = serial.Serial(port, baudrate, timeout=1) def lire_trame(self) -> str: ligne = self._serial.readline().decode("utf-8").strip() if not ligne.startswith("$"): raise ValueError(f"Trame NMEA invalide : {ligne!r}") return ligne def parser_gprmc(self, trame: str) -> dict: if not trame.startswith("$GPRMC"): raise ValueError("Pas une trame GPRMC") champs = trame.split(",") return { "heure": champs[1], "validite": champs[2], # "A"=actif, "V"=invalide "latitude": float(champs[3]) if champs[3] else None, "longitude": float(champs[5]) if champs[5] else None, } # ── test_uart.py ────────────────────────────────── @pytest.fixture def mock_serial(): with patch("uart_gps.serial.Serial") as mock_cls: instance = mock_cls.return_value yield instance def test_lire_trame_valide(mock_serial): trame = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" mock_serial.readline.return_value = (trame + "\r\n").encode() gps = LecteurGPS() resultat = gps.lire_trame() assert resultat.startswith("$GPRMC") def test_trame_corrompue(mock_serial): mock_serial.readline.return_value = b"CORRUPTED_DATA\r\n" gps = LecteurGPS() with pytest.raises(ValueError, match="invalide"): gps.lire_trame() def test_parser_gprmc_complet(mock_serial): trame = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" gps = LecteurGPS() parsed = gps.parser_gprmc(trame) assert parsed["validite"] == "A" assert parsed["latitude"] == pytest.approx(4807.038) def test_connexion_serie_perdue(mock_serial): # Simuler une déconnexion UART mock_serial.readline.side_effect = serial.SerialException("Port déconnecté") gps = LecteurGPS() with pytest.raises(serial.SerialException): gps.lire_trame()
Les capteurs ont des plages de fonctionnement précises. Tester aux limites exactes, en-dessous et au-dessus — c'est là que les bugs se cachent. Cette technique s'appelle Boundary Value Analysis (BVA).
| Capteur | Mesure | Min | Max | Résolution | Bibliothèque |
|---|---|---|---|---|---|
| DHT22 | Température | -40°C | +80°C | 0.1°C | adafruit_dht |
| DHT22 | Humidité | 0% | 100% | 0.1% | adafruit_dht |
| BMP280 | Pression | 300 hPa | 1100 hPa | 0.18 Pa | smbus2 / bmp280 |
| BMP280 | Température | -40°C | +85°C | 0.01°C | smbus2 / bmp280 |
| HC-SR04 | Distance | 2 cm | 400 cm | 0.3 cm | gpiozero |
| MCP3008 | Tension (ADC) | 0 V | VREF (3.3V) | 10 bits | spidev |
| MPU6050 | Accélération | -16g | +16g | 16 bits | smbus2 |
Pour chaque limite : tester la valeur juste en-dessous (min - ε), exactement à la limite (min), et juste au-dessus (min + ε). Idem pour la limite haute. C'est la technique la plus efficace pour trouver les off-by-one errors.
import pytest from unittest.mock import MagicMock class SystemeAlerte: """Déclenche une alerte si température hors plage sécurisée.""" TEMP_SECURITE_MIN = 10.0 TEMP_SECURITE_MAX = 35.0 TAUX_HUMIDITE_MAX = 85.0 def __init__(self, capteur, notificateur): self.capteur = capteur self.notificateur = notificateur def verifier(self) -> str: data = self.capteur.lire() t, h = data["temperature"], data["humidite"] if t < self.TEMP_SECURITE_MIN: self.notificateur.alerter(f"FROID: {t}°C") return "alerte_froid" if t > self.TEMP_SECURITE_MAX: self.notificateur.alerter(f"CHAUD: {t}°C") return "alerte_chaud" if h > self.TAUX_HUMIDITE_MAX: self.notificateur.alerter(f"HUMIDE: {h}%") return "alerte_humidite" return "ok" # ── Fixtures réutilisables ──────────────────────── @pytest.fixture def capteur_mock(): return MagicMock() @pytest.fixture def notif_mock(): return MagicMock() @pytest.fixture def systeme(capteur_mock, notif_mock): return SystemeAlerte(capteur_mock, notif_mock) # ════════════════════════════════════════════════ # BVA — Température (limites 10°C et 35°C) # ════════════════════════════════════════════════ @pytest.mark.parametrize("temp, hum, resultat_attendu, alerte_attendue", [ # ── Zone froide ────────────────────────────── (9.9, 50.0, "alerte_froid", True), # juste sous le min → alerte (10.0, 50.0, "ok", False), # exactement le min → OK (10.1, 50.0, "ok", False), # juste au-dessus → OK # ── Zone normale ───────────────────────────── (22.5, 50.0, "ok", False), # valeur typique # ── Zone chaude ────────────────────────────── (34.9, 50.0, "ok", False), # juste sous le max → OK (35.0, 50.0, "ok", False), # exactement le max → OK (35.1, 50.0, "alerte_chaud", True), # dépasse → alerte # ── Humidité ───────────────────────────────── (22.5, 85.0, "ok", False), # exactement le max → OK (22.5, 85.1, "alerte_humidite", True), # dépasse → alerte # ── Valeurs extrêmes capteur ───────────────── (-40.0, 0.0, "alerte_froid", True), # min physique DHT22 (80.0, 100.0,"alerte_chaud", True), # max physique DHT22 ]) def test_bva_temperature(systeme, capteur_mock, notif_mock, temp, hum, resultat_attendu, alerte_attendue): capteur_mock.lire.return_value = {"temperature": temp, "humidite": hum} resultat = systeme.verifier() assert resultat == resultat_attendu if alerte_attendue: notif_mock.alerter.assert_called_once() else: notif_mock.alerter.assert_not_called() # ════════════════════════════════════════════════ # Tester les pannes matérielles # ════════════════════════════════════════════════ def test_capteur_deconnecte(systeme, capteur_mock): capteur_mock.lire.side_effect = IOError("Capteur déconnecté") with pytest.raises(IOError): systeme.verifier() def test_capteur_instable(systeme, capteur_mock, notif_mock): # Simule un capteur qui échoue puis réussit capteur_mock.lire.side_effect = [ IOError("Timeout"), {"temperature": 22.5, "humidite": 50.0} ] with pytest.raises(IOError): systeme.verifier() # 1er appel → IOError resultat = systeme.verifier() # 2ème appel → OK assert resultat == "ok" def test_lecture_parasite_nan(systeme, capteur_mock): # Certains capteurs retournent NaN ou inf en cas de perturbation import math capteur_mock.lire.return_value = { "temperature": float("nan"), "humidite": 50.0 } # NaN comparisons retournent toujours False # Le système doit gérer ce cas resultat = systeme.verifier() assert resultat != "ok" # NaN ne doit pas passer comme "normal"
# ── Drift / dérive lente du capteur ─────────────── def test_derive_capteur(capteur_mock): """Simule une dérive progressive (capteur qui vieillit).""" # Température qui monte de 0.5°C à chaque lecture valeurs = [{"temperature": 20.0 + i*0.5, "humidite": 50.0} for i in range(10)] capteur_mock.lire.side_effect = valeurs lectures = [capteur_mock.lire() for _ in range(10)] temps = [l["temperature"] for l in lectures] assert temps[0] == 20.0 assert temps[-1] == 24.5 # ── Bruit aléatoire contrôlé ────────────────────── def test_lissage_avec_bruit(capteur_mock): """Teste que la moyenne sur 10 mesures bruitées converge.""" import random; random.seed(42) valeur_reelle = 23.0 bruit = [{"temperature": valeur_reelle + random.uniform(-0.5, 0.5), "humidite": 50.0} for _ in range(10)] capteur_mock.lire.side_effect = bruit moyenne = sum(capteur_mock.lire()["temperature"] for _ in range(10)) / 10 assert moyenne == pytest.approx(valeur_reelle, abs=0.3) # ── Test de timeout réseau / I2C ────────────────── def test_retry_apres_timeout(capteur_mock): """Le système doit réessayer 3 fois avant d'abandonner.""" capteur_mock.lire.side_effect = [ TimeoutError(), TimeoutError(), {"temperature": 22.0, "humidite": 55.0} ] def lire_avec_retry(capteur, max_retries=3): for tentative in range(max_retries): try: return capteur.lire() except TimeoutError: if tentative == max_retries - 1: raise return None resultat = lire_avec_retry(capteur_mock) assert resultat["temperature"] == 22.0 assert capteur_mock.lire.call_count == 3 # 2 échecs + 1 succès