Created
February 8, 2019 19:59
-
-
Save Droogans/6e284ad009138a01e34db288e5568097 to your computer and use it in GitHub Desktop.
This is as close to abusing the fixture pattern as I would ever like to get, but it was needed in a hurry and it got rid of a lot of duplicate code.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from itertools import chain, starmap | |
import pydash | |
import pytest | |
@pytest.fixture | |
def response(test_request): | |
""" | |
Use after calling the `test_request` fixture. Provides the resulting api request response object(s). | |
""" | |
return test_request.get('response') | |
@pytest.fixture | |
def json(test_request): | |
""" | |
Use after calling the `test_request` fixture. Provides the resulting api request response payloads(s). | |
""" | |
return test_request.get('json') | |
@pytest.fixture | |
async def test_request(request, api_client): | |
""" | |
Quickly make an http request, and assert against common aspects of the resulting json and response objects | |
before testing it further. Reduces boilerplate. Use with `json` and `response` fixtures to examine results further. | |
If you only need the response object and/or json body from a GET request, without any validation, only a string | |
endpoint is needed. See more options below to validate responses, or to make POST, PUT, PATCH, and DELETE calls. | |
You must create and supply your own aiohttp fixture as `api_client` to use this. | |
### Example usage without validation: | |
```py | |
@pytest.mark.asyncio | |
@pytest.mark.test_request('/kyt/users/1') | |
async def test_get_user_by_id(response, json): | |
assert response.status is not None | |
assert isinstance(json, dict) is True | |
``` | |
### Options for automatically validating api responses before running your tests: | |
`endpoint`: The api endpoint to make a request against. | |
`method`: The method to use to make the request. Defaults to "GET". | |
`request_kwargs`: Keyword arguments to pass to the api_client. | |
See: http://docs.aiohttp.org/en/stable/client_reference.html#aiohttp.ClientSession.request | |
`expected_status_code`: If any options are specified, defaults to 200. Otherwise, no status checks occur. | |
`is_dict`: Check if the top-level json payload is a dict. Defaults to True. | |
`expected_json_keys`: Compare the top-level keys of the json body to the values provided here. | |
`assert_deep_json_keys`: If True, traverse into all objects and collect a unique set of keys representing each | |
unique key address in the payload. List of objects will produce the union of all key | |
values found while iterating over all list members. Format is "parent/child". | |
Default is True. | |
`type_checks`: For each key/value pair in `type_checks`, assert that the json body's value found at the key address | |
`key` matches the expected type `value`. Supports lists of types, but at least one must match. | |
A "key address" is a direct call to https://pydash.readthedocs.io/en/v4.7.4/api.html#pydash.objects.get | |
### Example usage with validation: | |
@pytest.mark.test_request({ | |
'endpoint': '/users/1', | |
'expected_json_keys': [ | |
'creationDate', | |
'details/cluster/category', | |
'details/cluster/name', | |
'details/cluster/weight', | |
'lastActivity', | |
'score', | |
'scoreUpdatedDate', | |
'userId' | |
], | |
'type_checks': { | |
'details': list, | |
'details.0.cluster.name': str, | |
'details.0.custer.weight': [float, int], | |
} | |
}) | |
async def test_get_user_by_id(json): | |
assert json['userId'] == '1' | |
``` | |
### Testing multiple requests together: | |
In pytest, duplicate fixture calls are forbidden. If multiple test requests are required for a single test, | |
pass them as additional dicts. They will be called in the order they are listed. | |
Since there is more than one resulting json payload and more than one resulting request object, the fixtures | |
`json` and `response` will express their return values differently. These dicts will key off of the index of the | |
order the calls were made in by default. Calls can accept an optional `name` argument to name the key instead. | |
### Unnamed multi-requests: | |
```py | |
@pytest.mark.asyncio | |
@pytest.mark.test_request({ | |
'endpoint': '/abc', | |
'expected_status_code': 404 | |
}, { | |
'endpoint': '/' | |
}) | |
async def test_root(json): | |
assert json[0] == { 'error': 'Not Found', 'status_code': 404 } | |
assert json[1]['name'] == 'amlservice' | |
``` | |
### Named multi-requests: | |
```py | |
@pytest.mark.asyncio | |
@pytest.mark.test_request({ | |
'name': '404', | |
'endpoint': '/abc', | |
'expected_status_code': 404 | |
}, { | |
'name': 'root', | |
'endpoint': '/' | |
}) | |
async def test_root(json): | |
assert json['404'] == { 'error': 'Not Found', 'status_code': 404 } | |
assert json['root']['name'] == 'amlservice' | |
``` | |
""" | |
marker = request.node.get_closest_marker('test_request') | |
request = marker.args[0] | |
if isinstance(request, str): | |
response = await api_client.get(request) | |
json = await response.json() | |
return { 'json': json, 'response': response } | |
elif not isinstance(request, dict): | |
raise ValueError('test_request fixture expects all arguments passed in as a dict') | |
if len(marker.args) > 1: | |
requests = marker.args | |
return await _test_all_requests(requests, api_client) | |
response, json = await _test_one_request(request, api_client) | |
return { 'json': json, 'response': response } | |
async def _test_all_requests(requests, api_client): | |
all_responses = { 'json': {}, 'response': {} } | |
for index, request in enumerate(requests): | |
name = request.pop('name', index) | |
response, json = await _test_one_request(request, api_client) | |
all_responses['response'][name] = response | |
all_responses['json'][name] = json | |
return all_responses | |
async def _test_one_request(request, api_client): | |
method = request.pop('method', 'GET').lower() | |
request_fn = getattr(api_client, method) | |
response = await request_fn(request.pop('endpoint'), **request.pop('request_kwargs', {})) | |
json = await _assert_request(response, **request) | |
return response, json | |
async def _assert_request( | |
response, expected_status_code=200, is_dict=True, | |
expected_json_keys=None, assert_deep_json_keys=True, type_checks={}, name=None | |
): | |
if expected_status_code: | |
assert response.status == expected_status_code | |
json = await response.json() | |
if expected_json_keys is not None: | |
if assert_deep_json_keys: | |
assert _flattened_deep_json_keys(json) == sorted(expected_json_keys) | |
else: | |
assert sorted(json.keys()) == sorted(expected_json_keys) | |
if is_dict: | |
assert isinstance(json, dict), f"Expected response json to be a dict, instead it was a {type(json)}" | |
if type_checks is not None: | |
for path, expected_type in type_checks.items(): | |
failure_message = '{} is not of type {}'.format(path, expected_type) | |
if isinstance(expected_type, list): | |
assert type(pydash.get(json, path)) in expected_type, failure_message | |
else: | |
assert isinstance(pydash.get(json, path), expected_type), failure_message | |
return json | |
def _flattened_deep_json_keys(json): | |
""" | |
Taken from: | |
https://gist.github.com/alinazhanguwo/03206c554c1a8fcbe42a7d971efc7b26#file-flatten_json_iterative_solution-py | |
""" | |
if isinstance(json, list): | |
# force a top-level `parent_key` to prevent any pre-pended characters | |
json = { '': json } | |
def _unpack(parent_key, parent_value): | |
if isinstance(parent_value, dict): | |
for key, value in parent_value.items(): | |
if parent_key is not '': | |
traversed_key = '{}/{}'.format(parent_key, key) | |
else: # e.g., if json = { '': json } | |
traversed_key = key | |
yield traversed_key, value | |
elif isinstance(parent_value, list): | |
for value in parent_value: | |
yield parent_key, value | |
else: | |
yield parent_key, parent_value | |
while True: | |
# Keep unpacking the json file until all values are atomic elements (not dictionary or list) | |
json = dict(chain.from_iterable(starmap(_unpack, json.items()))) | |
if not any(isinstance(value, dict) for value in json.values()) and \ | |
not any(isinstance(value, list) for value in json.values()): | |
break | |
return sorted(set(json.keys())) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment