Python >> Tutoriel Python >  >> Python

Arrêtez d'utiliser datetime.now !

L'une de mes questions d'entretien d'embauche préférées est la suivante :

Cela semble assez innocent pour que quelqu'un suggère ceci comme solution :

import datetime

def tomorrow() -> datetime.date:
 return datetime.date.today() + datetime.timedelta(days=1)

Cela fonctionnera, mais il y a une question de suivi :

Avant de continuer... prenez une seconde pour réfléchir à votre réponse.

Approche naïve

L'approche la plus naïve pour tester une fonction qui renvoie la date de demain est la suivante :

# Bad
assert tomorrow() == datetime.date(2020, 4, 16)

Ce test passera aujourd'hui , mais il échouera n'importe quel autre jour.

Voici une autre façon de tester la fonction :

# Bad
assert tomorrow() == datetime.date.today() + datetime.timedelta(days=1)

Cela fonctionnera également, mais il y a un problème inhérent à cette approche. De la même manière que vous ne pouvez pas définir un mot dans le dictionnaire en l'utilisant lui-même, vous ne devez pas tester une fonction en répétant son implémentation.

Un autre problème avec cette approche est qu'elle ne teste qu'un seul scénario, pour le jour où elle est exécutée. Qu'en est-il de passer le lendemain sur un mois ou une année ? Et le lendemain du 2020-02-28 ?

Le problème avec les deux implémentations est que today est défini dans la fonction, et pour simuler différents scénarios de test, vous devez contrôler cette valeur. Une solution qui me vient à l'esprit est de se moquer de datetime.date , et essayez de définir la valeur renvoyée par today() :

>>> from unittest import mock
>>> with mock.patch('datetime.date.today', return_value=datetime.date(2020, 1, 1)):
... assert tomorrow() == datetime.date(2020, 1, 2)
...
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/lib/python3.7/unittest/mock.py", line 1410, in __enter__
 setattr(self.target, self.attribute, new_attr)
TypeError: can't set attributes of built-in/extension type 'datetime.date'

Comme l'exception le suggère, les modules intégrés écrits en C ne peuvent pas être moqués. Le unittest.mock La documentation traite spécifiquement de cette tentative de se moquer du module datetime. Apparemment, c'est un problème très courant et les rédacteurs de la documentation officielle ont estimé qu'il valait la peine de le mentionner. Ils vont même plus loin et créent un lien vers un article de blog sur ce problème précis. L'article vaut la peine d'être lu, et nous allons aborder la solution qu'il présente plus tard.

Comme tous les autres problèmes en Python, il existe des bibliothèques qui fournissent une solution. Deux bibliothèques qui se démarquent sont freezegun et libfaketime . Les deux offrent la possibilité de se moquer du temps à différents niveaux. Cependant, recourir à des bibliothèques externes est un luxe que seuls les développeurs de systèmes existants peuvent se permettre. Pour les nouveaux projets ou les projets suffisamment petits pour changer, il existe d'autres alternatives qui peuvent garder le projet exempt de ces dépendances.

Injection de dépendance

Le problème que nous essayions de résoudre avec mock peut également être résolu en modifiant l'API de la fonction :

import datetime

def tomorrow(asof: datetime.date) -> datetime.date:
 return asof + datetime.timedelta(days=1)

Pour contrôler le temps de référence de la fonction, le temps peut être fourni comme argument. Cela facilite le test de la fonction dans différents scénarios :

import datetime
assert tomorrow(asof=datetime.date(2020, 5, 1)) == datetime.date(2020, 5, 2)
assert tomorrow(asof=datetime.date(2019, 12, 31)) == datetime.date(2020, 1, 1)
assert tomorrow(asof=datetime.date(2020, 2, 28)) == datetime.date(2020, 2, 29)
assert tomorrow(asof=datetime.date(2021, 2, 28)) == datetime.date(2021, 3, 1)

Pour supprimer la dépendance de la fonction sur datetime.date.today , nous fournissons la date d'aujourd'hui comme argument. Ce modèle consistant à fournir ou "injecter" des dépendances dans des fonctions et des objets est souvent appelé "injection de dépendances", ou en abrégé "DI".

Injection de dépendance dans la nature

L'injection de dépendances est un moyen de découpler les modules les uns des autres. Comme le montre notre exemple précédent, la fonction tomorrow ne dépend plus de today .

L'injection de dépendances est très courante et souvent très intuitive. Il est fort probable que vous l'utilisiez déjà sans même le savoir. Par exemple, cet article suggère que fournir un fichier ouvert à json.load est une forme d'injection de dépendance :

import json

with open('path/to/file.json', 'r') as f:
 data = json.load(f)

Le framework de test populaire pytest construit toute son infrastructure de montage autour du concept d'injection de dépendance :

import pytest

@pytest.fixture
def one() -> int:
 return 1

@pytest.fixture
def two() -> int:
 return 2

def test_one_is_less_than_two(one: int, two: int) -> None:
 assert one < two

Les fonctions one et two sont déclarés comme appareils. Lorsque pytest exécute la fonction de test test_one_is_less_than_two , il lui fournira les valeurs renvoyées par les fonctions de fixture correspondant aux noms d'attributs. Dans pytest, l'injection se produit comme par magie simplement en utilisant le nom d'un appareil connu comme argument.

L'injection de dépendances n'est pas limitée à Python. Le framework JavaScript populaire Angular est également construit autour de l'injection de dépendance :

@Component({
 selector: 'order-list',
 template: `...`
})
export class OrderListComponent {
 orders: Order[];

 constructor(orderService: OrderService) {
 this.orders = orderService.getOrders();
 }
}

Remarquez comment le orderService est fourni, ou injecté, au constructeur. Le composant utilise le service de commande, mais ne l'instancie pas.

Fonctions d'injection

Parfois, injecter une valeur ne suffit pas. Par exemple, que se passe-t-il si nous devons obtenir la date actuelle avant et après une opération :

from typing import Tuple
import datetime

def go() -> Tuple[datetime.datetime, datetime.datetime]:
 started_at = datetime.datetime.now()
 # Do something ...
 ended_at = datetime.datetime.now()
 return started_at, ended_at

Pour tester cette fonction, nous pouvons fournir l'heure de début comme nous l'avons fait auparavant, mais nous ne pouvons pas fournir l'heure de fin. Une façon d'aborder cela consiste à effectuer les appels pour démarrer et terminer en dehors de la fonction. C'est une solution valable, mais pour les besoins de la discussion, nous supposerons qu'ils doivent être appelés à l'intérieur.

Puisque nous ne pouvons pas nous moquer de datetime.datetime elle-même, une façon de rendre cette fonction testable est de créer une fonction distincte qui renvoie la date actuelle :

from typing import Tuple
import datetime

def now() -> datetime.datetime:
 return datetime.datetime.now()

def go() -> Tuple[datetime.datetime, datetime.datetime]:
 started_at = now()
 # Do something ...
 ended_at = now()
 return started_at, ended_at

Pour contrôler les valeurs renvoyées par la fonction now dans les tests, nous pouvons utiliser un mock :

>>> from unittest import mock
>>> fake_start = datetime.datetime(2020, 1, 1, 15, 0, 0)
>>> fake_end = datetime.datetime(2020, 1, 1, 15, 1, 30)
>>> with mock('__main__.now', side_effect=[fake_start, fake_end]):
... go()
(datetime.datetime(2020, 1, 1, 15, 0),
 datetime.datetime(2020, 1, 1, 15, 1, 30))

Une autre façon d'aborder cela sans se moquer est de réécrire la fonction une fois de plus :

from typing import Callable, Tuple
import datetime

def go(
 now: Callable[[], datetime.datetime],
) -> Tuple[datetime.datetime, datetime.datetime]:
 started_at = now()
 # Do something ...
 ended_at = now()
 return started_at, ended_at

Cette fois, nous fournissons la fonction avec une autre fonction qui renvoie une date/heure. Ceci est très similaire à la première solution que nous avons suggérée, lorsque nous avons injecté le datetime lui-même à la fonction.

La fonction peut maintenant être utilisée comme ceci :

>>> go(datetime.datetime.now)
(datetime.datetime(2020, 4, 18, 14, 14, 5, 687471),
 datetime.datetime(2020, 4, 18, 14, 14, 5, 687475))

Pour le tester, nous proposons une fonction différente qui renvoie des dates et heures connues :

>>> fake_start = datetime.datetime(2020, 1, 1, 15, 0, 0)
>>> fake_end = datetime.datetime(2020, 1, 1, 15, 1, 30)
>>> gen = iter([fake_start, fake_end])
>>> go(lambda: next(gen))
(datetime.datetime(2020, 1, 1, 15, 0),
 datetime.datetime(2020, 1, 1, 15, 1, 30))

Ce modèle peut être encore plus généralisé à l'aide d'un objet utilitaire :

from typing import Iterator
import datetime

def ticker(
 start: datetime.datetime,
 interval: datetime.timedelta,
) -> Iterator[datetime.datetime]:
 """Generate an unending stream of datetimes in fixed intervals.

 Useful to test processes which require datetime for each step.
 """
 current = start
 while True:
 yield current
 current += interval

Utilisation de ticker , le test ressemblera désormais à ceci :

>>> gen = ticker(datetime.datetime(2020, 1, 1, 15, 0, 0), datetime.timedelta(seconds=90))
>>> go(lambda: next(gen)))
(datetime.datetime(2020, 1, 1, 15, 0),
 datetime.datetime(2020, 1, 1, 15, 1, 30))

Fait amusant :le nom "ticker" a été volé à Go.

Injecter des valeurs

Les sections précédentes illustrent l'injection de valeurs et de fonctions. Il ressort clairement des exemples que l'injection de valeurs est beaucoup plus simple. C'est pourquoi il est généralement avantageux d'injecter des valeurs plutôt que des fonctions.

Une autre raison est la cohérence. Prenez ce modèle commun qui est souvent utilisé dans les modèles Django :

from django.db import models

class Order(models.Model):
 created = models.DateTimeField(auto_now_add=True)
 modified = models.DateTimeField(auto_now=True)

Le modèle Order comprend deux champs datetime, created et modified . Il utilise le auto_now_add de Django attribut pour définir automatiquement created lorsque l'objet est enregistré pour la première fois, et auto_now pour définir modified chaque fois que l'objet est enregistré.

Disons que nous créons une nouvelle commande et que nous l'enregistrons dans la base de données :

>>> o = Order.objects.create()

Vous attendriez-vous à ce que ce test échoue :

>>> assert o.created == o.modified
False

C'est très inattendu. Comment un objet qui vient d'être créé peut-il avoir deux valeurs différentes pour created et modified ? Pouvez-vous imaginer ce qui se passerait si vous comptiez sur modified et created être égal lorsqu'un objet n'a jamais été modifié, et l'utiliser pour identifier les objets inchangés :

from django.db.models import F

# Wrong!
def get_unchanged_objects():
 return Order.objects.filter(created=F('modified'))

Pour le Order modèle ci-dessus, cette fonction renverra toujours un ensemble de requêtes vide.

La raison de ce comportement inattendu est que chaque élément DateTimeField utilise django.timezone.now en interne pendant save() pour obtenir l'heure actuelle. Le temps entre le moment où les deux champs sont remplis par Django fait que les valeurs finissent par être légèrement différentes :

>>> o.created
datetime.datetime(2020, 4, 18, 11, 41, 35, 740909, tzinfo=<UTC>)

>>> o.modified
datetime.datetime(2020, 4, 18, 11, 41, 35, 741015, tzinfo=<UTC>)

Si nous traitons timezone.now comme une fonction injectée, nous comprenons les incohérences que cela peut engendrer.

Alors, cela peut-il être évité ? Peut created et modified être égal lors de la première création de l'objet ? Je suis sûr qu'il existe de nombreux hacks, bibliothèques et autres solutions exotiques, mais la vérité est beaucoup plus simple. Si vous voulez vous assurer que ces deux champs sont égaux lors de la première création de l'objet, vous feriez mieux d'éviter auto_now et auto_now_add :

from django.db import models

class Order(models.Model):
 created = models.DateTimeField()
 modified = models.DateTimeField()

Ensuite, lorsque vous créez une nouvelle instance, fournissez explicitement les valeurs des deux champs :

>>> from django.utils import timezone
>>> asof = timezone.now()
>>> o = Order.objects.create(created=asof, modified=asof)
>>> assert o.created == o.modified
>>> Order.objects.filter(created=F('modified'))
<QuerySet [<Order: Order object (2)>]>

Pour citer le "Zen de Python", explicite vaut mieux qu'implicite. Fournir explicitement les valeurs des champs nécessite un peu plus de travail, mais c'est un petit prix à payer pour des données fiables et prévisibles.

en utilisant auto_now et auto_now_add

Quand est-il acceptable d'utiliser auto_now et auto_now_add ? Habituellement, lorsqu'une date est utilisée à des fins d'audit et non pour la logique métier, il est possible de créer ce raccourci et d'utiliser auto_now ou auto_now_add .

Quand instancier les valeurs injectées

L'injection de valeurs pose une autre question intéressante, à quel moment la valeur doit-elle être définie ? La réponse à cette question est "cela dépend", mais il existe une règle empirique qui est généralement correcte :les valeurs doivent être instanciées au niveau le plus élevé .

Par exemple, si asof représente lorsqu'une commande est créée, un backend de site Web desservant une vitrine peut définir cette valeur lorsque la demande est reçue. Dans une configuration Django normale, cela signifie que la valeur doit être définie par la vue. Un autre exemple courant est une tâche planifiée. Si vous avez des tâches qui utilisent des commandes de gestion, asof doit être défini par la commande de gestion.

Définir les valeurs au niveau le plus élevé garantit que les niveaux inférieurs restent découplés et plus faciles à tester . Le niveau auquel les valeurs injectées sont définies est le niveau auquel vous aurez généralement besoin d'utiliser mock pour tester. Dans l'exemple ci-dessus, le réglage asof dans la vue facilitera le test des modèles.

Outre les tests et l'exactitude, un autre avantage de la définition explicite plutôt qu'implicite des valeurs est que cela vous donne plus de contrôle sur vos données. Par exemple, dans le scénario du site Web, la date de création d'une commande est définie par la vue dès la réception de la demande. Cependant, si vous traitez un fichier de commandes d'un gros client, l'heure à laquelle la commande a été créée peut très bien se situer dans le passé, lorsque le client a créé les fichiers pour la première fois. En évitant les dates générées "automatiquement" par magie, nous pouvons implémenter cela en passant la date passée comme argument.

Injection de dépendance en pratique

La meilleure façon de comprendre les avantages de l'ID et sa motivation est d'utiliser un exemple concret.

Recherche IP

Disons que nous voulons essayer de deviner d'où viennent les visiteurs de notre site Django, et nous décidons d'essayer d'utiliser l'adresse IP de la requête pour le faire. Une implémentation initiale peut ressembler à ceci :

from typing import Optional
from django.http import HttpRequest
import requests

def get_country_from_request(request: HttpRequest) -> Optional[str]:
 ip = request.META.get('REMOTE_ADDR', request.META.get('HTTP_X_FORWARDED_FOR'))
 if ip is None or ip == '':
 return None

 response = requests.get(f'https://ip-api.com/json/{ip}')
 if not response.ok:
 return None

 data = response.json()
 if data['status'] != 'success':
 return None

 return data['countryCode']

Cette fonction unique accepte un HttpRequest , essaie d'extraire une adresse IP des en-têtes de requête, puis utilise le requests bibliothèque d'appeler un service externe pour obtenir l'indicatif du pays.

recherche IP

J'utilise le service gratuit https://ip-api.com pour rechercher un pays à partir d'une adresse IP. J'utilise ce service uniquement à des fins de démonstration. Je ne le connais pas, alors ne voyez pas cela comme une recommandation de l'utiliser.

Essayons d'utiliser cette fonction :

>>> from django.test import RequestFactory
>>> rf = RequestFactory()
>>> request = rf.get('/', REMOTE_ADDR='216.58.210.46')
>>> get_country_from_request(request)
'US'

OK, donc ça marche. Notez que pour l'utiliser nous avons créé un HttpRequest objet utilisant le RequestFactory de Django

Essayons d'écrire un test pour un scénario lorsqu'un code pays est trouvé :

import re
import json
import responses

from django.test import RequestFactory

rf = RequestFactory()

with responses.RequestsMock() as rsps:
 url_pattern = re.compile(r'http://ip-api.com/json/[0-9\.]+')
 rsps.add(responses.GET, url_pattern, status=200, content_type='application/json', body=json.dumps({
 'status': 'success',
 'countryCode': 'US'
 }))
 request = rf.get('/', REMOTE_ADDR='216.58.210.46')
 countryCode = get_country_from_request(request)
 assert countryCode == 'US'

La fonction utilise le requests bibliothèque en interne pour faire une requête à l'API externe. Pour simuler la réponse, nous avons utilisé le responses bibliothèque.

Si vous regardez ce test et que vous pensez que c'est très compliqué, vous avez raison. Pour tester la fonction, nous avons dû procéder comme suit :

  • Générer une requête Django en utilisant un RequestFactory .
  • Moquer un requests réponse en utilisant responses .
  • Connaître le fonctionnement interne de la fonction (quelle URL elle utilise).

Ce dernier point est celui où ça devient poilu. Pour tester la fonction, nous avons utilisé nos connaissances sur la façon dont la fonction est implémentée :quel point de terminaison elle utilise, comment l'URL est structurée, quelle méthode elle utilise et à quoi ressemble la réponse. Cela crée une dépendance implicite entre le test et l'implémentation. En d'autres termes, l'implémentation de la fonction ne peut pas changer sans changer également le test . Ce type de dépendance malsaine est à la fois inattendu et nous empêche de traiter la fonction comme une "boîte noire".

Notez également que nous n'avons testé qu'un seul scénario. Si vous regardez la couverture de ce test, vous constaterez qu'elle est très faible. Ensuite, nous essayons de simplifier cette fonction.

Attribuer la responsabilité

L'une des techniques pour rendre les fonctions plus faciles à tester consiste à supprimer les dépendances. Notre fonction IP dépend actuellement du HttpRequest de Django , le requests bibliothèque et implicitement sur le service externe. Commençons par déplacer la partie de la fonction qui gère le service externe vers une fonction distincte :

def get_country_from_ip(ip: str) -> Optional[str]:
 response = requests.get(f'http://ip-api.com/json/{ip}')
 if not response.ok:
 return None

 data = response.json()
 if data['status'] != 'success':
 return None

 return data['countryCode']

def get_country_from_request(request: HttpRequest) -> Optional[str]:
 ip = request.META.get('REMOTE_ADDR', request.META.get('HTTP_X_FORWARDED_FOR'))
 if ip is None or ip == '':
 return None

 return get_country_from_ip(ip)

Nous avons maintenant deux fonctions :

  • get_country_from_ip :reçoit une adresse IP et renvoie le code du pays.
  • get_country_from_request :accepte un Django HttpRequest , extrayez l'adresse IP de l'en-tête, puis utilisez la première fonction pour trouver le code du pays.

Après avoir divisé la fonction, nous pouvons maintenant rechercher une adresse IP directement, sans créer de requête :

>>> get_country_from_ip('216.58.210.46')
'US'
>>> from django.test import RequestFactory
>>> request = RequestFactory().get('/', REMOTE_ADDR='216.58.210.46')
>>> get_country_from_request(request)
'US'

Maintenant, écrivons un test pour cette fonction :

import re
import json
import responses

with responses.RequestsMock() as rsps:
 url_pattern = re.compile(r'http://ip-api.com/json/[0-9\.]+')
 rsps.add(responses.GET, url_pattern, status=200, content_type='application/json', body=json.dumps({
 'status': 'success',
 'countryCode': 'US'
 }))
 country_code = get_country_from_ip('216.58.210.46')
 assert country_code == 'US'

Ce test ressemble au précédent, mais nous n'avons plus besoin d'utiliser RequestFactory . Parce que nous avons une fonction distincte qui récupère directement le code pays d'une adresse IP, nous n'avons pas besoin de "simuler" un Django HttpRequest .

Cela dit, nous voulons toujours nous assurer que la fonction de niveau supérieur fonctionne et que l'adresse IP est correctement extraite de la requête :

# BAD EXAMPLE!
import re
import json
import responses

from django.test import RequestFactory

rf = RequestFactory()
request_with_no_ip = rf.get('/')
country_code = get_country_from_request(request_with_no_ip)
assert country_code is None

Nous avons créé une requête sans IP et la fonction a renvoyé None . Avec ce résultat, pouvons-nous vraiment dire avec certitude que la fonction fonctionne comme prévu ? Pouvons-nous dire que la fonction a renvoyé None parce qu'il n'a pas pu extraire l'IP de la requête, ou parce que la recherche de pays n'a rien renvoyé ?

Quelqu'un m'a dit un jour que si pour décrire une fonction, vous avez besoin d'utiliser les mots "et" ou "ou", vous pouvez probablement bénéficier de sa division. Il s'agit de la version profane du principe de responsabilité unique qui dicte que chaque classe ou fonction ne devrait avoir qu'une seule raison de changer .

La fonction get_country_from_request extrait l'IP d'une requête et essaie de trouver le code de pays correspondant. Donc, si la règle est correcte, nous devons la scinder :

def get_ip_from_request(request: HttpRequest) -> Optional[str]:
 ip = request.META.get('REMOTE_ADDR', request.META.get('HTTP_X_FORWARDED_FOR'))
 if ip is None or ip == '':
 return None
 return ip


# Maintain backward compatibility
def get_country_from_request(request: HttpRequest) -> Optional[str]:
 ip = get_ip_from_request(request)
 if ip is None:
 return None
 return get_country_from_ip(ip)

Pour pouvoir tester si nous extrayons correctement une adresse IP d'une requête, nous avons transféré cette partie dans une fonction distincte. Nous pouvons maintenant tester cette fonction séparément :

rf = RequestFactory()
assert get_ip_from_request(rf.get('/')) is None
assert get_ip_from_request(rf.get('/', REMOTE_ADDR='0.0.0.0')) == '0.0.0.0'
assert get_ip_from_request(rf.get('/', HTTP_X_FORWARDED_FOR='0.0.0.0')) == '0.0.0.0'
assert get_ip_from_request(rf.get('/', REMOTE_ADDR='0.0.0.0', HTTP_X_FORWARDED_FOR='1.1.1.1')) =='0.0.0.0'

Avec seulement ces 5 lignes de code, nous avons couvert beaucoup plus de scénarios possibles.

Utiliser un service

Jusqu'à présent, nous avons implémenté des tests unitaires pour la fonction qui extrait l'adresse IP de la requête et rendu possible la recherche d'un pays en utilisant uniquement une adresse IP. Les tests pour la fonction de haut niveau sont encore très brouillons. Parce que nous utilisons requests à l'intérieur de la fonction, nous avons été obligés d'utiliser responses aussi pour le tester. Il n'y a rien de mal avec responses , mais moins il y a de dépendances, mieux c'est.

Invoquer une requête à l'intérieur de la fonction crée une dépendance implicite entre cette fonction et le requests bibliothèque. Une façon d'éliminer cette dépendance est d'extraire la partie faisant la demande à un service distinct :

import requests

class IpLookupService:

 def __init__(self, base_url: str) -> None:
 self.base_url = base_url

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 response = requests.get(f'{self.base_url}/json/{ip}')
 if not response.ok:
 return None

 data = response.json()
 if data['status'] != 'success':
 return None

 return data['countryCode']

Le nouveau IpLookupService est instancié avec l'url de base du service, et fournit une fonction unique pour obtenir un pays à partir d'une IP :

>>> ip_lookup_service = IpLookupService('http://ip-api.com')
>>> ip_lookup_service.get_country_from_ip('216.58.210.46')
'US'

Construire des services de cette manière présente de nombreux avantages :

  • Encapsuler toute la logique liée à la recherche d'adresses IP
  • Fournit une interface unique avec des annotations de type
  • Peut être réutilisé
  • Peut être testé séparément
  • Peut être développé séparément (tant que l'API qu'il fournit reste inchangée)
  • Peut être ajusté pour différents environnements (par exemple, utiliser une URL différente pour le test et la production)

La fonction de niveau supérieur devrait également changer. Au lieu de faire des requêtes tout seul, il utilise le service :

def get_country_from_request(
 request: HttpRequest,
 ip_lookup_service: IpLookupService,
) -> Optional[str]:
 ip = get_ip_from_request(request)
 if ip is None:
 return None
 return ip_lookup_service.get_country_from_ip(ip)

Pour utiliser la fonction, nous lui passons une instance du service :

>>> ip_lookup_service = IpLookupService('http://ip-api.com')
>>> request = RequestFactory().get('/', REMOTE_ADDR='216.58.210.46')
>>> get_country_from_request(request, ip_lookup_service)
'US'

Maintenant que nous avons le contrôle total du service, nous pouvons tester la fonction de niveau supérieur sans utiliser responses :

from unittest import mock
from django.test import RequestFactory

fake_ip_lookup_service = mock.create_autospec(IpLookupService)
fake_ip_lookup_service.get_country_from_ip.return_value = 'US'

request = RequestFactory().get('/', REMOTE_ADDR='216.58.210.46')

country_code = get_country_from_request(request, fake_ip_lookup_service)
assert country_code == 'US'

Pour tester la fonction sans faire de requêtes http, nous avons créé une maquette du service. Nous définissons ensuite la valeur de retour de get_country_from_ip , et a transmis le service fictif à la fonction.

Modifier les implémentations

Un autre avantage de DI souvent mentionné est la possibilité de modifier complètement l'implémentation sous-jacente d'un service injecté. Par exemple, un jour, vous découvrez que vous n'avez pas besoin d'utiliser un service distant pour rechercher une adresse IP. Au lieu de cela, vous pouvez utiliser une base de données IP locale.

Parce que notre IpLookupService ne fuit pas son implémentation interne, c'est un changement facile :

from typing import Optional
import GeoIP

class LocalIpLookupService:
 def __init__(self, path_to_db_file: str) -> None:
 self.db = GeoIP.open(path_to_db_file, GeoIP.GEOIP_STANDARD)

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 return self.db.country_code_by_addr(ip)

L'API du service est restée inchangée, vous pouvez donc l'utiliser de la même manière que l'ancien service :

>>> ip_lookup_service = LocalIpLookupService('/usr/share/GeoIP/GeoIP.dat')
>>> ip_lookup_service.get_country_from_ip('216.58.210.46')
'US'
>>> from django.test import RequestFactory
>>> request = RequestFactory().get('/', REMOTE_ADDR='216.58.210.46')
>>> get_country_from_request(request, ip_lookup_service)
'US'

La meilleure partie ici est que les tests ne sont pas affectés. Tous les tests doivent réussir sans apporter de modifications.

GéoIP

Dans l'exemple, j'utilise l'API MaxMind GeoIP Legacy Python Extension car elle utilise des fichiers que j'ai déjà dans mon système d'exploitation dans le cadre de geoiplookup . Si vous avez vraiment besoin de rechercher des adresses IP, consultez GeoIP2 et assurez-vous de vérifier la licence et les restrictions d'utilisation.

De plus, les utilisateurs de Django pourraient être ravis de savoir que Django fournit un wrapper autour de geoip2 .

Services de dactylographie

Dans la dernière section, nous avons un peu triché. Nous avons injecté le nouveau service LocalIpLookupService dans une fonction qui attend une instance de IpLookupService . Nous nous sommes assurés que ces deux éléments sont identiques, mais les annotations de type sont désormais erronées. Nous avons également utilisé un mock pour tester la fonction qui n'est pas non plus de type IpLookupService . Alors, comment pouvons-nous utiliser les annotations de type tout en pouvant injecter différents services ?

from abc import ABCMeta
import GeoIP
import requests

class IpLookupService(metaclass=ABCMeta):
 def get_country_from_ip(self, ip: str) -> Optional[str]:
 raise NotImplementedError()


class RemoteIpLookupService(IpLookupService):
 def __init__(self, base_url: str) -> None:
 self.base_url = base_url

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 response = requests.get(f'{self.base_url}/json/{ip}')
 if not response.ok:
 return None

 data = response.json()
 if data['status'] != 'success':
 return None

 return data['countryCode']


class LocalIpLookupService(IpLookupService):
 def __init__(self, path_to_db_file: str) -> None:
 self.db = GeoIP.open(path_to_db_file, GeoIP.GEOIP_STANDARD)

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 return self.db.country_code_by_addr(ip)

Nous avons défini une classe de base appelée IpLookupService qui fait office d'interface. La classe de base définit l'API publique pour les utilisateurs de IpLookupService . En utilisant la classe de base, nous pouvons fournir deux implémentations :

  1. RemoteIpLookupService :utilise le requests bibliothèque pour rechercher l'IP à un externe.
  2. LocalIpLookupService :utilise la base de données GeoIP locale.

Désormais, toute fonction nécessitant une instance de IpLookupService peut utiliser ce type, et la fonction pourra accepter n'importe quelle sous-classe de celui-ci.

Avant de conclure, nous devons encore gérer les tests. Auparavant, nous supprimions la dépendance du test sur responses , maintenant nous pouvons abandonner mock aussi bien. Au lieu de cela, nous sous-classons IpLookupService avec une implémentation simple pour les tests :

from typing import Iterable

class FakeIpLookupService(IpLookupService):
 def __init__(self, results: Iterable[Optional[str]]):
 self.results = iter(results)

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 return next(self.results)

Le FakeIpLookupService implémente IpLookupService , et produit des résultats à partir d'une liste de résultats prédéfinis que nous lui fournissons :

from django.test import RequestFactory

fake_ip_lookup_service = FakeIpLookupService(results=['US'])
request = RequestFactory().get('/', REMOTE_ADDR='216.58.210.46')

country_code = get_country_from_request(request, fake_ip_lookup_service)
assert country_code == 'US'

Le test n'utilise plus mock .

Utiliser un protocole

La forme de hiérarchie de classes démontrée dans la section précédente est appelée "sous-typage nominal". Il existe une autre façon d'utiliser le typage sans classes, en utilisant Protocols :

from typing import Iterable, Optional
from typing_extensions import Protocol
import GeoIP
import requests


class IpLookupService(Protocol):
 def get_country_from_ip(self, ip: str) -> Optional[str]:
 pass


class RemoteIpLookupService:
 def __init__(self, base_url: str) -> None:
 self.base_url = base_url

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 response = requests.get(f'{self.base_url}/json/{ip}')
 if not response.ok:
 return None

 data = response.json()
 if data['status'] != 'success':
 return None

 return data['countryCode']


class LocalIpLookupService:
 def __init__(self, path_to_db_file: str) -> None:
 self.db = GeoIP.open(path_to_db_file, GeoIP.GEOIP_STANDARD)

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 return self.db.country_code_by_addr(ip)


class FakeIpLookupService:
 def __init__(self, results: Iterable[Optional[str]]):
 self.results = iter(results)

 def get_country_from_ip(self, ip: str) -> Optional[str]:
 yield from self.results

Le passage des classes aux protocoles est doux. Au lieu de créer IpLookupService comme classe de base, nous la déclarons Protocol . Un protocole est utilisé pour définir une interface et ne peut pas être instancié. Au lieu de cela, un protocole est utilisé uniquement à des fins de typage. Lorsqu'une classe implémente l'interface définie par le protocole, cela signifie que le "Sous-typage structurel" se termine et la vérification de type sera validée.

Dans notre cas, nous utilisons un protocole pour nous assurer qu'un argument de type IpLookupService implémente les fonctions que nous attendons d'un service IP.

sous-typage structurel et nominal

J'ai écrit sur les protocoles, le sous-typage structurel et nominal dans le passé. Découvrez la modélisation du polymorphisme dans Django avec Python.

Alors, lequel utiliser ? Certains langages, comme Java, utilisent exclusivement le typage nominal, tandis que d'autres langages, comme Go, utilisent le typage structurel pour les interfaces. Il y a des avantages et des inconvénients dans les deux sens, mais nous n'aborderons pas cela ici. En Python, le typage nominal est plus facile à utiliser et à comprendre, donc ma recommandation est de s'y tenir, à moins que vous n'ayez besoin de la flexibilité offerte par les protocoles.

Non-déterminisme et effets secondaires

Si vous avez déjà eu un test qui, un jour, a commencé à échouer, sans provocation, ou un test qui échoue une fois par lune bleue sans raison apparente, il est possible que votre code repose sur quelque chose qui n'est pas déterministe. Dans le datetime.date.today exemple, le résultat de datetime.date.today repose sur l'heure actuelle qui change constamment, donc ce n'est pas déterministe.

Il existe de nombreuses sources de non-déterminisme. Les exemples courants incluent :

  • Le hasard
  • Accès au réseau
  • Accès au système de fichiers
  • Accès à la base de données
  • Variables d'environnement
  • Variables globales mutables

L'injection de dépendance fournit un bon moyen de contrôler le non-déterminisme dans les tests. La recette de base est la suivante :

  1. Identifier la source du non-déterminisme et l'encapsuler dans un service  :Par exemple, TimeService, RandomnessService, HttpService, FilesystemService et DatabaseService.
  2. Utiliser l'injection de dépendances pour accéder à ces services :Ne les contournez jamais en utilisant directement datetime.now() et similaire.
  3. Fournir des implémentations déterministes de ces services dans les tests  :Utilisez plutôt une simulation ou une implémentation personnalisée adaptée aux tests.

Si vous suivez la recette avec diligence, vos tests ne seront pas affectés par des circonstances extérieures et vous n'aurez pas de tests feuilletés !

Conclusion

L'injection de dépendances est un modèle de conception comme un autre. Les développeurs peuvent décider dans quelle mesure ils souhaitent en tirer parti. Les principaux avantages de DI sont :

  • Découplez les modules, les fonctions et les objets.
  • Changer d'implémentation ou prendre en charge plusieurs implémentations différentes
  • Éliminer le non-déterminisme des tests.

Dans le cas d'utilisation ci-dessus, nous avons pris plusieurs rebondissements pour illustrer un point, ce qui aurait pu rendre l'implémentation plus compliquée qu'elle ne l'est réellement. En plus de cela, la recherche d'informations sur l'injection de dépendances dans Python se traduit souvent par des bibliothèques et des packages qui semblent complètement changer la façon dont vous structurez votre application. Cela peut être très intimidant.

En réalité, DI peut être utilisé avec parcimonie et dans des endroits appropriés pour obtenir les avantages énumérés ci-dessus. Lorsqu'elle est correctement implémentée, DI peut rendre votre code plus facile à maintenir et à tester.