Having integrated unit tests that cover your API's behavior is important, as it helps provide verification that your API code is still valid & working correctly with the rest of your application.
Tastypie provides some basic facilities that build on top of Django's testing support, in the form of a specialized TestApiClient
& ResourceTestCase
.
The ResourceTestCase
builds on top of Django's TestCase
. It provides quite a few extra assertion methods that are specific to APIs. Under the hood, it uses the TestApiClient
to perform requests properly.
The TestApiClient
builds on & exposes an interface similar to that of Django's Client
. However, under the hood, it hands all the setup needed to construct a proper request.
The typical use case will primarily consist of subclassing the ResourceTestCase
class & using the built-in assertions to ensure your API is behaving correctly. For the purposes of this example, we'll assume the resource in question looks like:
from tastypie.authentication import BasicAuthentication
from tastypie.resources import ModelResource
from entries.models import Entry
class EntryResource(ModelResource):
class Meta:
queryset = Entry.objects.all()
authentication = BasicAuthentication()
An example usage might look like:
import datetime
from django.contrib.auth.models import User
from tastypie.test import ResourceTestCase
from entries.models import Entry
class EntryResourceTest(ResourceTestCase):
# Use ``fixtures`` & ``urls`` as normal. See Django's ``TestCase``
# documentation for the gory details.
fixtures = ['test_entries.json']
def setUp(self):
super(EntryResourceTest, self).setUp()
# Create a user.
self.username = 'daniel'
self.password = 'pass'
self.user = User.objects.create_user(self.username, 'daniel@example.com', self.password)
# Fetch the ``Entry`` object we'll use in testing.
# Note that we aren't using PKs because they can change depending
# on what other tests are running.
self.entry_1 = Entry.objects.get(slug='first-post')
# We also build a detail URI, since we will be using it all over.
# DRY, baby. DRY.
self.detail_url = '/api/v1/entry/{0}/'.format(self.entry_1.pk)
# The data we'll send on POST requests. Again, because we'll use it
# frequently (enough).
self.post_data = {
'user': '/api/v1/user/{0}/'.format(self.user.pk),
'title': 'Second Post!',
'slug': 'second-post',
'created': '2012-05-01T22:05:12'
}
def get_credentials(self):
return self.create_basic(username=self.username, password=self.password)
def test_get_list_unauthorzied(self):
self.assertHttpUnauthorized(self.api_client.get('/api/v1/entries/', format='json'))
def test_get_list_json(self):
resp = self.api_client.get('/api/v1/entries/', format='json', authentication=self.get_credentials())
self.assertValidJSONResponse(resp)
# Scope out the data for correctness.
self.assertEqual(len(self.deserialize(resp)['objects']), 12)
# Here, we're checking an entire structure for the expected data.
self.assertEqual(self.deserialize(resp)['objects'][0], {
'pk': str(self.entry_1.pk),
'user': '/api/v1/user/{0}/'.format(self.user.pk),
'title': 'First post',
'slug': 'first-post',
'created': '2012-05-01T19:13:42',
'resource_uri': '/api/v1/entry/{0}/'.format(self.entry_1.pk)
})
def test_get_list_xml(self):
self.assertValidXMLResponse(self.api_client.get('/api/v1/entries/', format='xml', authentication=self.get_credentials()))
def test_get_detail_unauthenticated(self):
self.assertHttpUnauthorized(self.api_client.get(self.detail_url, format='json'))
def test_get_detail_json(self):
resp = self.api_client.get(self.detail_url, format='json', authentication=self.get_credentials())
self.assertValidJSONResponse(resp)
# We use ``assertKeys`` here to just verify the keys, not all the data.
self.assertKeys(self.deserialize(resp), ['created', 'slug', 'title', 'user'])
self.assertEqual(self.deserialize(resp)['name'], 'First post')
def test_get_detail_xml(self):
self.assertValidXMLResponse(self.api_client.get(self.detail_url, format='xml', authentication=self.get_credentials()))
def test_post_list_unauthenticated(self):
self.assertHttpUnauthorized(self.api_client.post('/api/v1/entries/', format='json', data=self.post_data))
def test_post_list(self):
# Check how many are there first.
self.assertEqual(Entry.objects.count(), 5)
self.assertHttpCreated(self.api_client.post('/api/v1/entries/', format='json', data=self.post_data, authentication=self.get_credentials()))
# Verify a new one has been added.
self.assertEqual(Entry.objects.count(), 6)
def test_put_detail_unauthenticated(self):
self.assertHttpUnauthorized(self.api_client.put(self.detail_url, format='json', data={}))
def test_put_detail(self):
# Grab the current data & modify it slightly.
original_data = self.deserialize(self.api_client.get(self.detail_url, format='json', authentication=self.get_credentials()))
new_data = original_data.copy()
new_data['title'] = 'Updated: First Post'
new_data['created'] = '2012-05-01T20:06:12'
self.assertEqual(Entry.objects.count(), 5)
self.assertHttpAccepted(self.api_client.put(self.detail_url, format='json', data=new_data, authentication=self.get_credentials()))
# Make sure the count hasn't changed & we did an update.
self.assertEqual(Entry.objects.count(), 5)
# Check for updated data.
self.assertEqual(Entry.objects.get(pk=25).title, 'Updated: First Post')
self.assertEqual(Entry.objects.get(pk=25).slug, 'first-post')
self.assertEqual(Entry.objects.get(pk=25).created, datetime.datetime(2012, 3, 1, 13, 6, 12))
def test_delete_detail_unauthenticated(self):
self.assertHttpUnauthorized(self.api_client.delete(self.detail_url, format='json'))
def test_delete_detail(self):
self.assertEqual(Entry.objects.count(), 5)
self.assertHttpAccepted(self.api_client.delete(self.detail_url, format='json', authentication=self.get_credentials()))
self.assertEqual(Entry.objects.count(), 4)
Note that this example doesn't cover other cases, such as filtering, PUT
to a list endpoint, DELETE
to a list endpoint, PATCH
support, etc.
The ResourceTestCase
exposes the following methods for use. Most are enhanced assertions or provide API-specific behaviors.
ResourceTestCase.get_credentials(self)
A convenience method for the user as a way to shorten up the often repetitious calls to create the same authentication.
Raises NotImplementedError
by default.
Usage:
class MyResourceTestCase(ResourceTestCase):
def get_credentials(self):
return self.create_basic('daniel', 'pass')
# Then the usual tests...
ResourceTestCase.create_basic(self, username, password)
Creates & returns the HTTP Authorization
header for use with BASIC Auth.
ResourceTestCase.create_apikey(self, username, api_key)
Creates & returns the HTTP Authorization
header for use with ApiKeyAuthentication
.
ResourceTestCase.create_digest(self, username, api_key, method, uri)
Creates & returns the HTTP Authorization
header for use with Digest Auth.
ResourceTestCase.create_oauth(self, user)
Creates & returns the HTTP Authorization
header for use with Oauth.
ResourceTestCase.assertHttpOK(self, resp)
Ensures the response is returning a HTTP 200.
ResourceTestCase.assertHttpCreated(self, resp)
Ensures the response is returning a HTTP 201.
ResourceTestCase.assertHttpAccepted(self, resp)
Ensures the response is returning either a HTTP 202 or a HTTP 204.
ResourceTestCase.assertHttpMultipleChoices(self, resp)
Ensures the response is returning a HTTP 300.
ResourceTestCase.assertHttpSeeOther(self, resp)
Ensures the response is returning a HTTP 303.
ResourceTestCase.assertHttpNotModified(self, resp)
Ensures the response is returning a HTTP 304.
ResourceTestCase.assertHttpBadRequest(self, resp)
Ensures the response is returning a HTTP 400.
ResourceTestCase.assertHttpUnauthorized(self, resp)
Ensures the response is returning a HTTP 401.
ResourceTestCase.assertHttpForbidden(self, resp)
Ensures the response is returning a HTTP 403.
ResourceTestCase.assertHttpNotFound(self, resp)
Ensures the response is returning a HTTP 404.
ResourceTestCase.assertHttpMethodNotAllowed(self, resp)
Ensures the response is returning a HTTP 405.
ResourceTestCase.assertHttpConflict(self, resp)
Ensures the response is returning a HTTP 409.
ResourceTestCase.assertHttpGone(self, resp)
Ensures the response is returning a HTTP 410.
ResourceTestCase.assertHttpTooManyRequests(self, resp)
Ensures the response is returning a HTTP 429.
ResourceTestCase.assertHttpApplicationError(self, resp)
Ensures the response is returning a HTTP 500.
ResourceTestCase.assertHttpNotImplemented(self, resp)
Ensures the response is returning a HTTP 501.
ResourceTestCase.assertValidJSON(self, data)
Given the provided data
as a string, ensures that it is valid JSON & can be loaded properly.
ResourceTestCase.assertValidXML(self, data)
Given the provided data
as a string, ensures that it is valid XML & can be loaded properly.
ResourceTestCase.assertValidYAML(self, data)
Given the provided data
as a string, ensures that it is valid YAML & can be loaded properly.
ResourceTestCase.assertValidPlist(self, data)
Given the provided data
as a string, ensures that it is valid binary plist & can be loaded properly.
ResourceTestCase.assertValidJSONResponse(self, resp)
Given a HttpResponse
coming back from using the client
, assert that you get back:
- An HTTP 200
- The correct content-type (
application/json
) - The content is valid JSON
ResourceTestCase.assertValidXMLResponse(self, resp)
Given a HttpResponse
coming back from using the client
, assert that you get back:
- An HTTP 200
- The correct content-type (
application/xml
) - The content is valid XML
ResourceTestCase.assertValidYAMLResponse(self, resp)
Given a HttpResponse
coming back from using the client
, assert that you get back:
- An HTTP 200
- The correct content-type (
text/yaml
) - The content is valid YAML
ResourceTestCase.assertValidPlistResponse(self, resp)
Given a HttpResponse
coming back from using the client
, assert that you get back:
- An HTTP 200
- The correct content-type (
application/x-plist
) - The content is valid binary plist data
ResourceTestCase.deserialize(self, resp)
Given a HttpResponse
coming back from using the client
, this method checks the Content-Type
header & attempts to deserialize the data based on that.
It returns a Python datastructure (typically a dict
) of the serialized data.
ResourceTestCase.serialize(self, data, format='application/json')
Given a Python datastructure (typically a dict
) & a desired content-type, this method will return a serialized string of that data.
ResourceTestCase.assertKeys(self, data, expected)
This method ensures that the keys of the data
match up to the keys of expected
.
It covers the (extremely) common case where you want to make sure the keys of a response match up to what is expected. This is typically less fragile than testing the full structure, which can be prone to data changes.
The TestApiClient
simulates a HTTP client making calls to the API. It's important to note that it uses Django's testing infrastructure, so it's not making actual calls against a webserver.
TestApiClient.__init(self, serializer=None)
Sets up a fresh TestApiClient
instance.
If you are employing a custom serializer, you can pass the class to the serializer=
kwarg.
TestApiClient.get_content_type(self, short_format)
Given a short name (such as json
or xml
), returns the full content-type for it (application/json
or application/xml
in this case).
TestApiClient.get(self, uri, format='json', data=None, authentication=None, **kwargs)
Performs a simulated GET
request to the provided URI.
Optionally accepts a data
kwarg, which in the case of GET
, lets you send along GET
parameters. This is useful when testing filtering or other things that read off the GET
params. Example:
from tastypie.test import TestApiClient
client = TestApiClient()
response = client.get('/api/v1/entry/1/', data={'format': 'json', 'title__startswith': 'a', 'limit': 20, 'offset': 60})
Optionally accepts an authentication
kwarg, which should be an HTTP header with the correct authentication data already setup.
All other **kwargs
passed in get passed through to the Django TestClient
. See https://docs.djangoproject.com/en/dev/topics/testing/#module-django.test.client for details.
TestApiClient.post(self, uri, format='json', data=None, authentication=None, **kwargs)
Performs a simulated POST
request to the provided URI.
Optionally accepts a data
kwarg. Unlike GET
, in POST
the data
gets serialized & sent as the body instead of becoming part of the URI. Example:
from tastypie.test import TestApiClient
client = TestApiClient()
response = client.post('/api/v1/entry/', data={
'created': '2012-05-01T20:02:36',
'slug': 'another-post',
'title': 'Another Post',
'user': '/api/v1/user/1/',
})
Optionally accepts an authentication
kwarg, which should be an HTTP header with the correct authentication data already setup.
All other **kwargs
passed in get passed through to the Django TestClient
. See https://docs.djangoproject.com/en/dev/topics/testing/#module-django.test.client for details.
TestApiClient.put(self, uri, format='json', data=None, authentication=None, **kwargs)
Performs a simulated PUT
request to the provided URI.
Optionally accepts a data
kwarg. Unlike GET
, in PUT
the data
gets serialized & sent as the body instead of becoming part of the URI. Example:
from tastypie.test import TestApiClient
client = TestApiClient()
response = client.put('/api/v1/entry/1/', data={
'created': '2012-05-01T20:02:36',
'slug': 'another-post',
'title': 'Another Post',
'user': '/api/v1/user/1/',
})
Optionally accepts an authentication
kwarg, which should be an HTTP header with the correct authentication data already setup.
All other **kwargs
passed in get passed through to the Django TestClient
. See https://docs.djangoproject.com/en/dev/topics/testing/#module-django.test.client for details.
TestApiClient.patch(self, uri, format='json', data=None, authentication=None, **kwargs)
Performs a simulated PATCH
request to the provided URI.
Optionally accepts a data
kwarg. Unlike GET
, in PATCH
the data
gets serialized & sent as the body instead of becoming part of the URI. Example:
from tastypie.test import TestApiClient
client = TestApiClient()
response = client.patch('/api/v1/entry/1/', data={
'created': '2012-05-01T20:02:36',
'slug': 'another-post',
'title': 'Another Post',
'user': '/api/v1/user/1/',
})
Optionally accepts an authentication
kwarg, which should be an HTTP header with the correct authentication data already setup.
All other **kwargs
passed in get passed through to the Django TestClient
. See https://docs.djangoproject.com/en/dev/topics/testing/#module-django.test.client for details.
TestApiClient.delete(self, uri, format='json', data=None, authentication=None, **kwargs)
Performs a simulated DELETE
request to the provided URI.
Optionally accepts a data
kwarg, which in the case of DELETE
, lets you send along DELETE
parameters. This is useful when testing filtering or other things that read off the DELETE
params. Example:
from tastypie.test import TestApiClient
client = TestApiClient()
response = client.delete('/api/v1/entry/1/', data={'format': 'json'})
Optionally accepts an authentication
kwarg, which should be an HTTP header with the correct authentication data already setup.
All other **kwargs
passed in get passed through to the Django TestClient
. See https://docs.djangoproject.com/en/dev/topics/testing/#module-django.test.client for details.