Les tests peuvent être une déception à écrire, mais même un plus gros cauchemar à maintenir. Lorsque nous avons remarqué que nous reportions des tâches simples simplement parce que nous avions peur de mettre à jour un cas de test monstrueux, nous avons commencé à chercher des moyens plus créatifs de simplifier le processus d'écriture et de maintenance des tests.
Dans cet article, je vais décrire une approche basée sur les classes pour écrire des tests.
Avant de commencer à écrire du code, fixons-nous quelques objectifs :
- Vaste - Nous voulons que nos tests couvrent autant de scénarios que possible. Nous espérons qu'une plate-forme solide pour la rédaction de tests nous permettra de nous adapter plus facilement aux changements et de couvrir davantage de domaines.
- Expressif - Les bons tests racontent une histoire. Les problèmes deviennent inutiles et les documents se perdent, mais les tests doivent toujours réussir - c'est pourquoi nous traitons nos tests comme des spécifications . Rédiger de bons tests peut aider les nouveaux arrivants (et les futurs eux-mêmes) à comprendre tous les cas extrêmes et les micro-décisions prises au cours du développement.
- Maintenable - À mesure que les exigences et les mises en œuvre changent, nous souhaitons nous adapter rapidement avec le moins d'effort possible.
Saisir des tests basés sur les classes
Les articles et tutoriels sur les tests donnent toujours des exemples simples tels que add
et sub
. J'ai rarement le plaisir de tester des fonctions aussi simples. Je vais prendre un exemple plus réaliste et tester un point de terminaison d'API qui se connecte :
POST /api/account/login { username: <str>, password: <str> }
Les scénarios que nous souhaitons tester sont :
- L'utilisateur s'est connecté avec succès.
- L'utilisateur n'existe pas.
- Mot de passe incorrect.
- Données manquantes ou incorrectes.
- Utilisateur déjà authentifié.
L'entrée de notre test est :
- Une charge utile,
username
etpassword
. - Le client effectuant l'action, anonyme ou authentifié.
La sortie que nous voulons tester est :
- La valeur de retour, l'erreur ou la charge utile.
- Le code d'état de la réponse.
- Effets secondaires. Par exemple, la date de la dernière connexion après une connexion réussie.
Après avoir correctement défini l'entrée et la sortie, nous pouvons écrire une classe de test de base :
from unittest import TestCase import requests class TestLogin: """Base class for testing login endpoint.""" @property def client(self): return requests.Session() @property def username(self): raise NotImplementedError() @property def password(self): raise NotImplementedError() @property def payload(self): return { 'username': self.username, 'password': self.password, } expected_status_code = 200 expected_return_payload = {} def setUp(self): self.response = self.client.post('/api/account/login', json=payload) def test_should_return_expected_status_code(self): self.assertEqual(self.response.status, self.expected_status_code) def test_should_return_expected_payload(self): self.assertEqual(self.response.json(), self.expected_return_payload)
- Nous avons défini l'entrée,
client
etpayload
, et la sortie attendueexpected_*
. - Nous avons effectué l'action de connexion lors du test
setUp
. Pour permettre à des cas de test spécifiques d'accéder au résultat, nous avons conservé la réponse sur l'instance de classe. - Nous avons implémenté deux scénarios de test courants :
- Testez le code d'état attendu.
- Testez la valeur de retour attendue.
Le lecteur attentif remarquera peut-être que nous levons un NotImplementedError
exception des propriétés. De cette façon, si l'auteur du test oublie de définir l'une des valeurs requises pour le test, il obtient une exception utile.
Utilisons notre TestLogin
class pour écrire un test de connexion réussie :
class TestSuccessfulLogin(TestLogin, TestCase): username = 'Haki', password = 'correct-password' expected_status_code = 200 expected_return_payload = { 'id': 1, 'username': 'Haki', 'full_name': 'Haki Benita', } def test_should_update_last_login_date_in_user_model(self): user = User.objects.get(self.response.data['id']) self.assertIsNotNone(user.last_login_date)
En lisant simplement le code, nous pouvons dire qu'un username
et password
sont envoyés. Nous attendons une réponse avec un code d'état 200 et des données supplémentaires sur l'utilisateur. Nous avons étendu le test pour vérifier également le last_login_date
dans notre modèle utilisateur. Ce test spécifique peut ne pas être pertinent pour tous les cas de test, nous ne l'ajoutons donc qu'au cas de test réussi.
Testons un scénario d'échec de connexion :
class TestInvalidPassword(TestLogin, TestCase): username = 'Haki' password = 'wrong-password' expected_status_code = 401 class TestMissingPassword(TestLogin, TestCase): payload = {'username': 'Haki'} expected_status_code = 400 class TestMalformedData(TestLogin, TestCase): payload = {'username': [1, 2, 3]} expected_status_code = 400
Un développeur qui tombe sur ce morceau de code sera en mesure de dire exactement ce qui devrait se passer pour n'importe quel type d'entrée. Le nom de la classe décrit le scénario et les noms des attributs décrivent l'entrée. Ensemble, la classe raconte une histoire facile à lire et à comprendre .
Les deux derniers tests définissent directement la charge utile (sans définir de nom d'utilisateur ni de mot de passe). Cela ne déclenchera pas d'erreur NotImplementedError car nous remplaçons directement la propriété de charge utile, qui est celle qui appelle le nom d'utilisateur et le mot de passe.
Un bon test devrait vous aider à trouver où se situe le problème.
Voyons la sortie d'un scénario de test ayant échoué :
FAIL: test_should_return_expected_status_code (tests.test_login.TestInvalidPassword) ------------------------------------------------------ Traceback (most recent call last): File "../tests/test_login.py", line 28, in test_should_return_expected_status_code self.assertEqual(self.response.status_code, self.expected_status_code) AssertionError: 400 != 401 ------------------------------------------------------
En regardant le rapport de test échoué, il est clair ce qui n'a pas fonctionné. Lorsque le mot de passe n'est pas valide, nous attendons le code d'état 401, mais nous avons reçu 400.
Rendons les choses un peu plus difficiles et testons un utilisateur authentifié tentant de se connecter :
class TestAuthenticatedUserLogin(TestLogin, TestCase): username = 'Haki' password = 'correct-password' @property def client(self): session = requests.session() session.auth = ('Haki', 'correct-password') return session expected_status_code = 400
Cette fois, nous avons dû remplacer la propriété client pour authentifier la session.
Passer notre test à l'épreuve
Pour illustrer la résilience de nos nouveaux cas de test, voyons comment nous pouvons modifier la classe de base à mesure que nous introduisons de nouvelles exigences et modifications :
- Nous avons effectué une refactorisation et le point de terminaison a changé à
/api/user/login
:
class TestLogin: # ... def setUp(self): self.response = self.client.post( '/api/user/login', json=payload, )
- Quelqu'un a décidé que cela pourrait accélérer les choses si nous utilisions un format de sérialisation différent (msgpack, xml, yaml):
class TestLogin: # ... def setUp(self): self.response = self.client.post( '/api/account/login', data=encode(payload), )
- Les responsables du produit veulent se mondialiser, et maintenant nous devons tester différentes langues :
class TestLogin: language = 'en' # ... def setUp(self): self.response = self.client.post( '/{}/api/account/login'.format(self.language), json=payload, )
Aucun des changements ci-dessus n'a réussi à casser nos tests existants.
Aller plus loin
Quelques éléments à prendre en compte lors de l'utilisation de cette technique.
Accélérer les choses
setUp
est exécuté pour chaque cas de test de la classe (les cas de test sont les fonctions commençant par test_*
). Pour accélérer les choses, il est mieux d'effectuer l'action en setUpClass
. Cela change quelques choses. Par exemple, les propriétés que nous avons utilisées doivent être définies en tant qu'attributs sur la classe ou en tant que @classmethod
s.
Utilisation des luminaires
Lors de l'utilisation de Django avec des projecteurs , l'action doit aller dans setUpTestData :
class TestLogin: fixtures = ( 'test/users', ) @classmethod def setUpTestData(cls): super().setUpTestData() cls.response = cls.get_client().post('/api/account/login', json=payload)
Django charge les appareils à setUpTestData
donc en appelant super l'action est exécutée après le chargement des projecteurs.
Une autre note rapide sur Django et les requêtes. J'ai utilisé le requests
mais Django, et le populaire Django restframework
, fournissent leurs propres clients. django.test.Client
dans le client de Django, et rest_framework.test.APIClient
est le client de DRF.
Exceptions de test
Lorsqu'une fonction déclenche une exception, nous pouvons étendre la classe de base et envelopper l'action avec try ... catch
:
class TestLoginFailure(TestLogin): @property def expected_exception(self): raise NotImplementedError() def setUp(self): try: super().setUp() except Exception as e: self.exception = e def test_should_raise_expected_exception(self): self.assertIsInstance( self.exception, self.expected_exception )
Si vous connaissez le assertRaises
contexte, je ne l'ai pas utilisé dans ce cas car le test ne devrait pas échouer pendant setUp
.
Créer des mixins
Les cas de test sont répétitifs par nature. Avec les mixins, nous pouvons résumer les parties communes des cas de test et en composer de nouveaux. Par exemple :
TestAnonymousUserMixin
- remplit le test avec un client API anonyme.TestRemoteResponseMixin
- réponse fictive du service à distance.
Ce dernier pourrait ressembler à ceci :
from unittest import mock class TestRemoteServiceXResponseMixin: mock_response_data = None @classmethod @mock.patch('path.to.function.making.remote.request') def setUpTestData(cls, mock_remote) mock_remote.return_value = cls.mock_response_data super().setUpTestData()
Conclusion
Quelqu'un a dit un jour que la duplication est moins chère que la mauvaise abstraction . Je ne pourrais pas être plus d'accord. Si vos tests ne s'intègrent pas facilement dans un modèle, cette solution n'est probablement pas la bonne . Il est important de décider avec soin ce qu'il faut résumer. Plus vous faites d'abstraction, plus vos tests sont flexibles. Mais, à mesure que les paramètres s'accumulent dans les classes de base, les tests deviennent de plus en plus difficiles à écrire et à maintenir, et nous revenons à la case départ.
Cela dit, nous avons trouvé cette technique utile dans diverses situations et avec différents frameworks (tels que Tornado et Django). Au fil du temps, il s'est avéré être résistant aux changements et facile à entretenir. C'est ce que nous avons entrepris de réaliser et nous le considérons comme un succès !