Python API test automation framework (Part 7) Refactoring structure
Logos in header image sources: Python, Requests, JSON, HTTP, Cerberus
This is the seventh post in a series on how to build an API framework using python.
You can read previous parts below:
- Python API test automation framework (Part 1) Introduction, Setup and Installation
- Python API test automation framework (Part 2) Making HTTP requests
- Python API test automation framework (Part 3) Writing Fluent assertions
- Python API test automation framework (Part 4) Working with XML using LXML
- Python API test automation framework (Part 5) Working with JSON and JsonPath
- Python API test automation framework (Part 6) API response schema validation
So far, we’ve been observing how to achieve a specific piece of functionality in our API framework using available packages in the python ecosystem.
However, when we talk about a framework, we have to do a bit more upfront thinking about how this would be used by team members and the overall structure in which it is organized becomes as important as well.
One way that I typically approach is to decide on an structure based on initial needs and then continuously refactor as the domain, context changes, typically after a few cycles the structure and the relationships between different modules, classes becomes clear and you have your framework structure.
Let’s see what some of these changes are:
Remove unnecessary comment and replace them with well named functions/classes
If you have been following along earlier commits/posts in this series, you would have seen that we
had given inline comments
using #
above many lines of code.
While this might be okay for a demo framework like this, I still wanted to call out that this practice could be quite bad in a real project. Reason being, with every passing wave of refactoring you might extract certain functions or rename variables or even change behavior in some cases.
Quite often, it’s easy to forget updating the comment and then you have comments which don’t even match with underlying logic and that is just plain confusing for the person who reads your code. 🤷🏼♂️
I’ve thus removed these from this branch and from the final master branch as well.
Below is the updated tests file with this and some other changes:
tests/people_test.py
import random
from json import loads
import pytest
import requests
from assertpy.assertpy import assert_that
from jsonpath_ng import parse
from clients.people.people_client import PeopleClient
from config import BASE_URI
from tests.helpers.people_helpers import search_created_user_in
from utils.file_reader import read_file
client = PeopleClient()
def test_read_all_has_kent():
response = client.read_all_persons()
assert_that(response.status_code).is_equal_to(requests.codes.ok)
assert_that(response.as_dict).extracting('fname').is_not_empty().contains('Kent')
def test_new_person_can_be_added():
last_name, response = client.create_person()
peoples = client.read_all_persons().as_dict
is_new_user_created = search_created_user_in(peoples, last_name)
assert_that(is_new_user_created).is_not_empty()
Notice, we have replaced all the comments with better named functions that clarify intent.
Introducing client class as an abstraction layer
In earlier posts, we were directly calling requests.get()
, post()
and other methods and working
with response objects.
This is often a practice to avoid because it tightly couples our test implementation to request libraries implementation
While requests (or, any other library) today does a good job for us, we might have to tweak its behavior for all our tests or might have to switch to a different library altogether.
To ensure minimal changes to test code in those scenarios, it’s always a good idea to wrap these third party library code into an abstraction that we maintain.
One common pattern is to abstract these domain operations into their own client class/module.
I’ve thus introduced a people_client.py
clients/people/people_client.py
from json import dumps
from uuid import uuid4
from clients.people.base_client import BaseClient
from config import BASE_URI
from utils.request import APIRequest
class PeopleClient(BaseClient):
def __init__(self):
super().__init__()
self.base_url = BASE_URI
self.request = APIRequest()
def create_person(self, body=None):
last_name, response = self.__create_person_with_unique_last_name(body)
return last_name, response
def __create_person_with_unique_last_name(self, body=None):
if body is None:
last_name = f'User {str(uuid4())}'
payload = dumps({
'fname': 'New',
'lname': last_name
})
else:
last_name = body['lname']
payload = dumps(body)
response = self.request.post(self.base_url, payload, self.headers)
return last_name, response
def read_one_person_by_id(self, person_id):
pass
def read_all_persons(self):
return self.request.get(self.base_url)
def update_person(self):
pass
def delete_person(self, person_id):
url = f'{BASE_URI}/{person_id}'
return self.request.delete(url)
Notice this has simple wrapper methods for the same operations that we were directly calling from
the test function and also makes every call to requests from an object of APIRequest()
class
instead of calling request module directly
How does this APIRequest class look like?
utils/request.py
from dataclasses import dataclass
import requests
@dataclass
class Response:
status_code: int
text: str
as_dict: object
headers: dict
class APIRequest:
def get(self, url):
response = requests.get(url)
return self.__get_responses(response)
def post(self, url, payload, headers):
response = requests.post(url, data=payload, headers=headers)
return self.__get_responses(response)
def delete(self, url):
response = requests.delete(url)
return self.__get_responses(response)
def __get_responses(self, response):
status_code = response.status_code
text = response.text
try:
as_dict = response.json()
except Exception:
as_dict = {}
headers = response.headers
return Response(
status_code, text, as_dict, headers
)
Notice, we have created wrapper methods for the HTTP operations that our current framework needs, namely post(), delete() and also we are wrapping the response object into a custom object that we control.
This is very powerful for the above mentioned reasons
- We can now easily modify request libraries behavior without changing underlying tests
- Our clients are not depending on requests library and we are free to switch to a different implementation in the future if such a need arises.
We also make use of Pythons @dataclass
annotation
to create this data holder and use the private method __get_responses
to take a response object
from requests and then return our own implementation.
Make the client thin
Another thing to observe is, we have not put any validations on response status code, body, headers in these client methods.
This is intentional since we want this to be the tests concern. The client just gives a simple class to make these HTTP requests and leaves all implementation details for different validations to the tests.
This approach ensures we can reuse the same method in the client to test for a success (2XX) or a failure case (4XX, 5XX) status code.
Abstract helper methods into their own class/module
I’ve also moved some helper methods related to searching a person in response and the JSON path implementation into its own module, this ensures that the test code is clean and there is less cognitive overload on the person looking at the code to grasp each and every detail.
For instance a descriptive method name like search_created_user_in
is always going to be more
understandable than [person for person in peoples if person['lname'] == last_name][0]
which
requires the code reader to grasp python list comprehension, array slicing and what not.
tests/helpers/people_helpers.py
from jsonpath_ng import parse
def search_created_user_in(peoples, last_name):
return [person for person in peoples if person['lname'] == last_name][0]
def search_nodes_using_json_path(peoples, json_path):
jsonpath_expr = parse(json_path)
return [match.value for match in jsonpath_expr.find(peoples)]
Abstract common assertions into their own module
I’ve also extracted some assertions into their own functions and moved them into their own module, for above reasons
tests/assertions/people_assertions.py
from assertpy import assert_that
def assert_people_have_person_with_first_name(response, first_name):
assert_that(response.as_dict).extracting('fname').is_not_empty().contains(first_name)
def assert_person_is_present(is_new_user_created):
assert_that(is_new_user_created).is_not_empty()
Extract fixtures into conftest.py
file
Pytest framework has the flexibility to put
fixture code into a conftest.py
file which is auto discovered and allows us to separate setup teardown code from the actual tests.
These fixtures can be reused at different levels and you can easily move them up the directory structure if you want the same fixtures to be available to many different test files
tests/conftest.py
import random
import pytest
from utils.file_reader import read_file
@pytest.fixture
def create_data():
payload = read_file('create_person.json')
random_no = random.randint(0, 1000)
last_name = f'Olabini{random_no}'
payload['lname'] = last_name
yield payload
In the end our entire test file is now well refactored and much more readable.
import requests
from clients.people.people_client import PeopleClient
from tests.assertions.people_assertions import *
from tests.helpers.people_helpers import *
client = PeopleClient()
def test_read_all_has_kent():
response = client.read_all_persons()
assert_that(response.status_code).is_equal_to(requests.codes.ok)
assert_people_have_person_with_first_name(response, first_name='Kent')
def test_new_person_can_be_added():
last_name, response = client.create_person()
assert_that(response.status_code, description='Person not created').is_equal_to(requests.codes.no_content)
peoples = client.read_all_persons().as_dict
is_new_user_created = search_created_user_in(peoples, last_name)
assert_person_is_present(is_new_user_created)
def test_created_person_can_be_deleted():
persons_last_name, _ = client.create_person()
peoples = client.read_all_persons().as_dict
new_person_id = search_created_user_in(peoples, persons_last_name)['person_id']
response = client.delete_person(new_person_id)
assert_that(response.status_code).is_equal_to(requests.codes.ok)
def test_person_can_be_added_with_a_json_template(create_data):
client.create_person(create_data)
response = client.read_all_persons()
peoples = response.as_dict
result = search_nodes_using_json_path(peoples, json_path="$.[*].lname")
expected_last_name = create_data['lname']
assert_that(result).contains(expected_last_name)
A Simple heuristic to follow: Make it work => beautiful => fast
Notice how we approached this whole exercise and did these refactoring in phases. That is one iterative way to build frameworks and has the benefit of ensuring only the required bits of code come into the framework.
Below is the general heuristic i follow which has been written about multiple times.
- Make it work: Write the simplest thing possible that could work and get the job done first, don’t do any premature optimizations in this phase
- Make it beautiful: With implementation details figured out, think of how you can structure it better and refactor existing lines of code, methods into their own logical place. This is where you think of patterns, structure, which responsibilities lie with which class/module etc.
- Make it fast: Make the code you write something that runs really fast. In this phase, You optimize your implementation (after profiling current performance) and think about concurrency as well. More on this in a future post.
Conclusion
In summary, Most of the concerns were abstracted into a dedicated place which can be easily understood and modified in the future.
Is this the perfect structure though?
Heck no 🥴, what you decide on heavily maps to your specific domain models and context, however treat these as some guidelines along the way on how to approach structuring your framework.
A well defined structure would most often than not, stand the test of time while ensuring all the people contributing understand and are able to navigate it with ease and is a very important aspect that good software engineers care about.
You can see this entire code on this Github repo and branch
If you found this post useful, Do share it with a friend or colleague and if you have thoughts, I’d be more than happy to chat over at twitter or comments. Until next time. Happy Testing.
Comments