from unittest import mock from django.contrib.auth import get_user_model from django.utils import timezone from django.urls import reverse from rest_framework import status User = get_user_model() class GenericAPITestCaseMixin: """ Base mixin for testing one or more operations on a model. The specifics of each operation is not implemented here. Each operation has its own TestCase, which relies on methods/attributes defined here (some or all). The "real" mixins for each operation are: [List,Create,Retrieve,Update,Destroy]APITestCaseMixin. Example: E.g. for a creation test, your testcase should be something like: class MyModelCreateAPITestCase( CreateAPITestCaseMixin, GenericAPITestCaseMixin, APITestCase, ): ... Attributes: # General model (models.Model): The model class under test. url_label (str): Use to get the API urls of the model under test: - The model url is:`reverse('{url_label}-list). This is used by `[List,Create]APITestCaseMixin`. The url can also be customized by defining `get_url_model` method. - The url of a model object is: `reverse('{label}-detail')`. This is used by `[Retrieve,Update,Destroy]APITestCaseMixin`. The url can also be customized by defining `get_url_object` method. # Authentication user_auth (User instance): If given, this user is authenticated for each request sent to the API. Default to `None`. See section 'Authenticate requests'. user_auth_mapping (dict of str: User instance): Associates a user to authenticate for each type of API request. See section 'Authenticate requests'. Authenticate requests: Each real testcase call `get_user` method with a label for the operation under test (e.g. UpdateAPITestCaseMixin will call `get_user('update')`. If it returns a user, this latter is authenticated with `force_authenticate` method of the test client. The default behavior, which can be customized by replacing `get_user` method, is to return `user_auth` attribute/property if not None. Else, `user_auth_mapping[operation_type_label]` or None if key is missing. A basic user is created during setUp. It can be found as `user` attribute. """ model = None url_label = '' user_auth = None user_auth_mapping = {} def setUp(self): """ Prepare a test execution. Don't forget to call super().setUp() if you define this in subclass. """ # Dict comparison can be large. self.maxDiff = 2000 # This will be returned by django.utils.timezone.now thanks to the mock # in test methods. # This can be useful to check auto_now and auto_now_add fields. self.now = timezone.now() # A very basic user. self.user = User.objects.create_user( username='user', password='user', first_name='first', last_name='last', ) def get_user(self, action): """ Returns a user to authenticate for requests of type 'action'. Property `user_auth` has precedence over the property `user_auth_mapping`. Args: action (str): Operation label. See LCRUD TestCases for reserved labels, before setting your own if you create a new common operation TestCase. Returns: User instance | None: If None, no user should be authenticated for this request. Else, the returned user should be authenticated. """ if self.user_auth is not None: return self.user_auth return self.user_auth_mapping.get(action) def get_url_model(self, *args, **kwargs): """ Returns the API url for the model. Default to a reverse on `{url_label}-list`. Used by `[List,Create]APITestCaseMixin`. """ return reverse( '{label}-list'.format(label=self.url_label), args=args, kwargs=kwargs, ) def get_url_object(self, *args, **kwargs): """ Returns the API url for a particular object of model. Default to a reverse on `{url_label}-detail`. Used by `[Retrieve,Update,Destroy]APITestCaseMixin`. """ return reverse( '{label}-detail'.format(label=self.url_label), args=args, kwargs=kwargs, ) def db_create(self, data): """ Create a model instance in DB from data given as argument. `data` can define M2M fields. Operations: - Create object from non-M2M fields. - Link the M2M fields with `set` on the created object. Returns: The created object. """ fields, m2m_fields = {}, {} for name, value in data.items(): if self.model._meta.get_field(name).many_to_many: m2m_fields[name] = value else: fields[name] = value instance = self.model.objects.create(**fields) for name, value in m2m_fields.items(): getattr(instance, name).set(value) return instance def get_expected_data(self, instance): """ Returns the data the API should reply for the object `instance`. This should be the same result of the serializer used for the sent request. Must be defined by subclasses, except for DestroyAPITestCaseMixin. Args: instance (a `model` object) Returns: JSON-decoded data of this instance. """ raise NotImplementedError( "Subclass must implement 'get_expected_data(instance)' method." ) def assertInstanceEqual(self, instance, expected): """ Checks if instance verifies expected. For each key/value pair of expected, it verifies that instance.key is equal to value. `assertEqual` is used, except for M2M fields where `assertQuerysetEqual(ordered=True, ...)` is used. Args: instance (`model` object): E.g. obtained from DB after a create operation. expected (dict): Expected data of instance. """ for key, exp_value in expected.items(): field = self.model._meta.get_field(key) value = getattr(instance, key) if field.many_to_many: self.assertQuerysetEqual( value.all(), map(repr, exp_value), ordered=False, ) else: self.assertEqual(value, exp_value) @property def instances_data(self): """ Instances data of the model which will be created if necessary. For example, ListAPITestCaseMixin uses these before sending the list request. The first is also used, if `instance_data` is not re-defined, for RUD operations. """ raise NotImplementedError( "Property or attribute 'instances_data' must be declared by " "subclass." ) @property def instance_data(self): """ Data of a single instance of model. For example, this data is used to create the instance of model to apply RUD operations. Default to the first item of the `instances_data` property. """ return self.instances_data[0] class ListAPITestCaseMixin: """ Mixin to test the "List" API request of a model. Has hooks to check the returned objects and their ordering. Authentication: This operation use the label 'list'. See `GenericAPITestCaseMixin`#`Authenticate requests` for further informations. Attributes: list_ordering (list of str | str): The ordering of objects that should be respected by the API response. Same format as the ordering Meta attribute of models, e.g. '-created_at' or ['-event', 'created_at']. Ordering on '__' fields are currently not supported. Todo: * Allow '__' in ordering. * Support pages. Currently we expect returned objects are on a single page. """ list_ordering = [] @property def list_expected(self): """ Model instances the API should returned. Default to all objects of models. Eventually sorted according to `list_ordering` attribute. """ qs = self.model.objects.all() if self.list_ordering: qs = qs.order_by(self.list_ordering) return qs @property def list_expected_count(self): """ Number of objects the API should returned. Default to the length of expected returned objects. """ return len(self.list_expected) @property def list_expected_ordering(self): """ Use this to get expected ordering, instead of relying directly on the `list_ordering`, if you write subclasses. """ if isinstance(self.list_ordering, (tuple, list)): return self.list_ordering return [self.list_ordering] def assertOrdered(self, results, ordering): """ Check `results` are sorted according to `ordering`. Attributes: results (list of model instances) ordering (list of fields as str) """ _ordering = [ (f[1:], True) if f.startswith('-') else (f, False) for f in ordering ] it = iter(results) previous = next(it) for current in it: self._assertOrdered(previous, current, _ordering) previous = current def _assertOrdered(self, d1, d2, ordering): if not ordering: return True field, desc = ordering[0] v1, v2 = d1[field], d2[field] if v1 == v2: self._assertOrdered(v1, v2, ordering[1:]) elif desc: self.assertGreater(v1, v2, msg=( "Ordering on '%s' (DESC) is not respected for the following " "objects.\n- First: %r.\n- Second: %r." % (field, d1, d2) )) else: self.assertLess(v1, v2, msg=( "Ordering on '%s' (ASC) is not respected for the following " "objects:\n- First: %r.\n- Second: %r." % (field, d1, d2) )) @mock.patch('django.utils.timezone.now') def test_list(self, mock_now): """ The test. Preparation: - Create instances from `instances_data` property. - Optionally, authenticate client. Execution: - HTTP GET request on model url. Check: - Response status code: 200. - The base response: count (pages not supported). - Results (serialized instances) are formatted as expected and their ordering. """ mock_now.return_value = self.now # Setup database. for data in self.instances_data: self.db_create(data) # Call API to get instances list. user = self.get_user('list') if user: self.client.force_authenticate(user) r = self.client.get(self.get_url_model()) # Check API response. self.assertEqual(r.status_code, status.HTTP_200_OK) base_response = { 'count': self.list_expected_count, 'previous': None, 'next': None, } self.assertDictContainsSubset(base_response, r.data) results = r.data['results'] # Check API response ordering. self.assertOrdered(results, self.list_expected_ordering) # Check API response data. for result, instance in zip(results, self.list_expected): self.assertDictEqual(result, self.get_expected_data(instance)) class CreateAPITestCaseMixin: """ Mixin to test the "Create" API request of a model. Authentication: This operation use the label 'create'. See `GenericAPITestCaseMixin`#`Authenticate requests` for further informations. Note: The request is sent assuming the payload is JSON. """ @property def create_data(self): """ Payload of the create request sent to the API. """ raise NotImplementedError( "Subclass must define a 'create_data' attribute/property. This is " "the payload of the POST request to the model url." ) @property def create_expected(self): """ Data of model instance which should be created. """ raise NotImplementedError( "Subclass must define a create_expected attribute/property. It is " "a dict-like object whose value should be equal to the key " "attribute of the created instance." ) @mock.patch('django.utils.timezone.now') def test_create(self, mock_now): """ The test. Preparation: - Optionally, authenticate client. Execution: - HTTP POST request on model url. Payload from `create_data`. Check: - Response status code: 201. - Instance has been created in DB. - Instance created is as expected (check with `create_expected`). - Instance is correctly serialized (in response' data). """ mock_now.return_value = self.now # Call API to create an instance of model. user = self.get_user('create') if user: self.client.force_authenticate(user) r = self.client.post(self.get_url_model(), self.create_data) # Check database. instances = self.model.objects.all() self.assertEqual(len(instances), 1) instance = instances[0] self.assertInstanceEqual(instance, self.create_expected) # Check API response. self.assertEqual(r.status_code, status.HTTP_201_CREATED) self.assertDictEqual(r.data, self.get_expected_data(instance)) class RetrieveAPITestCaseMixin: """ Mixin to test the "Retrieve" API request of a model object. Authentication: This operation use the label 'retrieve'. See `GenericAPITestCaseMixin`#`Authenticate requests` for further informations. """ @mock.patch('django.utils.timezone.now') def test_retrieve(self, mock_now): """ The test. Preparation: - Create a model instance from `instance_data` property. - Optionally, authenticate client. Execution: - Get url of the object with its pk. - HTTP GET request on this url. Check: - Response status code: 200. - Instance is correctly serialized (in response' data). """ mock_now.return_value = self.now # Setup database. data = self.instance_data instance = self.db_create(data) # Call API to retrieve the event data. user = self.get_user('retrieve') if user: self.client.force_authenticate(user) r = self.client.get(self.get_url_object(pk=1)) # Check API response. self.assertEqual(r.status_code, status.HTTP_200_OK) self.assertDictEqual(r.data, self.get_expected_data(instance)) class UpdateAPITestCaseMixin: """ Mixin to test the "Update" API request of a model. Authentication: This operation use the label 'update'. See `GenericAPITestCaseMixin`#`Authenticate requests` for further informations. Notes: * A single test using partial update / PATCH HTTP method is written. * The request is sent assuming the payload is JSON. Todo: * Add test for update / PUT HTTP method. """ @property def update_data(self): """ Payload of the update request sent to the API. """ raise NotImplementedError( "Subclass must define a update_data attribute/property. This is " "the payload of the PUT request to the instance url." ) @property def update_expected(self): """ Data of model instance which should be updated. """ raise NotImplementedError( "Subclass must define a update_expected attribute/property. It is " "a dict-like object whose value should be equal to the key " "attribute of the updated instance." ) @mock.patch('django.utils.timezone.now') def test_update(self, mock_now): """ The test. Preparation: - Create a model instance from `instance_data` property. - Optionally, authenticate client. Execution: - Get url of the object with its pk. - HTTP PATCH request on this url. Payload from `update_data`. Check: - Response status code: 200. - No instance has been created or deleted in DB. - Updated instance is as expected (check with `update_expected`). - Instance is correctly serialized (in response' data). """ mock_now.return_value = self.now # Setup database. data = self.instance_data instance = self.db_create(data) # Call API to update the event. user = self.get_user('update') if user: self.client.force_authenticate(user) r = self.client.patch(self.get_url_object(pk=1), self.update_data) # Check database. instances = self.model.objects.all() self.assertEqual(len(instances), 1) instance.refresh_from_db() self.assertInstanceEqual(instance, self.update_expected) # Check API response. self.assertEqual(r.status_code, status.HTTP_200_OK) self.assertDictEqual(r.data, self.get_expected_data(instance)) class DestroyAPITestCaseMixin: """ Mixin to test the "Destroy" API request of a model. Authentication: This operation use the label 'destroy'. See `GenericAPITestCaseMixin`#`Authenticate requests` for further informations. """ @mock.patch('django.utils.timezone.now') def test_destroy(self, mock_now): """ The test. Preparation: - Create a model instance from `instance_data` property. - Optionally, authenticate client. Execution: - Get url of the object with its pk. - HTTP DELETE request on this url. Check: - Response status code: 204. - Instance is no longer present in DB. """ mock_now.return_value = self.now # Setup database. data = self.instance_data instance = self.db_create(data) # Call API to update the event. user = self.get_user('destroy') if user: self.client.force_authenticate(user) r = self.client.delete(self.get_url_object(pk=1)) # Check database. instances = self.model.objects.all() self.assertEqual(len(instances), 0) with self.assertRaises(self.model.DoesNotExist): instance.refresh_from_db() # Check API response. self.assertEqual(r.status_code, status.HTTP_204_NO_CONTENT) self.assertIsNone(r.data) class ModelAPITestCaseMixin( ListAPITestCaseMixin, CreateAPITestCaseMixin, RetrieveAPITestCaseMixin, UpdateAPITestCaseMixin, DestroyAPITestCaseMixin, GenericAPITestCaseMixin, ): """ Tests all LCRUD operations on a model. See general docs in GenericAPITestCaseMixin. See docs for a specific operation in [Operation]APITestCaseMixin. Example: class EventAPITestCase(ModelAPITestCaseMixin, APITestCase): model = Event ... @property def create_data(self): ... @property def create_expected(self): ... ... If you want to run only a few operations, consider using specific-mixins individually. You can still have something as `EventAPITestCaseMixin` to provide common atributes/properties/methods. """ pass