Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
*.pyc
.ropeproject
*.swp
tags
103 changes: 101 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,104 @@
Api By Example for Python
=========================
# Api By Example for Python

This repository includes tools to be able to use the ABE format from within
Python code. In particular we aim to support python tests.


## Support for unittest.TestCase

You can use ABE in your unittests by mixing in `AbeTestMixin`, adding a
class-level attribute `samples_root` that points to a root folder containing
your sample files.

You can then compare how your code behaves re: an ABE sample with a simple
call:

```python
self.assert_matches_sample(
path_to_sample, label, actual_url, actual_response
)
```


Here is a full example:


```python
import os

from abe.unittest import AbeTestMixin
from django.conf import settings
from django.core.urlresolvers import reverse
from rest_framework_jwt.test import APIJWTTestCase as TestCase

from ..factories.accounts import UserFactory, TEST_PASSWORD


class TestMyAccountView(AbeTestMixin, TestCase):
url = reverse('myaccount')
samples_root = os.path.join(settings.BASE_DIR, 'docs', 'api')

def setUp(self):
super(TestMyAccountView, self).setUp()
self.user = UserFactory()

def test_get_my_account_not_logged_in(self):
"""
If I'm not logged in, I can't access any account data
"""
response = self.client.get(self.url)
self.assert_matches_sample(
'accounts/profile.json', 'unauthenticated', self.url, response
)

def test_get_my_account_logged_in(self):
"""
If I'm logged in, I can access any account data
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)

response = self.client.get(self.url)

self.assert_matches_sample(
'accounts/profile.json', 'OK', self.url, response
)
```

This is the file under `docs/api/accounts/profile.json`:

```json
{
"description": "Retrieve profile of logged in user",
"url": "/accounts/me",
"method": "GET",
"examples": {
"OK": {
"request": {
"url": "/accounts/me"
},
"response": {
"status": 200,
"body": {
"id": 1,
"username": "user-0",
"first_name": "",
"last_name": "",
"email": "user-0@example.com"
}
}
},
"unauthenticated": {
"description": "I am not logged in",
"request": {
"url": "/accounts/me"
},
"response": {
"status": 403,
"body": {
"detail": "Authentication credentials were not provided."
}
}
}
}
}
```
2 changes: 1 addition & 1 deletion abe/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.1.0'
__version__ = '0.2.0'
118 changes: 118 additions & 0 deletions abe/unittest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from copy import copy
from operator import attrgetter
import os

from .mocks import AbeMock
from .utils import to_unicode


class AbeTestMixin(object):
"""
Mixin for unittest.TestCase to check against API samples.

Example usage:

class TestCase(AbeTestMixin, unittest.TestCase)
...

"""
# Root directory to load samples from.
samples_root = '.'

def load_sample(self, sample_path):
"""
Load a sample file into an AbeMock object.
"""
sample_file = os.path.join(self.samples_root, sample_path)
return AbeMock(sample_file)

def get_sample_request(self, path, label):
"""
Get the request body to send for a specific sample label.

"""
sample = self.load_sample(path)
sample_request = sample.examples[label].request
return sample_request.body

def assert_data_equal(self, data1, data2):
"""
Two elements are recursively equal without taking order into account
"""
try:
if isinstance(data1, list):
self.assertIsInstance(data2, list)
self.assert_data_list_equal(data1, data2)
elif isinstance(data1, dict):
self.assertIsInstance(data2, dict)
self.assert_data_dict_equal(data1, data2)
else:
data1 = to_unicode(data1)
data2 = to_unicode(data2)
self.assertIsInstance(data2, data1.__class__)
self.assertEqual(data1, data2)
except AssertionError as exc:
message = str(exc) + '\n{}\n{}\n\n'.format(data1, data2)
raise type(exc)(message)

def assert_data_dict_equal(self, data1, data2):
"""
Two dicts are recursively equal without taking order into account
"""
self.assertEqual(
len(data1), len(data2),
msg='Number of elements mismatch: {} != {}\n'.format(
data1.keys(), data2.keys())
)
for key in data1:
self.assertIn(key, data2)
self.assert_data_equal(data1[key], data2[key])

def assert_data_list_equal(self, data1, data2):
"""
Two lists are recursively equal without taking order into account
"""
self.assertEqual(
len(data1), len(data2),
msg='Number of elements mismatch: {} {}'.format(
data1, data2)
)

data1_elements = copy(data1)
for element2 in data2:
fails, exceptions, found = [], [], False
while data1_elements and not found:
element = data1_elements.pop()
try:
self.assert_data_equal(element, element2)
found = True
except AssertionError as exc:
exceptions.append(exc)
fails.append(element)
if not data1_elements:
message = '\n*\n'.join(
map(attrgetter('message'), exceptions)
)
raise type(exceptions[0])(message)
data1_elements.extend(fails)

def assert_matches_sample(self, path, label, url, response):
"""
Check a URL and response against a sample.

:param sample_name:
The name of the sample file, e.g. 'query.json'
:param label:
The label for a specific sample request/response, e.g. 'OK'
:param url:
The actual URL we want to compare with the sample
:param response:
The actual API response we want to match with the sample
"""
sample = self.load_sample(path)
sample_request = sample.examples[label].request
sample_response = sample.examples[label].response

self.assertEqual(url, sample_request.url)
self.assertEqual(response.status_code, sample_response.status)
self.assert_data_equal(response.data, sample_response.body)
29 changes: 29 additions & 0 deletions abe/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime
import decimal
import sys

_PY3 = sys.version_info >= (3, 0)


def datetime_to_string(value):
representation = value.isoformat()
if value.microsecond:
representation = representation[:23] + representation[26:]
if representation.endswith('+00:00'):
representation = representation[:-6] + 'Z'
return representation


def to_unicode(data):
"""
Ensure that dates, Decimals and strings become unicode
"""
if isinstance(data, datetime):
data = datetime_to_string(data)
elif isinstance(data, decimal.Decimal):
data = str(data)

if not _PY3:
if isinstance(data, str):
data = unicode(data)
return data
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from abe import __version__

setup(
name='ABE-mocks for python',
name='abe-python',
description='Parse ABE files for usage within python tests',
version=__version__,
author='Carles Barrobés',
Expand Down