Skip to content

Commit 48c777d

Browse files
committed
Support non-strict matching of samples
- assert_matches_sample includes an optional argument to skip matching certain response fields
1 parent 4f3b980 commit 48c777d

7 files changed

Lines changed: 195 additions & 35 deletions

File tree

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ python:
88
install: pip install -r test-requirements.txt
99
script:
1010
- python -m unittest discover || python -m unittest
11-
- flake8 abe
11+
- flake8 abe tests

abe/unittest.py

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22

33
from .mocks import AbeMock
4-
from .utils import to_unicode
4+
from .utils import normalize, subkeys
55

66

77
class AbeTestMixin(object):
@@ -33,53 +33,69 @@ def get_sample_request(self, path, label):
3333
sample_request = sample.examples[label].request
3434
return sample_request.body
3535

36-
def assert_data_equal(self, data1, data2):
36+
def assert_item_matches(self, real, sample):
37+
"""
38+
A primitive value matches the sample.
39+
40+
If the sample represents a parameter, then do simple pattern matching.
41+
42+
"""
43+
real = normalize(real)
44+
sample = normalize(sample)
45+
self.assertEqual(real, sample)
46+
47+
def assert_data_equal(self, real, sample, ignore=None):
3748
"""
3849
Two elements are recursively equal
50+
51+
:param ignore:
52+
Names of fields to ignore
3953
"""
54+
ignore = ignore or []
4055
try:
41-
if isinstance(data1, list):
42-
self.assertIsInstance(data2, list)
43-
self.assert_data_list_equal(data1, data2)
44-
elif isinstance(data1, dict):
45-
self.assertIsInstance(data2, dict)
46-
self.assert_data_dict_equal(data1, data2)
56+
if isinstance(real, list):
57+
self.assertIsInstance(sample, list)
58+
self.assert_data_list_equal(real, sample, ignore)
59+
elif isinstance(real, dict):
60+
self.assertIsInstance(sample, dict)
61+
self.assert_data_dict_equal(real, sample, ignore)
4762
else:
48-
data1 = to_unicode(data1)
49-
data2 = to_unicode(data2)
50-
self.assertIsInstance(data2, data1.__class__)
51-
self.assertEqual(data1, data2)
63+
self.assert_item_matches(real, sample)
5264
except AssertionError as exc:
53-
message = str(exc) + '\n{}\n{}\n\n'.format(data1, data2)
65+
message = str(exc) + '\n{}\n{}\n\n'.format(real, sample)
5466
raise type(exc)(message)
5567

56-
def assert_data_dict_equal(self, data1, data2):
68+
def assert_data_dict_equal(self, real, sample, ignore=None):
5769
"""
5870
Two dicts are recursively equal without taking order into account
5971
"""
72+
ignore = ignore or []
6073
self.assertEqual(
61-
len(data1), len(data2),
74+
len(real), len(sample),
6275
msg='Number of elements mismatch: {} != {}\n'.format(
63-
data1.keys(), data2.keys())
76+
real.keys(), sample.keys())
6477
)
65-
for key in data1:
66-
self.assertIn(key, data2)
67-
self.assert_data_equal(data1[key], data2[key])
78+
for key in real:
79+
if key not in ignore:
80+
self.assertIn(key, sample)
81+
inner_ignore = subkeys(ignore, key)
82+
self.assert_data_equal(real[key], sample[key], inner_ignore)
6883

69-
def assert_data_list_equal(self, data1, data2):
84+
def assert_data_list_equal(self, real, sample, ignore=None):
7085
"""
7186
Two lists are recursively equal, including ordering.
7287
"""
88+
ignore = ignore or []
7389
self.assertEqual(
74-
len(data1), len(data2),
90+
len(real), len(sample),
7591
msg='Number of elements mismatch: {} {}'.format(
76-
data1, data2)
92+
real, sample)
7793
)
7894

7995
exceptions = []
80-
for element, element2 in zip(data1, data2):
96+
for real_item, sample_item in zip(real, sample):
8197
try:
82-
self.assert_data_equal(element, element2)
98+
self.assert_data_equal(real_item, sample_item, ignore)
8399
except AssertionError as exc:
84100
exceptions.append(exc)
85101

@@ -123,16 +139,19 @@ def assert_matches_request(self, sample_request, wsgi_request):
123139
if 'body' in sample_request:
124140
self.assert_data_equal(wsgi_request.POST, sample_request['body'])
125141

126-
def assert_matches_response(self, sample_response, wsgi_response):
142+
def assert_matches_response(self, sample_response, wsgi_response,
143+
ignore=None):
127144
"""
128145
Check that the sample response and wsgi response match.
129146
"""
147+
ignore = ignore or []
130148
self.assertEqual(wsgi_response.status_code, sample_response.status)
131149
if 'body' in sample_response:
132150
response_parsed = wsgi_response.data
133-
self.assert_data_equal(response_parsed, sample_response.body)
151+
self.assert_data_equal(
152+
response_parsed, sample_response.body, ignore)
134153

135-
def assert_matches_sample(self, path, label, response):
154+
def assert_matches_sample(self, path, label, response, ignore=None):
136155
"""
137156
Check a URL and response against a sample.
138157
@@ -143,10 +162,15 @@ def assert_matches_sample(self, path, label, response):
143162
:param response:
144163
The actual API response we want to match with the sample.
145164
It is assumed to be a Django Rest Framework response object
165+
:param ignore:
166+
List of fields that will not be checked for strict matching.
167+
You can use this to include server-generated fields whose exact
168+
value you don't care about in your test, like ids, dates, etc.
146169
"""
170+
ignore = ignore or []
147171
sample = self.load_sample(path)
148172
sample_request = sample.examples[label].request
149173
sample_response = sample.examples[label].response
150174

151-
self.assert_matches_response(sample_response, response)
175+
self.assert_matches_response(sample_response, response, ignore=ignore)
152176
self.assert_matches_request(sample_request, response.wsgi_request)

abe/utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,23 @@ def datetime_to_string(value):
1616
return representation
1717

1818

19-
def to_unicode(data):
19+
def normalize(data):
2020
"""
2121
Ensure that dates, Decimals and strings become unicode
22+
23+
Integers, on the other hand, are not converted.
2224
"""
2325
if isinstance(data, datetime):
2426
data = datetime_to_string(data)
25-
else:
27+
elif not isinstance(data, int):
2628
data = str(data)
2729

2830
if not _PY3 and isinstance(data, str):
2931
data = unicode(data)
3032
return data
33+
34+
35+
def subkeys(ignore, key):
36+
new_keys = filter(lambda s: s.startswith(key + '.'), ignore)
37+
new_keys = map(lambda s: s[len(key) + 1:], new_keys)
38+
return new_keys

ep.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ dependencies:
44
version: ">=2.6.0"
55
file: test-requirements.txt
66
run:
7-
- flake8 abe
7+
- flake8 abe tests
88
- coverage erase
99
- coverage run --omit="*/tests/*","*$VIRTUAL_ENV*" -m unittest discover
1010
- coverage html

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
flake8
44
coverage
5+
mock

tests/test_assertions.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,35 @@
1010
DATA_DIR = join(dirname(abspath(__file__)), 'data')
1111

1212

13-
class TestDataListEqual(TestCase, AbeTestMixin):
13+
class TestAssertDataEqual(TestCase, AbeTestMixin):
14+
15+
def test_int_value_matches(self):
16+
self.assert_data_equal(3, 3)
17+
18+
def test_int_value_mismatch(self):
19+
self.assertRaises(
20+
AssertionError,
21+
self.assert_data_equal, 3, 33
22+
)
23+
24+
def test_int_vs_string_value_mismatch(self):
25+
self.assertRaises(
26+
AssertionError,
27+
self.assert_data_equal, 3, "3"
28+
)
29+
30+
def test_string_value_matches(self):
31+
self.assert_data_equal("hello", "hello")
32+
33+
def test_string_value_mismatch(self):
34+
self.assertRaises(
35+
AssertionError,
36+
self.assert_data_equal, "hell", "hello"
37+
)
1438

1539

40+
class TestDataListEqual(TestCase, AbeTestMixin):
41+
1642
def test_simple_list_equality(self):
1743
self.assert_data_list_equal([1, 2], [1, 2])
1844

@@ -141,6 +167,98 @@ def test_assertion_error_if_post_data_mismatch(self):
141167
)
142168

143169

170+
class TestAssertMatchesResponse(TestCase, AbeTestMixin):
171+
172+
def setUp(self):
173+
abe_mock = AbeMock({
174+
"method": "POST",
175+
"url": "/resource/",
176+
"examples": {
177+
"OK": {
178+
"response": {
179+
"status": 201,
180+
"body": {
181+
"id": 12,
182+
"name": "My Resource",
183+
"url": "http://example.com/resource/12",
184+
"author": {
185+
"name": "Slough",
186+
"url": "http://example.com/user/25"
187+
}
188+
}
189+
}
190+
}
191+
}
192+
})
193+
self.sample_response = abe_mock.examples['OK'].response
194+
195+
def test_response_matches_strictly(self):
196+
response = Mock()
197+
response.status_code = 201
198+
response.data = {
199+
"id": 12,
200+
"name": "My Resource",
201+
"url": "http://example.com/resource/12",
202+
"author": {
203+
"name": "Slough",
204+
"url": "http://example.com/user/25"
205+
}
206+
}
207+
208+
self.assert_matches_response(
209+
self.sample_response, response
210+
)
211+
212+
def test_non_strict_response_matches(self):
213+
response = Mock()
214+
response.status_code = 201
215+
response.data = {
216+
"id": 25,
217+
"name": "My Resource",
218+
"url": "http://example.com/resource/12312",
219+
"author": {
220+
"name": "Slough",
221+
"url": "http://testserver/25/"
222+
}
223+
}
224+
225+
self.assert_matches_response(
226+
self.sample_response, response, ignore=['id', 'url', 'author.url']
227+
)
228+
229+
def test_non_strict_list_value_matches(self):
230+
abe_mock = AbeMock({
231+
"url": "/resource/",
232+
"method": "GET",
233+
"examples": {
234+
"OK": {
235+
"response": {
236+
"status": 200,
237+
"body": {
238+
"contributors": [
239+
{"name": "Jack", "id": 1},
240+
{"name": "Jill", "id": 2},
241+
]
242+
}
243+
}
244+
}
245+
}
246+
})
247+
sample = abe_mock.examples['OK'].response
248+
response = Mock()
249+
response.status_code = 200
250+
response.data = {
251+
"contributors": [
252+
{"name": "Jack", "id": 12},
253+
{"name": "Jill", "id": 23},
254+
]
255+
}
256+
257+
self.assert_matches_response(
258+
sample, response, ignore=['contributors.id']
259+
)
260+
261+
144262
class TestFilenameInstantiation(TestCase):
145263

146264
def setUp(self):
@@ -150,10 +268,10 @@ def test_can_still_use_deprecated_instantiation(self):
150268
with warnings.catch_warnings(record=True) as w:
151269
warnings.simplefilter("always")
152270

153-
mock = AbeMock(self.filename)
271+
AbeMock(self.filename)
154272

155273
self.assertEqual(len(w), 1)
156274
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
157275

158276
def test_from_filename(self):
159-
mock = AbeMock.from_filename(self.filename)
277+
AbeMock.from_filename(self.filename)

tests/test_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from unittest import TestCase
2+
3+
from abe.utils import subkeys
4+
5+
6+
class TestSubkeys(TestCase):
7+
def test_subkeys(self):
8+
new_keys = subkeys(['key.one', 'key.two', 'hello', 'keyring'], 'key')
9+
self.assertEqual(new_keys, ['one', 'two'])

0 commit comments

Comments
 (0)