poulpe/api/test/testcases.py
2017-08-13 18:05:24 +02:00

694 lines
20 KiB
Python

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