Python >> Tutoriel Python >  >> Python

Maintenir les tests au sec avec des tests basés sur des classes en Python

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 et password .
  • 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 et payload , et la sortie attendue expected_* .
  • 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 !