Skip to content

Commit 497bd2d

Browse files
authored
Merge pull request #15 from ShipChain/feature/assertion-helper
Pytest assertion fixture
2 parents 474cafb + 5a25f94 commit 497bd2d

File tree

10 files changed

+1498
-106
lines changed

10 files changed

+1498
-106
lines changed

.bandit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[bandit]
22
targets: src/
3-
exclude: src/shipchain_common/test_utils.py
3+
exclude: src/shipchain_common/test_utils/

README.md

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,186 @@
1-
# python-common
1+
# Shipchain Common Python Library
2+
23
A PyPI package containing shared code for ShipChain's Python/Django projects
4+
5+
6+
## Pytest Fixtures
7+
8+
When shipchain-common is installed, a pytest plugin named `json_asserter` is automatically registered. This plugin is
9+
designed for writing concise pytest cases that make json_asserter about responses from a Django Rest Framework API. Most
10+
of the functionality is tailored to the `application/vnd.api+json` response type, but should still be usable for
11+
plain `application/json` responses.
12+
13+
### json_asserter Fixture
14+
15+
The `json_asserter` fixture exposes several methods for testing specific HTTP Status codes as well as a class for
16+
building consistent entity references that must be found within the responses.
17+
18+
#### Usage with application/vnd.api+json
19+
20+
This is the default when utilizing the `json_asserter`. If the response does not conform to the
21+
[JSON Api standard](https://jsonapi.org/), the assertions will fail.
22+
23+
##### Asserting Error Responses
24+
25+
To assert that a given response must have an error status, there are several 400-level response methods. With the
26+
exception of the HTTP_400 method, each of these include the default error message for ease of use.
27+
28+
The following will assert that the response status was 403 and that the default error message ("You do not have
29+
permission to perform this action") is present.
30+
31+
```python
32+
response = api_client.get(self.detail_url)
33+
json_asserter.HTTP_403(response)
34+
```
35+
36+
If a different error message should exist, or when checking the error of a 400 response, the specific error may
37+
be provided as an argument.
38+
39+
```python
40+
response = api_client.get(self.detail_url)
41+
json_asserter.HTTP_400(response, error='Specific error message that should be in the respose')
42+
```
43+
44+
##### Asserting Successful Responses
45+
46+
To assert that a given response must have status 200, call the HTTP_200 method with only the Response object:
47+
48+
```python
49+
response = api_client.get(self.detail_url)
50+
json_asserter.HTTP_200(response)
51+
```
52+
53+
While this is valid, it is **very strongly** recommended to include additional details about the data present in the
54+
response. There are two ways to provide the data; however only one way can be used at a time in a given invocation.
55+
56+
###### Simple Usage
57+
58+
For simple responses, the easiest way to specify required data in the responses is by directly specifying the
59+
Resource Type `resource`, the Resource Identifier `pk`, as well as any specific Attributes of the resource
60+
`attributes`.
61+
62+
```python
63+
response = api_client.get(self.detail_url)
64+
json_asserter.HTTP_200(response,
65+
resource='User',
66+
pk='4b56399d-3155-4fe5-ba4a-9718289a78b7',
67+
attributes={'username': 'example_user'})
68+
```
69+
70+
This will throw an assertion if the response is not for the resource type `User` with id
71+
`4b56399d-3155-4fe5-ba4a-9718289a78b7` and with _at least_ the attribute username `example_user`. If the response
72+
includes _additional_ attributes that are not listed in the call to the json_asserter method, they are ignored. The
73+
methods check partial objects and do not require that every attribute in the response must be defined in the
74+
assertion.
75+
76+
It is also possible to assert only on the resource type and id without providing attributes. This is useful if you
77+
are testing a response that generates content for the fields that may not be known prior to obtaining the response.
78+
Additionally, providing only the attributes and not the type and id will check only that an object in the response
79+
has those attributes, regardless of resource type or id.
80+
81+
###### Advanced Usage
82+
83+
For responses where the associated Relationship and any extra Included resources are important, those can be included
84+
in the assertion.
85+
86+
```python
87+
response = api_client.get(self.detail_url)
88+
json_asserter.HTTP_200(response,
89+
entity_refs=json_asserter.EntityRef(
90+
resource='User',
91+
pk='4b56399d-3155-4fe5-ba4a-9718289a78b7',
92+
attributes={'username': 'example_user'},
93+
relationships={
94+
'manager': json_asserter.EntityRef(
95+
resource='User',
96+
pk='88e38305-9775-4b34-95d0-4e935bb7156c')}),
97+
included=json_asserter.EntityRef(
98+
resource='User',
99+
pk='88e38305-9775-4b34-95d0-4e935bb7156c',
100+
attributes={'username': 'manager_user'}))
101+
```
102+
103+
This requires the same original record in the response, but now also requires that there be _at least_ one relationship
104+
named `manager` with the associated User and that User must be present (with at least the one attribute) in the
105+
`included` property of the response.
106+
107+
The above example utilizes the `EntityRef` exposed via the `json_asserter` fixture. This is a reference to a single
108+
entity defined by a combination of: ResourceType, ResourceID, Attributes, and Relationships. When providing the
109+
`entity_refs` argument to an assertion, you cannot provide any of the following arguments to the assertion directly:
110+
`resource`, `pk`, `attributes`, or `relationships`.
111+
112+
When providing `included` json_asserter, you can provide either a single EntityRef or a list of EntityRef instances. If
113+
a list is provided, _all_ referenced entities must be present in the `included` property of the response. As they do
114+
for the simple usage above, The same assertion rules apply here regarding providing a combination of `resource`,
115+
`pk`, and `attributes`.
116+
117+
The `entity_refs` parameter can be a list of EntityRef instances as well. However, this is only valid for List
118+
responses. If a list of entity_refs is provided for a non-list response, an assertion will occur. To assert that a
119+
response is a list, the parameter `is_list=True` must be provided. You can provide either a single EntityRef or a
120+
list of EntityRef instances. If a list is provided, _all_ referenced entities must be present in the list of
121+
returned data.
122+
123+
#### Usage with application/json
124+
125+
Support is included for making assertions on plain JSON responses with `json_asserter`. To ignore the JSON API specific
126+
assertions, you must provide the `vnd=False` parameter. Only the `attributes` parameter is valid as there are no
127+
relationships or included properties in a plain json response.
128+
129+
Given this response:
130+
131+
```json
132+
{
133+
"id": "07b374c3-ed9b-4811-901a-d0c5d746f16a",
134+
"name": "example 1",
135+
"field_1": 1,
136+
"owner": {
137+
"username": "user1"
138+
}
139+
}
140+
```
141+
142+
Asserting the top level attributes as well as nested attributes is possible using the following call:
143+
144+
```python
145+
response = api_client.get(self.detail_url)
146+
json_asserter.HTTP_200(response,
147+
vnd=False,
148+
attributes={
149+
'id': '07b374c3-ed9b-4811-901a-d0c5d746f16a',
150+
'owner': {
151+
'username': 'user1'
152+
}
153+
})
154+
```
155+
156+
For a list response:
157+
158+
```json
159+
[{
160+
"username": "user1",
161+
"is_active": False
162+
},
163+
{
164+
"username": "user2",
165+
"is_active": False
166+
},
167+
{
168+
"username": "user3",
169+
"is_active": False
170+
}]
171+
```
172+
173+
It is possible to assert that one or many sets of attributes exist in the response:
174+
```python
175+
response = api_client.get(self.detail_url)
176+
json_asserter.HTTP_200(response,
177+
vnd=False,
178+
is_list=True,
179+
attributes=[{
180+
"username": "user1",
181+
"is_active": False
182+
}, {
183+
"username": "user3",
184+
"is_active": False
185+
}])
186+
```

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ pytest-cov = "^2.6"
4343
pytest-django = "^3.6"
4444
pytest-mock = "^1.10"
4545
safety = "^1.8"
46+
47+
[tool.poetry.plugins."pytest11"]
48+
"json_asserter" = "shipchain_common.test_utils.json_asserter"

src/shipchain_common/renderers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ def _get_resource_name(context, expand_polymorphic_types=False):
2121
if original_view_resource_name:
2222
raise AttributeError
2323

24+
# If view is not a ConfigurableGenericViewSet, don't try to get configurable serializer
25+
from .viewsets import ConfigurableGenericViewSet
26+
if not isinstance(view, ConfigurableGenericViewSet):
27+
raise AttributeError
28+
2429
serializer = view.get_serializer_class(
2530
serialization_type=SerializationType.RESPONSE if is_response else SerializationType.REQUEST)
2631

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# flake8: noqa
2+
"""
3+
Copyright 2019 ShipChain, Inc.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
"""
17+
18+
from .json_asserter import \
19+
json_asserter, \
20+
AssertionHelper
21+
22+
from .helpers import\
23+
create_form_content, \
24+
datetimeAlmostEqual, \
25+
get_jwt, \
26+
generate_vnd_json, \
27+
random_location,\
28+
random_timestamp, \
29+
replace_variables_in_string, \
30+
GeoCoderResponse
31+
32+
from .mocked_rpc_responses import \
33+
mocked_rpc_response, \
34+
mocked_wallet_error_creation,\
35+
mocked_wallet_invalid_creation, \
36+
mocked_wallet_valid_creation, \
37+
second_mocked_wallet_valid_creation,\
38+
invalid_eth_amount, \
39+
invalid_ship_amount, \
40+
valid_eth_amount
41+
42+
from ..utils import validate_uuid4 # Imported for backwards compatibility usage of `from shipchain_common.test_utils`

src/shipchain_common/test_utils.py renamed to src/shipchain_common/test_utils/helpers.py

Lines changed: 0 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,13 @@
1818
import re
1919
import random
2020
from datetime import datetime, timedelta
21-
from unittest.mock import Mock
2221
from uuid import uuid4
2322

2423
import jwt
2524
from django.conf import settings
2625
from django.test.client import encode_multipart
27-
from requests.models import Response
2826
from rest_framework_simplejwt.utils import aware_utcnow, datetime_to_epoch
2927

30-
from .utils import validate_uuid4 # Imported for backwards compatibility usage of `from shipchain_common.test_utils`...
31-
3228

3329
def create_form_content(data):
3430
boundary_string = 'BoUnDaRyStRiNg'
@@ -91,77 +87,6 @@ def generate_vnd_json(attributes, object_type, object_id=None):
9187
return data
9288

9389

94-
def mocked_rpc_response(json, content=None, code=200):
95-
response = Mock(spec=Response)
96-
response.status_code = code
97-
response.content = content
98-
response.json.return_value = json
99-
return response
100-
101-
102-
def invalid_eth_amount():
103-
return mocked_rpc_response({
104-
"jsonrpc": "2.0",
105-
"result": {
106-
"success": True,
107-
"ether": "1000000",
108-
"ship": "1000000000000000000"
109-
},
110-
"id": 0
111-
})
112-
113-
114-
def invalid_ship_amount():
115-
return mocked_rpc_response({
116-
"jsonrpc": "2.0",
117-
"result": {
118-
"success": True,
119-
"ether": "1000000000000000000",
120-
"ship": "1000000"
121-
},
122-
"id": 0
123-
})
124-
125-
126-
def mocked_wallet_valid_creation():
127-
return mocked_rpc_response({
128-
"jsonrpc": "2.0",
129-
"result": {
130-
"success": True,
131-
"wallet": {
132-
"id": "d5563423-f040-4e0d-8d87-5e941c748d91",
133-
"public_key": "a07d45389b1a3b40c6784f749a1d616b4c6f0dba195c0348aef81e9b4c8a7f5c13c0d9b8c360cd2307683a2"
134-
"d8f20bd7d801c7a11f4681440f11372f2de465942",
135-
"address": "0x94Fad76b5Be2b746598BCe12e7b45D7C06D8DA1F"
136-
}
137-
},
138-
"id": 0
139-
})
140-
141-
142-
def mocked_wallet_invalid_creation():
143-
return mocked_rpc_response({
144-
"jsonrpc": "2.0",
145-
"result": {
146-
"success": True,
147-
"wallet": {
148-
"public_key": "a07d45389b1a3b40c6784f749a1d616b4c6f0dba195c0348aef81e9b4c8a7f5c13c0d9b8c360cd2307683a2"
149-
"d8f20bd7d801c7a11f4681440f11372f2de465942",
150-
"address": "0x94Fad76b5Be2b746598BCe12e7b45D7C06D8DA1F"
151-
}
152-
},
153-
"id": 0
154-
})
155-
156-
157-
def mocked_wallet_error_creation():
158-
return mocked_rpc_response({
159-
"jsonrpc": "2.0",
160-
"result": {},
161-
"id": 0
162-
})
163-
164-
16590
def random_location():
16691
"""
16792
:return: Randomly generated location geo point.
@@ -202,34 +127,6 @@ def replace_variables_in_string(string, parameters):
202127
return string
203128

204129

205-
def second_mocked_wallet_valid_creation():
206-
return mocked_rpc_response({
207-
"jsonrpc": "2.0",
208-
"result": {
209-
"success": True,
210-
"wallet": {
211-
"id": "256e621b-2d42-4bf2-ac76-27336d4bf770",
212-
"public_key": "234111c81a928562e114b9b137dac3c36d7fac5fb6551042608a69c4838335646a641059995510793d9be5f"
213-
"c9ea4dd0c5180aafff831462319b3c6878812f987",
214-
"address": "0x3fB9Ff55672084f3A34E4C77dACF5f3a8D71037a"
215-
}
216-
},
217-
"id": 0
218-
})
219-
220-
221-
def valid_eth_amount():
222-
return mocked_rpc_response({
223-
"jsonrpc": "2.0",
224-
"result": {
225-
"success": True,
226-
"ether": "1000000000000000000",
227-
"ship": "1000000000000000000"
228-
},
229-
"id": 0
230-
})
231-
232-
233130
class GeoCoderResponse:
234131
def __init__(self, status, point=None):
235132
self.ok = status

0 commit comments

Comments
 (0)