Pour implémenter des tests unitaires simples, on crée une classe héritant de django.test.TestCase
.
Cette classe doit être située dans un répertoire tests
, le nom du fichier doit être préfixé de test_
et les méthodes également préfixée de test_
pour qu’elles soient considérées comme des tests unitaires.
lib/patient/tests/models/test_patient.py
from django.test import TestCase
from lib.patient.models import Patient
from lib.patient.factories.patient import PatientFactory
class PatientTest(TestCase):
def test_national_identification_number_fmt(self):
"""
Tests that the national_identification_number is correctly formatted
"""
patient = PatientFactory(
firstname='Alice',
lastname='A',
gender=Patient.GENDER_FEMALE,
birth_date='2000-12-31',
national_identification_number=None,
)
self.assertIsNone(patient.national_identification_number)
self.assertIsNone(patient.national_identification_number_fmt)
patient.national_identification_number = '200123818500114'
self.assertEqual(patient.national_identification_number_fmt, '2 00 12 38 185 001 14')
Peut également utiliser la classe SimpleTestCase
.
Elle n’a pas d’intégration avec la BDD, ce qui permet de gagner du temps lors de l’exécution des tests si aucun accès à la BDD n’est nécesssaire.
assertFalse(self, expr, msg=None)
assertTrue(self, expr, msg=None)
assertRaises(self, expected_exception, *args, **kwargs)
assertWarns(self, expected_warning, *args, **kwargs)
assertLogs(self, logger=None, level=None)
assertNoLogs(self, logger=None, level=None)
assertEqual(self, first, second, msg=None)
assertNotEqual(self, first, second, msg=None)
assertAlmostEqual(self, first, second, places=None, msg=None
assertNotAlmostEqual(self, first, second, places=None, msg=None
assertSequenceEqual(self, seq1, seq2, msg=None, seq_type=None)
assertListEqual(self, list1, list2, msg=None)
assertTupleEqual(self, tuple1, tuple2, msg=None)
assertSetEqual(self, set1, set2, msg=None)
assertIn(self, member, container, msg=None)
assertNotIn(self, member, container, msg=None)
assertIs(self, expr1, expr2, msg=None)
assertIsNot(self, expr1, expr2, msg=None)
assertDictEqual(self, d1, d2, msg=None)
assertDictContainsSubset(self, subset, dictionary, msg=None)
assertCountEqual(self, first, second, msg=None)
assertMultiLineEqual(self, first, second, msg=None)
assertLess(self, a, b, msg=None)
assertLessEqual(self, a, b, msg=None)
assertGreater(self, a, b, msg=None)
assertGreaterEqual(self, a, b, msg=None)
assertIsNone(self, obj, msg=None)
assertIsNotNone(self, obj, msg=None)
assertIsInstance(self, obj, cls, msg=None)
assertNotIsInstance(self, obj, cls, msg=None)
assertRaisesRegex(self, expected_exception, expected_regex
assertWarnsRegex(self, expected_warning, expected_regex
assertRegex(self, text, expected_regex, msg=None)
assertNotRegex(self, text, unexpected_regex, msg=None)
Vérifie qu’une valeur est False
self.assertFalse(Search.objects.search('Dupont').exists())
Vérifie qu’une valeur est True
self.assertTrue(Search.objects.search('Charlene dupont').exists())
Vérifie qu’une valeur est None
self.assertIsNone(data['clinical-height'])
Vérifie qu’une valeur n’est pas None
self.assertIsNotNone(attributes.get('token', None))
Vérifie qu’une valeur est égale à une autre
self.assertEqual(error['code'], 'accepted')
self.assertEqual(Search.objects.search('"St Dupon"').count(), 1)
Vérifie qu’une valeur n’est pas égale à une autre
self.assertNotEqual(attr, '')
Vérifie qu’une valeur en contient une autre (a in b
)
self.assertIn('unexpected UUID', str(log.msg))
Vérifie qu’une valeur n’en contient pas une autre
self.assertNotIn('predicted-cpt', payload['data']['attributes'])
self.assertNotIn(self.profile.id, obj_id_list)
Est spécifique à Django pour vérifier l’état d’une réponse
response = self.client.get(self.url)
self.assertContains(response, 't3st')
Permet d’évaluer davantage que assertIn
SimpleTestCase.assertContains(response, text, count=None, status_code=200, msg_prefix='', html=False)
Inverse d’assertContains
self.assertNotContains(response, 'Thomas DUPONT')
Vérifie que deux listes sont égales
self.assertListEqual([x.email for x in cached_objects.admins], [admin1.email, admin2.email])
Vérifie qu’une valeur est supérieure à la valeur donnée
self.assertGreater(patient.get_age(record.date), 99)
Vérifie qu’une valeur est inférieur à la valeur donnée
self.assertLess(patient.get_age(record.date), 20)
Vérifie que deux objets sont la même instance
self.assertIs(self.patient.record, self.record)
Vérifie qu’un objet est une instance d’une classe donnée
self.assertIsInstance(self.record.antecedents_ptr, RecordAntecedents)
Vérifie qu’une exception est levée
# Ensure PhoneDevice no longer exists
with self.assertRaises(PhoneDevice.DoesNotExist):
phone_device.refresh_from_db()
Vérifie que des logs sont ajoutés
with self.assertLogs('foo', level='INFO') as cm:
logging.getLogger('foo').info('first message')
logging.getLogger('foo.bar').error('second message')
self.assertEqual(cm.output, [
'INFO:foo:first message',
'ERROR:foo.bar:second message',
])
Vérifie qu’une réponse donnée est une redirection vers un autre endpoint
url = reverse('admin:doctors-team-edit', args=[6])
# 2FA enabled
self.assertRedirects(self.client.get(url), reverse('settings-security'), fetch_redirect_response=False)
La méthode setUp
est appelé avant chaque méthode test_*
, elle permet de définir des variables dont on se sert dans les tests unitaires sans avoir à les re-définir à chaque fois.
class PatientACLApiTest(APITestCase):
"""
Test patient ACL logic
"""
def setUp(self):
"""
Is called before each test.
Creates objects used in the tests.
"""
# Create medical centers
self.medical_center = MedicalCenterFactory(
name='CHU Nice',
)
def test_create_medical_center_acl(self):
"""
Test ACL logic on medical centers
"""
medical_center = self.medical_center
On a également tearDown
, appelé après chaque test
def setUp(self):
"Hook method for setting up the test fixture before exercising it."
pass
def tearDown(self):
"Hook method for deconstructing the test fixture after testing it."
pass
Toutes les méthodes présentes commençant par test_
dans une classe TestCase sont considérées comme de tests unitaires distincts, on peut donc temporairement désactiver des tests unitaires en renommant des méthodes — par exemple skip_test_...
.
Une autre manière de désactiver des tests unitaires est d’utiliser le décorateur unittest.skip
# flake8: noqa E501
import unittest
from django.test import TestCase
@unittest.skip('FONCT-25 Update stage detection logic')
class Sprint1Test(TestCase):
On peut temporairement modifier les configurations de l’application avec django.test.override_settings
soit à l’échelle d’une classe
from rest_framework.test import APITestCase
from django.test import override_settings
@override_settings(IS_2FA_ENABLED=True)
class UserAuth2FApiTest(APITestCase):
d’une méthode
from django.test import TestCase, override_settings
class UserModelTest(TestCase):
@override_settings(IS_NOMINATIM_ENABLED=True)
def test_trigger_compute_longitude_latitude(self):
ou encore d’un bloc
from django.test import TestCase, override_settings
class GetAIReportModelTest(TestCase):
def test_launch_get_ai_report(self):
...
with override_settings(
IS_LUNGSCREENAI_ENABLED=True,
LUNGSCREENAI_MAX_ATTEMPTS_RECOVER=5,
LUNGSCREENAI_MAX_ATTEMPTS_MISSING=2,
LUNGSCREENAI_URL='http://localhost:8000/api/lungscreenai/dummy',
):
On peut empêcher le déclenchement des hooks de cycle de vie des modèles crées via une factory graĉe à factory.django.mute_signals
import factory
from . import models
from . import signals
@factory.django.mute_signals(signals.pre_save, signals.post_save)
class FooFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Foo
def make_chain():
with factory.django.mute_signals(signals.pre_save, signals.post_save):
# pre_save/post_save won't be called here.
return SomeFactory(), SomeOtherFactory()
Il est possible de définir le temps qui sera retourné par un appel à time.time()
avec django.test.utils.freeze_time
from django.test.utils import freeze_time
class ApiLungScreenAITest(APITestCase):
def test_api_permission(self):
# Auth with valid token, but expired signature (created an hour ago): unauthorized
with freeze_time(time.time() - 3601):
signature = create_signature(settings.LUNGSCREENAI_AUTH_TOKEN)
self.client.credentials(HTTP_AUTHORIZATION=f'Token {settings.LUNGSCREENAI_AUTH_TOKEN}')
response = self.client.get(url, HTTP_X_SIGNATURE=signature)
self.assertEqual(response.status_code, 401)
Lors du lancement de tests unitaires, les mails ne sont pas envoyés au serveur SMTP mais à un container django.core.mail
, dont on peut se servir pour vérifier le déclenchement d’envoi par mail
from django.core import mail
class UserAuth2FApiTest(APITestCase):
def test_login(self):
self.assertEqual(len(mail.outbox), 0)
...
# Did send a mail
self.assertEqual(len(mail.outbox), 1)
mail_txt = mail.outbox[0].body
# Has an OTP in it
device = devices[0]
challenge_otp1 = device.challenge_otp
self.assertNotEqual(mail_txt.find(f'code: {challenge_otp1}'), -1)
# Reset mail outbox
mail.outbox = []
...
# Didn't send a mail but sms
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, 'Fake SMS to +33789123456')
self.assertEqual(mail.outbox[0].to, [user.email])
Le mocking consiste à remplacer ou modifier le comportement de dépendances pour les tests. Cela permet
d’éviter de dépendre de services externes lorsqu’on exécute des tests. On ne peut pas toujours garantir que les services externes sont disponibles lorsqu’on exécue les tests, ce qui les rend imprévisibles et incohérents.
d’éviter les effets secondaires et les conséquences involontaires lorsqu’on exécute les tests — par exemple, l’envoi accidentel de mails ou overload de services externes.
d’isoler le morceau de code testé, ce qui rend le test plus précis et plus fiable.
Pour simuler du code, on utilise unittest.mock
On par exemple peut forcer la valeur retournée par uuid.uuid4()
en utilisant patch.return_value
:
def recipe_image_file_path(instance, filename):
"""Generate file path path for new recipe image."""
ext = os.path.splitext(filename)[1]
+ filename = f'{uuid.uuid4()}{ext}'
return os.path.join('uploads', 'recipe', filename)
class Recipe(models.Model):
"""Recipe object."""
image = models.ImageField(
null=true,
upload_to=recipe_image_file_path,
)
from unittest.mock import patch
from django.test import TestCase
class ModelTests(TestCase):
+@patch('core.models.uuid.uuid4')
+def test_recipe_file_name_uuid(self, mock_uuid):
uuid = 'test-uuid'
+ mock_uuid.return_value = uuid
file_path = models.recipe_image_file_path(None, 'example.jpg')
self.assertEqual(file_path, f'uploads/recipe/{uuid}.jpg')
Ou forcer une fonction à lever une exception lorsqu’on l’appelle avec patch.side_effect
:
import time
from psycopg2 import OperationalError as Psycopg2Error
from django.db.utils import OperationalError
from django.core.mangement.base import BaseCommand
class Command(BaseCommand):
"""
Django command to wait for database.
"""
def handle(self, *args, **kwargs):
self.stdout.write('Waiting for database...')
db_up = False
while db_up is False:
try:
+ self.check(databases=['default'])
db_up = True
except (Psycopg2Error, OperationalError):
self.stdout.write('Database unavailable, waiting 1 second...')
time.sleep(1)
self.stdout.write(self.style.SUCCES('Database available!'))
from io import BytesIO, StringIO
from unittest.mock import patch
from psycopg import OperationalError as PsycopgError
from django.core.management import call_command
from django.db.utils import OperationalError
from django.test import SimpleTestCase
@patch('core.management.commands.wait_for_db.Command.check')
class CommandTests(SimpleTestCase):
def setUp(self):
self.stdout = StringIO()
self.stderr = StringIO()
def test_wait_for_db_ready(self, patched_check):
"""
Test waiting for database if database ready
"""
patched_check.return_value = True
call_command('wait_for_db', stdout=self.stdout, stderr=self.stderr, no_color=True)
patched_check.assert_called_once_with(databases=['default'])
@patch('time.sleep')
def test_wait_for_db_delay(self, patched_sleep, patched_check):
"""
Test waiting for database when getting OperationalError
"""
# Define various different values
# that happen each time we call it, in the order we call it
+ patched_check.side_effect = (
[PsycopgError] * 2
+ [OperationalError] * 3
+ [True]
)
call_command('wait_for_db', stdout=self.stdout, stderr=self.stderr, no_color=True)
self.assertEqual(patched_check.call_count, 6)
Permet de vérifier si un appel externe a été effectué.
Peut être ajouté via
un décorateur sur la classe:
import requests_mock
@requests_mock.Mocker(real_http=True)
class MedicalImagesUploadTest(APITestCase):
def test_medical_images_upload_file_creation_with_tasks_applied(self, m):
orthanc_matcher = m.register_uri(requests_mock.ANY, settings.ORTHANC_API_ROOT_URL)
self.load_orthanc_data(m)
self.assertTrue(orthanc_matcher.called)
self.assertEqual(orthanc_matcher.call_count, 5)
un décorateur sur la méthode
class DrugApiTest(APITestCase):
@requests_mock.Mocker()
def test_creation_with_new_drug(self, m):
m.register_uri(requests_mock.ANY, requests_mock.ANY, real_http=True)
self.assertFalse(m.called)
response = self.client.post(
reverse("jsonapi:drug-list"),
payload,
content_type="application/vnd.api+json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(m.called)
self.assertEqual(m.call_count, 1)
un bloc
class QuestionnaireTaskTest(APITestCase):
def test_create_patient_questionnaires(self):
# Note: requests_mock should be imported after requests for it to work
import requests_mock
with requests_mock.Mocker(real_http=False) as m:
m.register_uri(requests_mock.ANY, requests_mock.ANY)
self.assertFalse(m.called)
# The task creates an URI
create_patient_questionnaires.delay()
history = m.request_history
# API was called
self.assertTrue(m.called)
self.assertEqual(len(history), 1)
self.assertEqual(history[0].method, "PUT")
# Mail was sent
self.assertEqual(len(mail.outbox), 1)
Si real_http=False
, alors la requête vers l’extérieur n’est pas effectuée.
On peut également définir le contenu de la réponse
@override_settings(IS_NOMINATIM_ENABLED=True)
def test_trigger_compute_longitude_latitude(self):
import requests_mock
text = json.dumps([{
'lat': '0.1',
'lon': '0.2',
}])
user = UserFactory()
self.assertIsNone(user.address_latitude)
# Address is filled in: nominatim is called
with requests_mock.Mocker(real_http=False) as m:
m.register_uri(requests_mock.ANY, requests_mock.ANY, text=text)
user.address_text = '20 rue Saint-André, 06100 Nice'
user.save()
user.refresh_from_db()
self.assertTrue(m.called)
self.assertIsNotNone(user.address_latitude)