DjangoRestFramework API checker

Overview

Test status Coverage

This module offers some utilities to avoid unwanted changes in Django Rest Framework responses, so to keep stable contracts

The purpose is to guarantee that any code changes never introduce ‘contract violations’ changing the Serialization behaviour.

Contract violations can happen when:

  • fields are removed from Serializer
  • field representation changes (ie. date/number format, )
  • Response status code changes (optional)
  • Response headers change (optional)

How it works:

First time the test run, the response and model instances are serialized and saved on the disk; any further execution is checked against this first response.

Test data are saved in the same directory where the test module lives, under _api_checker/<module_fqn>/<test_class>

Fields that cannot be checked by value (ie timestamps/last modified) can be tested writing custom assert_<field_name> methods.

In case of nested objects, method names must follow the field “path”. (ie. assert_permission_modified vs assert_modified)

This module can also intercept when a field is added, in this case it is mandatory recreate stored test data; simply delete them from the disk or set API_CHECKER_RESET environment variable and run the test again,

In case something goes wrong the output will be

Field values mismatch

Field removed

AssertionError: View `<class 'path.to.module.CustomerListAPIView'>` breaks the contract.
Field `id` is missing in the new response

Field added

AssertionError: View `<class 'path.to.module.CustomerListAPIView'>` returned more field than expected.
Action needed api_customers.response.json need rebuild.
New fields are:
`['country']`

Table Of Contents

Install

Using pip:

pip install drf-api-checker

Go to https://github.com/saxix/drf-api-checker if you need to download a package or clone the repo.

drf-api-checker does not need to be added into INSTALLED_APPS

How to run the tests

$ pip install tox
$ tox

Unitest style test support

Unitest style is supported via ApiCheckerMixin and ApiCheckerBase

ApiCheckerMixin

Base test looks like:

class TestAPIAgreements(ApiCheckerMixin, TestCase):
    def get_fixtures(self):
        return {'customer': CustomerFactory()}

    def test_customer_detail(self):
        url = reverse("customer-detail", args=[self.get_fixture('customer').pk])
        self.assertGET(url)

get_fixtures must returns a dictionary of all the fixtures that need to be restored to have comparable responses.

WARNING: when factory_boy is used pay attention to ForeignKeys. They need to be listed too and the factory need to be written in a way that can reproduce predictable records

ApiCheckerBase

class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase):
    URLS = [
            reverse("intervention-list"),
            reverse("intervention-detail", args=[101]),
           ]

    def get_fixtures(cls):
        return {'intervention': InterventionFactory(id=101),
           'result': ResultFactory(),
           }

Unittest Recipes

Check DateTimeField() with auto_now=True

Add a method assert_<fieldname> that check by format instead

class TestUrls(TestCase, metaclass=ApiCheckerBase):

    def assert_timestamp(self, response, expected, path=''):
        value = response['timestamp']
        assert datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')

Check protected url

Using standard DRF way: self.client.login() or self.client.force_authenticate()

PyTest

pytest is supported via @frozenfixture and @contract

PyTest Recipes

Check DateTimeField() with auto_now=True

Create a custom Recorder and pass it to @contract

from drf_api_checker.recorder import Recorder
from drf_api_checker.pytest import contract, frozenfixture

class MyRecorder(Recorder):

    def assert_timestamp(self, response, expected, path=''):
        value = response['timestamp']
        assert datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')


@contract(recorder_class=MyRecorder)
def test_user_list(user):
    return reverse('api:user-list')

Check protected url

Create a custom Recorder and override client property

class MyRecorder(Recorder):
    @property
    def client(self):
        user = UserFactory(is_superuser=True)
        client = APIClient()
        client.force_authenticate(user)
        return client

@contract(recorder_class=MyRecorder)
def test_user_list(user):
    return reverse('api:user-list')

Check methods other than GET

from drf_api_checker.recorder import Recorder
from drf_api_checker.pytest import contract, frozenfixture

@contract(recorder_class=MyRecorder, method='post')
def test_url_post(frozen_detail):
    url = reverse("master-create")
    return url, {"name": "name1"}

Change the name of frozenfixture filename

def get_fixture_name(seed, request):
    viewset = request.getfixturevalue('viewset')
    url = viewset.get_service().endpoint.strip('.').replace('/', '_')
    return os.path.join(seed, url) + '.fixture.json'


@frozenfixture(fixture_name=get_fixture_name)
def data(db, request):
    viewset = request.getfixturevalue('viewset')
    factory = factories_registry[viewset.serializer_class.Meta.model]
    data = (factory(schema_name='bolivia'),
            factory(schema_name='chad'),
            factory(schema_name='lebanon'))
    return data

Use pytest.parametrize

@pytest.mark.parametrize("method", ['get', 'options'])
def test_parametrize(frozen_detail, api_checker_datadir, method):
    url = reverse("master-list")
    recorder = MyRecorder(api_checker_datadir)
    recorder.assertCALL(url, method=method)

Authenticate client with different users

pseudo-code

@pytest.mark.parametrize("permission", ['can_read', 'can_write'])
def test_parametrize(frozen_detail, api_checker_datadir, permission):
    url = reverse("master-list")
    user = UserFactory()
    with user_grant_permissions(user, [permission])
        recorder = MyRecorder(api_checker_datadir, as_user=user):
        recorder.assertCALL(url, method=method)

API

Unittest/django support

ApiCheckerMixin
class drf_api_checker.unittest.ApiCheckerMixin[source]

Mixin to enable API contract check

How to use it:

  • implement get_fixtures() to create data for test. It should returns a dictionary
  • use self.assert<METHOD>(url) to check urls contract

Example:

class TestAPIAgreements(ApiCheckerMixin, TestCase):
def get_fixtures(self):
return {‘customer’: CustomerFactory()}
def test_customer_detail(self):
url = reverse(“customer-detail”, args=[self.get_fixture(‘customer’).pk]) self.assertGET(url)
ApiCheckerBase
class drf_api_checker.unittest.ApiCheckerBase[source]

Custom _type_, intended to be used as metaclass. It will create a test for each url defined in URLS in the format test__<normalized_url_path>, if a method with the same name is found the creation is skipped reading this as an intention to have a custom test for that url.

class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase):
URLS = [
reverse(“intervention-list”), reverse(“intervention-detail”, args=[101]),

]

def get_fixtures(cls):
return {‘intervention’: InterventionFactory(id=101),
‘result’: ResultFactory(), }

running this code will produce…

… test_url__api_v2_interventions (etools.applications.partners.tests.test_api.TestAPIIntervention) … ok test_url__api_v2_interventions_101 (etools.applications.partners.tests.test_api.TestAPIIntervention) … ok …

pytest support

@frozenfixture
drf_api_checker.pytest.frozenfixture(fixture_name=<function default_fixture_name>)[source]
@contract
drf_api_checker.pytest.contract(recorder_class=<class 'drf_api_checker.recorder.Recorder'>, allow_empty=False, name=None, method='get', checks=None, debug=False, **kwargs)[source]

Internals

Recorder
class drf_api_checker.recorder.Recorder(data_dir, owner=None, headers_to_check=None, fixture_file=None, as_user=None)[source]
ForeignKeysCollector
class drf_api_checker.collector.ForeignKeysCollector(using)[source]