Python >> Tutoriel Python >  >> Python

Comment nous avons remplacé des dizaines d'appareils de test par une seule fonction simple

Tout a commencé lorsque nous avons ajouté des indicateurs de fonctionnalité à notre application. Après quelques délibérations, nous avons créé un modèle "jeu de fonctionnalités" avec des champs booléens pour chaque fonctionnalité :

class FeatureSet(models.Model):
 name = models.CharField(max_length=50)
 can_pay_with_credit_card = models.BooleanField()
 can_save_credit_card = models.BooleanField()
 can_receive_email_notifications = models.BooleanField()

Nous avons ajouté une clé étrangère du compte utilisateur au modèle d'ensembles de fonctionnalités et créé des ensembles de fonctionnalités pour les utilisateurs "pro", "débutants" et "commerciaux".

Pour appliquer les fonctionnalités, nous avons ajouté des tests aux endroits appropriés. Par exemple :

def pay_with_credit_card(self, user_account, amount):
 if not user_account.feature_set.can_pay_with_credit_card:
 raise FeatureDisabled('can_pay_with_credit_card')
 # ...

Le problème

À ce stade, nous avions une grande base de code et de nombreux tests. Malheureusement, beaucoup de nos tests étaient une relique de l'époque où nous utilisions beaucoup les projecteurs.

L'idée de devoir mettre à jour et ajouter de nouveaux appareils était inacceptable. Mais, nous devions encore tester les nouvelles fonctionnalités, nous avons donc commencé à écrire des tests comme celui-ci :

def test_should_charge_credit_card(self):
 feature_set = user_account.feature_set
 feature_set.can_pay_with_credit_card = True
 feature_set.save(update_fields=['can_pay_with_credit_card'])
 pay_with_credit_card(user_account, 100)

def test_should_fail_when_feature_disabled(self):
 feature_set = user_account.feature_set
 feature_set.can_pay_with_credit_card = False
 with self.assertRaises(FeatureDisabled):
 pay_with_credit_card(self.user_account, 100)

Nous avions beaucoup de tests à mettre à jour et certaines des fonctionnalités que nous avons ajoutées ont interrompu le flux des autres tests, ce qui a entraîné un gâchis !

Le gestionnaire de contexte

Nous avons déjà utilisé des gestionnaires de contexte pour améliorer nos tests dans le passé, et nous avons pensé que nous pouvions en utiliser un ici pour activer et désactiver des fonctionnalités :

from contextlib import contextmanager

@contextmanager
def feature(feature_set, feature_name, enabled):
 original_value = getattr(feature_set, feature_name)
 setattr(feature_set, feature_name, enabled)
 feature_set.save(update_fields=[feature_name])

 try:
 yield

 finally:
 setattr(feature_set, feature_name, original_value)
 feature_set.save(update_fields=[feature_name])

Que fait ce gestionnaire de contexte ?

  1. Enregistrer la valeur d'origine de la fonctionnalité.
  2. Définissez la nouvelle valeur de la fonctionnalité.
  3. Rendements - c'est là que notre code de test s'exécute réellement.
  4. Remettre la fonctionnalité à sa valeur d'origine

Cela a rendu nos tests beaucoup plus élégants :

def test_should_charge_credit_card(self):
 with feature(user_account.feature_set, can_pay_with_credit_card, True):
 pay_with_credit_card(user_account, 100)

def test_should_fail_when_feature_disabled(self):
 with feature(user_account.feature_set, can_pay_with_credit_card, False):
 with self.assertRaises(FeatureDisabled):
 pay_with_credit_card(self.user_account, 100)

**kwargs

Ce gestionnaire de contexte s'est avéré très utile pour les fonctionnalités, nous avons donc pensé... pourquoi ne pas l'utiliser également pour d'autres choses ?

Nous avions beaucoup de méthodes impliquant plus d'une fonctionnalité :

def test_should_not_send_notification(self):
 feature_set = user_account.feature_set
 with feature(feature_set, can_pay_with_credit_card, True):
 with feature(feature_set, can_receive_notifications, False):
 pay_with_credit_card(user_account, 100)

Ou plusieurs objets :

def test_should_not_send_notification_to_inactive_user(self):
 feature_set = user_account.feature_set
 user_account.user.is_active = False
 with feature(feature_set, can_receive_notifications, False):
 pay_with_credit_card(user_account, 100)

Nous avons donc réécrit le gestionnaire de contexte pour accepter n'importe quel objet et ajouté la prise en charge de plusieurs arguments :

@contextmanager
def temporarily(obj, **kwargs):
 original_values = {k: getattr(obj, k) for k in kwargs}

 for k, v in kwargs.items():
 setattr(obj, k, v)

 obj.save(update_fields=kwargs.keys())

 try:
 yield

 finally:
 for k, v in original_values.items():
 setattr(obj, k, v)

 obj.save(update_fields=original_values.keys())

Le gestionnaire de contexte peut désormais accepter plusieurs fonctionnalités, enregistrer les valeurs d'origine, définir les nouvelles valeurs et restaurer lorsque nous avons terminé.

Les tests sont devenus beaucoup plus faciles :

def test_should_not_send_notification(self):
 with temporarily(
 user_account.feature_set,
 can_pay_with_credit_card=True,
 can_receive_notifications=False,
 ):
 pay_with_credit_card(user_account, 100)
 self.assertEquals(len(outbox), 0)

Nous pouvons maintenant utiliser la fonction sur d'autres objets également :

def test_should_fail_to_login_inactive_user(self):
 with temporarily(user, is_active=False):
 response = self.login(user)
 self.assertEqual(response.status_code, 400)

Profitez !

L'avantage de performance caché

Après un certain temps à se familiariser avec le nouvel utilitaire, nous avons remarqué un autre avantage en termes de performances. Dans les tests qui avaient des configurations lourdes, nous avons réussi à déplacer la configuration du niveau de test au niveau de la classe.

Pour illustrer la différence, testons une fonction qui envoie une facture aux utilisateurs. Les factures ne sont généralement envoyées que lorsque la transaction est terminée. Pour créer une transaction complète, nous avons besoin de beaucoup de configuration (choisir des produits, passer à la caisse, émettre un paiement, etc.).

Il s'agit d'un test qui nécessite beaucoup de configuration :

class TestSendInvoice(TestCase):

 def setUp(self):
 self.user = User.objects.create_user( ... )
 self.transaction = Transaction.create(self.user, ... )
 Transaction.add_product( ... )
 Transaction.add_product( ... )
 Transaction.checkout( ... )
 Transaction.request_payment( ... )
 Transaction.process_payment( ... )

 def test_should_not_send_invoice_to_commercial_user(self):
 self.user.type = 'commercial'
 mail.outbox = []
 Transaction.send_invoice(self.user)
 self.assertEqual(len(mail.outbox), 0)

 def test_should_attach_special_offer_to_pro_user(self):
 self.user.type = 'pro'
 mail.outbox = []
 Transaction.send_invoice(self.user)
 self.assertEqual(len(mail.outbox), 1)
 self.assertEqual(
 mail.outbox[0].subject,
 'Invoice and a special offer!'
 )

Le setUp La fonction doit s'exécuter avant chaque fonction de test car les fonctions de test modifient les objets et cela peut créer une dépendance dangereuse entre les cas de test.

Pour éviter les dépendances entre les cas de test, nous devons nous assurer que chaque test laisse les données exactement telles qu'elles les ont obtenues. Heureusement, c'est exactement ce que fait notre nouveau gestionnaire de contexte :

class TestSendInvoice(TestCase):

 @classmethod
 def setUpTestData(cls):
 cls.user = User.objects.create_user( ... )
 cls.transaction = Transaction.create(cls.user, ... )
 Transaction.add_product( ... )
 Transaction.add_product( ... )
 Transaction.checkout( ... )
 Transaction.request_payment( ... )
 Transaction.process_payment( ... )

 def test_should_not_send_invoice_to_commercial_user(self):
 mail.outbox = []
 with temporarily(self.user, type='commercial'):
 Transaction.send_invoice(self.user)
 self.assertEqual(len(mail.outbox), 0)

 def test_should_attach_special_offer_to_pro_user(self):
 mail.outbox = []
 with temporarily(self.user, type='pro'):
 Transaction.send_invoice(self.user)
 self.assertEqual(len(mail.outbox), 1)
 self.assertEqual(mail.outbox[0].subject, 'Invoice and a special offer!')

Nous avons déplacé le code de configuration vers setUpTestData. Le code de configuration ne s'exécutera qu'une seule fois pour toute la classe de test, ce qui accélérera les tests.

Les derniers mots

La motivation de ce processeur de contexte était notre longue relation malsaine avec les luminaires. Au fur et à mesure que nous avons mis à l'échelle notre application, les luminaires sont devenus un fardeau. Le fait qu'autant de tests dépendent d'eux rendait difficile leur remplacement complet.

Avec l'ajout de fonctionnalités, nous savions que nous ne voulions plus nous fier aux appareils et nous avons cherché des moyens créatifs, plus détaillés et maintenables de gérer les données de test. Avoir un moyen simple de créer différentes variantes d'un objet à tester était exactement ce dont nous avions besoin.


Post précédent
No