Skip to content

Commit 00e55ce

Browse files
committed
Merge pull request #1 from apibyexample/txels/unittest-support
Add built-in support for unittest
2 parents ee3992f + adb65f5 commit 00e55ce

File tree

6 files changed

+251
-4
lines changed

6 files changed

+251
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*.pyc
44
.ropeproject
55
*.swp
6+
tags

README.md

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,104 @@
1-
Api By Example for Python
2-
=========================
1+
# Api By Example for Python
32

43
This repository includes tools to be able to use the ABE format from within
54
Python code. In particular we aim to support python tests.
5+
6+
7+
## Support for unittest.TestCase
8+
9+
You can use ABE in your unittests by mixing in `AbeTestMixin`, adding a
10+
class-level attribute `samples_root` that points to a root folder containing
11+
your sample files.
12+
13+
You can then compare how your code behaves re: an ABE sample with a simple
14+
call:
15+
16+
```python
17+
self.assert_matches_sample(
18+
path_to_sample, label, actual_url, actual_response
19+
)
20+
```
21+
22+
23+
Here is a full example:
24+
25+
26+
```python
27+
import os
28+
29+
from abe.unittest import AbeTestMixin
30+
from django.conf import settings
31+
from django.core.urlresolvers import reverse
32+
from rest_framework_jwt.test import APIJWTTestCase as TestCase
33+
34+
from ..factories.accounts import UserFactory, TEST_PASSWORD
35+
36+
37+
class TestMyAccountView(AbeTestMixin, TestCase):
38+
url = reverse('myaccount')
39+
samples_root = os.path.join(settings.BASE_DIR, 'docs', 'api')
40+
41+
def setUp(self):
42+
super(TestMyAccountView, self).setUp()
43+
self.user = UserFactory()
44+
45+
def test_get_my_account_not_logged_in(self):
46+
"""
47+
If I'm not logged in, I can't access any account data
48+
"""
49+
response = self.client.get(self.url)
50+
self.assert_matches_sample(
51+
'accounts/profile.json', 'unauthenticated', self.url, response
52+
)
53+
54+
def test_get_my_account_logged_in(self):
55+
"""
56+
If I'm logged in, I can access any account data
57+
"""
58+
self.client.login(username=self.user.username, password=TEST_PASSWORD)
59+
60+
response = self.client.get(self.url)
61+
62+
self.assert_matches_sample(
63+
'accounts/profile.json', 'OK', self.url, response
64+
)
65+
```
66+
67+
This is the file under `docs/api/accounts/profile.json`:
68+
69+
```json
70+
{
71+
"description": "Retrieve profile of logged in user",
72+
"url": "/accounts/me",
73+
"method": "GET",
74+
"examples": {
75+
"OK": {
76+
"request": {
77+
"url": "/accounts/me"
78+
},
79+
"response": {
80+
"status": 200,
81+
"body": {
82+
"id": 1,
83+
"username": "user-0",
84+
"first_name": "",
85+
"last_name": "",
86+
"email": "[email protected]"
87+
}
88+
}
89+
},
90+
"unauthenticated": {
91+
"description": "I am not logged in",
92+
"request": {
93+
"url": "/accounts/me"
94+
},
95+
"response": {
96+
"status": 403,
97+
"body": {
98+
"detail": "Authentication credentials were not provided."
99+
}
100+
}
101+
}
102+
}
103+
}
104+
```

abe/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.1.0'
1+
__version__ = '0.2.0'

abe/unittest.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from copy import copy
2+
from operator import attrgetter
3+
import os
4+
5+
from .mocks import AbeMock
6+
from .utils import to_unicode
7+
8+
9+
class AbeTestMixin(object):
10+
"""
11+
Mixin for unittest.TestCase to check against API samples.
12+
13+
Example usage:
14+
15+
class TestCase(AbeTestMixin, unittest.TestCase)
16+
...
17+
18+
"""
19+
# Root directory to load samples from.
20+
samples_root = '.'
21+
22+
def load_sample(self, sample_path):
23+
"""
24+
Load a sample file into an AbeMock object.
25+
"""
26+
sample_file = os.path.join(self.samples_root, sample_path)
27+
return AbeMock(sample_file)
28+
29+
def get_sample_request(self, path, label):
30+
"""
31+
Get the request body to send for a specific sample label.
32+
33+
"""
34+
sample = self.load_sample(path)
35+
sample_request = sample.examples[label].request
36+
return sample_request.body
37+
38+
def assert_data_equal(self, data1, data2):
39+
"""
40+
Two elements are recursively equal without taking order into account
41+
"""
42+
try:
43+
if isinstance(data1, list):
44+
self.assertIsInstance(data2, list)
45+
self.assert_data_list_equal(data1, data2)
46+
elif isinstance(data1, dict):
47+
self.assertIsInstance(data2, dict)
48+
self.assert_data_dict_equal(data1, data2)
49+
else:
50+
data1 = to_unicode(data1)
51+
data2 = to_unicode(data2)
52+
self.assertIsInstance(data2, data1.__class__)
53+
self.assertEqual(data1, data2)
54+
except AssertionError as exc:
55+
message = str(exc) + '\n{}\n{}\n\n'.format(data1, data2)
56+
raise type(exc)(message)
57+
58+
def assert_data_dict_equal(self, data1, data2):
59+
"""
60+
Two dicts are recursively equal without taking order into account
61+
"""
62+
self.assertEqual(
63+
len(data1), len(data2),
64+
msg='Number of elements mismatch: {} != {}\n'.format(
65+
data1.keys(), data2.keys())
66+
)
67+
for key in data1:
68+
self.assertIn(key, data2)
69+
self.assert_data_equal(data1[key], data2[key])
70+
71+
def assert_data_list_equal(self, data1, data2):
72+
"""
73+
Two lists are recursively equal without taking order into account
74+
"""
75+
self.assertEqual(
76+
len(data1), len(data2),
77+
msg='Number of elements mismatch: {} {}'.format(
78+
data1, data2)
79+
)
80+
81+
data1_elements = copy(data1)
82+
for element2 in data2:
83+
fails, exceptions, found = [], [], False
84+
while data1_elements and not found:
85+
element = data1_elements.pop()
86+
try:
87+
self.assert_data_equal(element, element2)
88+
found = True
89+
except AssertionError as exc:
90+
exceptions.append(exc)
91+
fails.append(element)
92+
if not data1_elements:
93+
message = '\n*\n'.join(
94+
map(attrgetter('message'), exceptions)
95+
)
96+
raise type(exceptions[0])(message)
97+
data1_elements.extend(fails)
98+
99+
def assert_matches_sample(self, path, label, url, response):
100+
"""
101+
Check a URL and response against a sample.
102+
103+
:param sample_name:
104+
The name of the sample file, e.g. 'query.json'
105+
:param label:
106+
The label for a specific sample request/response, e.g. 'OK'
107+
:param url:
108+
The actual URL we want to compare with the sample
109+
:param response:
110+
The actual API response we want to match with the sample
111+
"""
112+
sample = self.load_sample(path)
113+
sample_request = sample.examples[label].request
114+
sample_response = sample.examples[label].response
115+
116+
self.assertEqual(url, sample_request.url)
117+
self.assertEqual(response.status_code, sample_response.status)
118+
self.assert_data_equal(response.data, sample_response.body)

abe/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from datetime import datetime
2+
import decimal
3+
import sys
4+
5+
_PY3 = sys.version_info >= (3, 0)
6+
7+
8+
def datetime_to_string(value):
9+
representation = value.isoformat()
10+
if value.microsecond:
11+
representation = representation[:23] + representation[26:]
12+
if representation.endswith('+00:00'):
13+
representation = representation[:-6] + 'Z'
14+
return representation
15+
16+
17+
def to_unicode(data):
18+
"""
19+
Ensure that dates, Decimals and strings become unicode
20+
"""
21+
if isinstance(data, datetime):
22+
data = datetime_to_string(data)
23+
elif isinstance(data, decimal.Decimal):
24+
data = str(data)
25+
26+
if not _PY3:
27+
if isinstance(data, str):
28+
data = unicode(data)
29+
return data

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from abe import __version__
66

77
setup(
8-
name='ABE-mocks for python',
8+
name='abe-python',
99
description='Parse ABE files for usage within python tests',
1010
version=__version__,
1111
author='Carles Barrobés',

0 commit comments

Comments
 (0)