Makina Blog

Le blog Makina-corpus

Django Rest Framework : les tests (partie 8)


Avec les API REST, développer très rapidement des tests fonctionnels complets qui frôlent les 100% de couverture, c'est possible. Et DRF nous propose une pincée de surcharges aux outils de tests du core de Django pour y parvenir. Cependant, son usage est tellement intuitif que certains points peuvent nous échapper dans la précipitation.

Écrire des tests avec DRF, c'est un vrai plaisir. Fini les tests partiels de gabarits. Fini la corvée des tests sur des UI qui changent tous les quatre matins. Fini les tests qui tournent pendant quatre heures avec diverses implémentations de la WebDriver API (Selenium entre autre). Avec des points d'entrées dépourvus d'UI, on oublie les paillettes et on se concentre uniquement sur le métier.

Tout ceci est encore plus simple grâce aux surcharges de DRF. La documentation officielle est particulièrement complète. Vous devez donc la lire attentivement. Je suis passé à côté de certains détails importants dont j'aimerai vous faire part.

Surcharge du core de Django

On pourrait croire que la surcharge est importante et pourtant, c'est peanut. Vraiment. Voyez par vous-même le fichier test.py.

Si on regarde de plus près, on s'aperçoit que toutes les classes helper de tests classiques du core de Django sont surchargées :

  • APITestCase
  • APITransactionTestCase
  • APISimpleTestCase
  • APILiveServerTestCase

Toutes ces classes sont des surcharges dans un seul et unique but : remplacer le client HTTP. C'est tout. À la place, elles utilisent le client HTTP APIClient. Ce nouveau client étend le client standard (et donc indirectement RequestFactory) avec sa propre request factory APIRequestFactory, ainsi que son propre client handler ForceAuthClientHandler.

"Euuuh ok, mais concrètement, ça nous apporte quoi en plus ?". En fait, toutes ces surcharges se résument principalement à :

  • offrir la possibilité de spécifier le format d'une requête HTTP. Ce format permet d'envoyer automatiquement le bon entête HTTP Content-Type et d'encoder les données envoyées. Typiquement, vous ne codez plus : .. code-block:: python self.client.post( reverse('bar-list'), data='<?xml version="1.0" encoding="utf-8"?><root><name>baz</name></root>',
    content_type='application/xml', ) mais plutôt :
    .. code-block:: python self.client.post( reverse('bar-list'), data={"name": "baz"}, format='xml' )
  • encoder les données envoyées même si la méthode n'est pas du POST. Les méthodes HTTP supportées sont : PUT, PATCH, DELETE, OPTIONS. Bah ouais, ça serait quand même bête de ne pas en profiter avec ces méthodes… On peut donc aussi faire : .. code-block:: python self.client.put( reverse('bar-detail', kwargs={'pk': 1}), data={"name": "baz"}, format='xml', )
  • forcer l'authentification d'un utilisateur avec la méthode force_authenticate. C'est aussi simple que : .. code-block:: python from django.contrib.auth import get_user_model user = get_user_model().objects.first() self.client.force_authenticate(user) # Every request are now logged with 'user' instance Note : depuis Django 1.9, on peut enfin le faire aussi avec le client standard et sa méthode force_login. Halleluja !

Renderer et format par défaut

Le premier piège consiste à passer à côté de cette note dans la documentation officielle :

By default the available formats are 'multipart' and 'json'.

Autrement dit par défaut, les nostalgiques du XML ne vont implicitement pas tester le fameux parser XML. Seul les parsers pour le multipart et le JSON sont supportés. Pour y remédier, vous devez modifier ce setting avec l'équivalent de vos parsers déclarés dans DEFAULT_PARSER_CLASSES :

REST_FRAMEWORK['TEST_REQUEST_RENDERER_CLASSES'] = (
    'rest_framework.renderers.JSONRenderer',  # to encode data for parser JSONParser
    'rest_framework_xml.renderers.XMLRenderer',  # to encode data for parser XMLParser
    'rest_framework.renderers.MultiPartRenderer',  # to encode data for parser MultiPartParser
)

Pour ceux que le terme TEST_REQUEST_RENDERER_CLASSES perturbent, sachez que ce sont les renderers qui sont utilisés pour encoder les données que vous allez envoyer à votre application qui utilisera ensuite ses parsers pour interprêter les données.

Plus grave, le second piège est de louper la phrase juste après :

For compatibility with Django's existing RequestFactory the default format is 'multipart'.

Eh oui. Vous croyez que vous testez votre parser JSON par défaut ? Eh bien non ! Et dans la vraie vie, je doute sérieusement que vos clients effectuent des requêtes HTTP du style :

curl -v -X POST -H 'Content-Type: multipart/form-data; boundary=BoUnDaRyStRiNg; charset=utf-8' -F "name=titi" "http://127.0.0.1:8000/api/bar/"

Non en effet, il est plus probable que ce soit un petit malin qui tente de vous faire du mal.

Vous devriez donc toujours spécifier le format de test par défaut de votre choix avec le setting :

REST_FRAMEWORK['TEST_REQUEST_DEFAULT_FORMAT'] = 'json'

Désormais, si vous ne précisez pas le format dans vos requêtes de tests (ce qui arrive dans 95% des cas, disons-le), vous testerez le parser JSON et non le multipart.

Pour les 5% restant, vous devrez explicitement spécifier le format. C'est le cas pour les uploads de fichiers par exemple. Donc attention, pensez à toujours spécifier format='multipart':

with open(filepath) as fp:
    response = self.client.patch(
        url,
        data={'doc': fp},
        format='multipart',  # DON'T FORGET ME
    )

Bon, je sais, je sais. Pour ces deux pièges, vous me direz sûrement : "Peu importe, si on considère les parser/renderer comme acquis, effectuer les tests unitaires en JSON ou en multipart importe peu". Vous n'avez pas complètement tort mais quand même… par principe c'est pas top. Si pour peu d'efforts, nos tests peuvent être le plus proche de la réalité, alors pourquoi s'en priver ?

Tester les entêtes HTTP

C'est pas vraiment un truc qu'on fait souvent avec des tests Web. Par contre avec les API REST, c'est très fréquent. Et on peut vite s'embrouiller. Je vais donc vous prémâcher une sorte de mémento.

Vous pouvez envoyer l'entête HTTP Content-Type implicitement avec le paramètre format :

self.client.patch(
    self.url,
    data,
    format='xml',  # implicit Content-Type header
)

ou de façon explicite avec le paramètre content_type. Attention, les données envoyées ne seront pas encodées pour vous :

self.client.patch(
    self.url,
    data,  # warning: raw data not encoded
    content_type='application/xml',  # explicit Content-Type header
)

En clair, vous ne le ferez pas souvent. Jamais en fait.

Et pour vérifier que votre entête HTTP a bien été envoyé, vous pouvez accéder à la requête générée par le client HTTP avec response.request :

self.assertEqual(
    response.request['CONTENT_TYPE'],  # *request* generated by the HTTP client
    'application/xml',
)

En effet, la requête construite par le client HTTP est stockée dans la réponse. Elle est en réalité un dictionnaire utilisé pour construire l'environnement de la requête WSGI finale générée qui est elle aussi stockée dans la réponse. Vous pouvez donc également l'utiliser pour faire vos vérifications :

self.assertEqual(
    response.wsgi_request.META.get('CONTENT_TYPE'),  # final WSGI request generated
    'application/xml',
)

Mais bon, c'est un peu long à taper hein ;-)

Au contraire, si vous souhaitez spécifier le format de réponse attendue (combien même ça serait utile à tester), vous pouvez faire par exemple :

self.client.patch(
    self.url,
    HTTP_ACCEPT='application/xml',  #  *Accept* HTTP header
)

En fait, tous les paramètres additionnels sont renvoyés vers le constructeur de l'objet de requête WSGI en tant que variables d'environnement, normalement transmises par le serveur Web.

Et ensuite, pour vérifier que le serveur répond correctement :

self.assertEqual(
    response['content-type'],  # You can access header directly, just like email.Message object
    'application/xml; charset=utf-8'
)

Un peu à l'image de l'objet Message de la librairie email de Python, vous pouvez accéder directement aux entêtes de la réponse.

Vous pouvez voir ces exemples en action en allant sur la branche tests du dépôt GIT.

Tester les contenus des réponses

Un détail à ne pas oublier : les réponses renvoyées par votre REST API ne sont pas des objets HttpResponse mais bien des objets enfants Response de DRF. Ces derniers ont un raccourci fort utile pour accéder aux données dont il faut user et abuser : response.data.

Donc par pitié, ne faites pas :

self.assertIn('"name":"foo"', response.content)

Mais plutôt :

self.assertEqual(response.data['name'], 'foo')

Tester les transactions

Parce que vous êtes un développeur consciencieux, vous testez tout. Y compris les transactions foireuses. Surtout les transactions foireuses. En vérité, ça mériterait presque un article dédié. Je ne le fais pas dans l'immédiat mais gardez à l'esprit que tout ceci s'applique aussi bien aux tests Django de manière générale.

On suppose que vous connaissez un minimum les transactions SQL. On suppose aussi que vous avez déjà joué avec leurs intégrations dans Django. Peut-être même que vous avez lu un de nos articles sur le sujet.

L'ORM de Django est géniale. Mais comme tout ORM, ces surcouches d'abstraction nous font oublier la réalité. Voire pire, vous ne la connaissez pas. Et les transactions SQL n'échappent pas à la règle. Par exemple avec la méthode save() d'un modèle, une transaction SQL est lancée pour assurer l'atomicité entre la table de base et d'hypothétiques tables parentes de votre modèle.

Si une erreur SQL arrive, une exception commune aux différents SGBD peut être soulevée par les wrappers de l'ORM. Par exemple, si on a un doublon, on va se manger l'exception IntegrityError. Admettons que l'on souhaite effectuer une autre opération d'écriture si cette erreur arrive :

from django.db import IntegrityError

try:
    instance.save()
except IntegrityError:
    instance.state = FAIL
    instance.save()  # I'm locked uuuup, they won't let me out, nooo, they won't let me out

Que se passe-t-il à votre avis ?

An error occurred in the current transaction. You can't execute queries until the end of the 'atomic' block.

Plutôt clair comme message d'erreur. Comme l'ORM a crée une transaction qui est bloquée suite à l'erreur d'intégrité, vous avez deux choix possibles pour continuer à écrire :

  • faire un COMMIT (écrire quand même)
  • faire un ROLLBACK (tout annuler)

Dieu merci, l'ORM ne fait pas ce choix pour vous. Le plus souvent, c'est un rollback que vous voulez faire :

from django.db import IntegrityError, transaction

try:
    instance.save()
except IntegrityError:
    transaction.rollback()  # Don't worry about a thing
    instance.state = FAIL
    instance.save()  # Cause every little thing gonna be alright

Ça va beaucoup mieux désormais. Là, on se dit ok, pour blinder le tout, on va écrire un test pour s'assurer qu'en cas d'échec (de doublon), on aura bien un statut en fail. Alors c'est parti, on va écrire notre test avec la classe APITestCase.

Pour cela, on va reprendre un exemple concret pas beaucoup plus différent. Clonez le dépôt GIT et positionnez-vous sur la branche tests. On va plus précisément tester cette vue :

from django.db import IntegrityError, transaction

from rest_framework.decorators import detail_route
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet

from .models import Bar
from .serializers import BarSerializer


class BarViewSet(ModelViewSet):
    queryset = Bar.objects.all()
    serializer_class = BarSerializer
    permission_classes = (IsAuthenticated,)

    @detail_route(methods=['patch'], permission_classes=(AllowAny,))
    def name(self, request, *args, **kwargs):
        """
        Stupid endpoint which blindly try to update a unique field. This is
        just for purpose : raising an IntegrityError. DB transaction is still
        active and need a rollback/commit command if we want to be able to
        continue to write some stuff.
        """
        instance = self.get_object()
        instance.name = request.data.get('name')  # no check, hack me if you want

        try:
            instance.save()  # Write query fail
        except IntegrityError:
            transaction.rollback()  # Finish transaction to continu
            instance.name = None
            instance.save()  # Because we rollback, we can now write new changes
            return Response("I will survive !")

        return Response(instance.name)

Bon, la seule différence, c'est qu'on force la valeur None à l'attribut name si on a un doublon. Me demandez pas pourquoi, j'avais pas de meilleure idée sur le moment.

Puis on écrit un nouveau test :

class BarNameViewTestCase(APITestCase):

    def test_rollback(self):
        Bar.objects.create(name='sethguekobar')

        bar = Bar.objects.create(name='changeme')
        url = reverse('bar-name', kwargs={'pk': bar.pk})

        self.client.patch(url, {'name': 'sethguekobar'})  # Qu'est dupliqué, dupliqué, dupliqué, dupliquééééé, mes chaussures brillent ...

Si vous exécutez le test :

./manage.py test 

Vous devriez avoir la joie de vous prendre une belle exception :

TransactionManagementError: This is forbidden when an 'atomic' block is active.

Arrrggg. Deux possibilités : vous êtes lâche, faible et abandonnez votre test ("Pfff, j'ai pas le temps."). Honte à vous. Sinon, vous persévérez rien qu'un peu car vous êtes un(e) battant(e) et que si peu ne vous fait pas peur. Alors voyons… le message est pourtant très clair. Mais d'où vient cette transaction ?

Elle se cache ici ! Rappelez-vous, on parlait des classes APITestCase et APITransactionTestCase. Chacunes héritent respectivement de TestCase et de TransactionTestCase. La principale différence entre ces deux classes, c'est que la classe TestCase exécute toutes les méthodes de votre classe de test dans une transaction SQL. Pourquoi ? Pour accélérer considérablement vos tests car il n'y a pas d'écriture définitive. Le SGBD attends votre confirmation via la commande COMMIT pour sortir le marteau-piqueur et écrire dans le marbre. À la fin de vos tests, cette classe de test fait un rollback pour annuler toutes ces écritures.

C'est la raison pour laquelle il faut utiliser le plus possible ces classes TestCase dérivées (comme APITestCase). C'est aussi la raison pour laquelle vous ne pouvez pas débugger les données dans la base de test… "Bah c'est ouf quoi, y'a rien dans la BDD ? Me dit pas que tout est en RAM quand même ?". Bah si… enfin presque :-).

Attention, le nom de la classe TransactionTestCase n'indique pas que vos tests sont exécutés dans une transaction SQL mais au contraire, que vous pouvez tester les transactions SQL avec. Je sais, c'est un peu perturbant…

Donc la solution, c'est simplement de remplacer la classe parente APITestCase par APITransactionTestCase :

class BarNameViewTestCase(APITransactionTestCase):  # Oooh wee

Et là au grand miracle, tout fonctionne enfin. Votre code est plus robuste que jamais et vous vous rappelez un peu mieux comment les transactions SQL fonctionnent.

Améliorer ces tests

Comme on est déjà pas mal sorti du cadre DRF, on ne va pas s'arrêter en si bon chemin et on fini avec un peu de morale :

  • découper les tests avec des noms de méthodes lisibles et compréhensibles. En lisant le nom de la méthode, on doit savoir précisément ce qu'elle va faire. Et ceci vous oblige à isoler vos tests au maximum. Une seule règle : keep it stupid. J'ai longtemps fait cette erreur en pensant qu'il était plus intéressant de faire des tests complets de la vraie vie. Belle erreur. Pourtant, ça partait d'une bonne idée… Bien sûr que vos clients ne font pas des tests isolés. Mais si chaque partie isolée remplit son rôle, il ne reste plus que la glu entre ces parties à tester. C'est ce qu'on appelle des tests d'intégrations. Ils viennent en complément des tests unitaires/fonctionnels et en aucun cas ne les remplacent. Se dire : "Je fais direct des tests d'intégrations sans test unitaire/fonctionnel car c'est une perte de temps" est une erreur. Les tests d'intégration, c'est la cerise sur le gâteau. Et ce pour une raison simple: les tests d'intégrations sont très durs à maintenir. Pour illustrer ces propos et mes propres erreurs, regardez les tests de ce projet en v1. Regarder ces mêmes tests réécrit pour la v2. Sérieux, c'est pas plus lisible ?
  • utilisez au maximum la méthode de classe setUpTestData au profit de la méthode d'instance setUp. Le gain en performance peut être très significatif. Et des tests qui tournent vite, c'est des tests qui ont plus de chance d'être maintenus. Attention, cela vous oblige à penser autrement l'écriture de vos tests. Pour en savoir plus, vous pouvez lire un de nos articles sur l'optimisation des tests.
  • c'est tout ou rien. Il n'y a rien de pire que de donner l'illusion que certaines parties sont testées… Donc par pitié, ne faites pas des tests de 3 lignes qui ne vérifient rien, voire juste l'évidence. Très naïvement, on peut ainsi obtenir un taux de couverture à 100% qui ne veut rien dire. Vos collègues seront heureux de découvrir plus tard des bugs sur des parties soit-disant testées… Mieux vaut s'abstenir, quitte à revenir plus tard dessus. Au moins, le message est clair.
  • depuis Django 1.9, il est possible de parallèliser les tests. Pensez donc à isoler vos tests pour rendre cette parallèlisation possible. Croyez-moi, le gain en performances est au rendez-vous. C'est simple, le temps d'exécution de vos tests est proportionnellement divisé par le nombre de processus. Le calcul est vite vu…
  • enfin des tests, c'est du code. Pas juste du pâté balancé au kilomètre. Mais des tests, c'est aussi du pâté… Vous devez trouver le juste milieu. N'ayez pas peur de faire du copier-coller. N'ayez pas peur de faire beaucoup d'écritures SQL. N'ayez pas peur de faire du code hardcodé. Au contraire, des tests c'est simple (isolé) et pragmatique. Bête et méchant. Pas de truc dynamique, pas de variables à deux lettres, pas de boucle d'assert qui vous font galérer pour retrouver la ligne exacte de l'erreur, etc. Des tests, c'est du code à maintenir dans le temps, presque aussi important que le code exécuté en production. Ils décrivent les comportements et les erreurs attendus. D'ailleurs, c'est la raison pour laquelle certains framework de test utilisent le terme de spécification (spec). Enfin, les tests témoignent de l'importance et de la qualité accordée à votre application.

Conclusion

Faire des tests avec DRF, c'est simple, rapide et intuitif. Vous avez à disposition des outils qui vous facilitent la vie. De surcroît, tester une vue, ça revient presque à tester une fonction : on ne parle plus vraiment de test d'intégration mais plutôt de test fonctionnel. Pas besoin de mock tordus qui vous obligent à connaître le framework par coeur. Pas besoin non plus de coder votre application en fonction de vos tests. Bref, que du bonheur.

Alors pas d'excuse, je ne veux plus voir de projet DRF en dessous des 90% de couverture en branche !

Formations associées

Formations Django

Formation Django Rest Framework

À distance (FOAD) Du 10 au 14 juin 2024

Voir la formation

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Actualités en lien

Image
Django_rest_framework_logo
04/02/2016

Django Rest Framework : versioning avec DRF (partie 7)

DRF remplit vaillamment son devoir et propose une intégration poussée du versioning d'API. À tel point qu'on est vite perdu dans la multitude de méthodes possibles.

Voir l'article
14/01/2016

Django Rest Framework : négociation de contenu (partie 6)

Après s'être remis de nos émotions avec les routeurs, on va s'accorder une petite trêve avec un thème qui ne déchaîne pas les passions : la négociation de contenu. Pourtant, c'est un passage obligatoire car c'est un élément essentiel aux API REST pour communiquer avec les clients. Allez, courage !

Voir l'article
Image
Django_Rest_Framework_routeurs
06/01/2016

Django Rest Framework : personnalisation des routeurs (partie 5)

Dernière ligne droite pour les routeurs : après avoir analysé le fonctionnement et l'intégration, c'est au tour de la personnalisation.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus