Asynchronous API Automation testing using Qxf2’s Framework

You can now create asynchronous API automation test using Qxf2’s API Automation Framework.

In this post we will go about explaining:
– Why we need Async?
– How we modified our Synchronous framework to support Asynchronous HTTP calls
– How to create an Async API Automation test using our framework
– Cases where Async is not the right fit

Note: This post is mostly about how a few changes to our framework helped make it support running tests asynchronously but if you have not used our API framework and have no idea on how it works you can still follow along to know how to create non-blocking functions for blocking functions and how to create coroutine test functions for pytest.


Why Async?

Modern computers equipped with multi cpu cores can perform multiple tasks at the same time. When running an I/O bound task computers sometime wait on the I/O operation idly. Async helps reduce this idle wait time by running another task during the wait. Running steps asynchronously in most cases can also help reduce execution time drastically. When we ran 300 API requests synchronously and asynchronously using our framework, we noticed the asynchronous steps ran more than 10x faster. We find making asynchronous calls for setup steps to be especially useful.

2024-08-28 14:44:12 | WARNING | tests.test_api_sync_example| The total number of sync requests is 300
2024-08-28 14:44:12 | WARNING | tests.test_api_sync_example| The time taken to complete sync call is 90.46587109565735
2024-08-28 14:44:13 | WARNING | test_api_async_example     | The total number of async requests is 300
2024-08-28 14:44:13 | WARNING | test_api_async_example     | The time taken to complete async call is 7.610826253890991

Changes we made to our Framework:

We have added an image below implementing our framework against a sample Cars-API application to show you have our Player-Interface API Automation framework works.

API Automation Framework

The following layers form the crux of the framework:
1. Endpoints layer
2. Interface layer
3. Player layer
The API endpoints in the application are abstracted in the Endpoints layer(all the *_API_Endpoints.py modules), the Interface layer(the API_Interface.py module) collects all the API endpoint abstractions & the Player layer(API_Player.py module) houses the business logic to validate the application.
The Base_API file that the endpoints abstractions extend to make the HTTP calls using requests module only used blocking functions to make GET, POST, PUT & DELETE requests so to start supporting Async API automation test we added non blocking functions to make those requests.

Blocking GET HTTP method:
def get(self, url, headers=None):
    "Run HTTP Get request against an url"
    headers = headers if headers else {}
    response = None
    try:
        response = requests.get(url=url,headers=headers)
    except Exception as generalexcep:
        print(f"Unable to run GET request against {url} due to {generalexcep}")
    return response

Disclaimer: The code in this blog post is intended for demonstration purposes only. We recommend following better coding standards for real-world applications.

Non-blocking GET HTTP method for the previous synchoronous method:
async def async_get(self, url, headers={}):
    "Run the blocking GET method in a thread"
    response = await asyncio.to_thread(self.get, url, headers)
    return response

Although running code on a separate thread is not asynchronous but using asyncio.to_thread allows us to await on the result asynchronously.
We used asyncio.to_thread to add similar non-blocking functions for the corresponding blocking functions in the *_API_Endpoints, API_Player modules too.


Create an Async API Automation test

A simple test cannot be used to call the coroutines we added in the API_Player module. The test function needs to be a couroutine too. pytest-asyncio plugin allows creating coroutine test functions.

@pytest.mark.asyncio
async def test_api_async_example(test_api_obj):
    "Run api test"

The @pytest.mark.asyncio is required for pytest to collect the coroutine as a test.
The test scenarios are then added as tasks inside the coroutine test function, we used asyncio.TaskGroup object to group the test scenarios and run them asynchronously.

async with asyncio.TaskGroup() as group:
    get_cars = group.create_task(test_api_obj.async_get_cars(auth_details))
    add_new_car = group.create_task(test_api_obj.async_add_car(car_details=car_details,
                                                               auth_details=auth_details))
    get_car = group.create_task(test_api_obj.async_get_car(auth_details=auth_details,
                                                           car_name=existing_car,
                                                           brand=brand))

Putting it all together, this is how our sample Async API Automation test for the Cars-API app looks:

"""
API Async EXAMPLE TEST
This test collects tasks using asyncio.TaskGroup object \
and runs these scenarios asynchronously:
1. Get the list of cars
2. Add a new car
3. Get a specifi car from the cars list
4. Get the registered cars
"""
 
import asyncio
import os
import sys
import pytest
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from conf import api_example_conf
 
@pytest.mark.asyncio
# Skip running the test if Python version < 3.11
@pytest.mark.skipif(sys.version_info < (3,11),
                    reason="requires Python3.11 or higher")
async def test_api_async_example(test_api_obj):
    "Run api test"
    try:
        expected_pass = 0
        actual_pass = -1
 
        # set authentication details
        username = api_example_conf.user_name
        password = api_example_conf.password
        auth_details = test_api_obj.set_auth_details(username, password)
 
        # Get an existing car detail from conf
        existing_car = api_example_conf.car_name_1
        brand = api_example_conf.brand
        # Get a new car detail from conf
        car_details = api_example_conf.car_details
 
        async with asyncio.TaskGroup() as group:
            get_cars = group.create_task(test_api_obj.async_get_cars(auth_details))
            add_new_car = group.create_task(test_api_obj.async_add_car(car_details=car_details,
                                                                       auth_details=auth_details))
            get_car = group.create_task(test_api_obj.async_get_car(auth_details=auth_details,
                                                                   car_name=existing_car,
                                                                   brand=brand))
            get_reg_cars = group.create_task(test_api_obj.async_get_registered_cars(auth_details=auth_details))
 
        test_api_obj.log_result(get_cars.result(),
                                positive="Successfully obtained the list of cars",
                                negative="Failed to get the cars")
        test_api_obj.log_result(add_new_car.result(),
                                positive=f"Successfully added new car {car_details}",
                                negative="Failed to add a new car")
        test_api_obj.log_result(get_car.result(),
                                positive=f"Successfully obtained a car - {existing_car}",
                                negative="Failed to add a new car")
        test_api_obj.log_result(get_reg_cars.result(),
                                positive="Successfully obtained registered cars",
                                negative="Failed to get registered cars")
        # write out test summary
        expected_pass = test_api_obj.total
        actual_pass = test_api_obj.passed
        test_api_obj.write_test_summary()
        # Assertion
        assert expected_pass == actual_pass,f"Test failed: {__file__}"
 
    except Exception as e:
        test_api_obj.write(f"Exception when trying to run test: {__file__}")
        test_api_obj.write(f"Python says: {str(e)}")

We have used @pytest.mark.skipif marker to skip running the test for Python versions less than 3.11, the TaskGroup object api is available on versions 3.11 and later.


Scenarios where Async API test is not the right choice

Though Async API Automation test can help validate an app and reduce the time it takes to run the validations, there are cases where making HTTP calls asynchronously might not be the right approach. Here are a few scenarios where we think synchronous test is a better choice:
– Test where a few scenarios need to run in an order are not a right fit for asynchronous testing, because the order of execution of the coroutines collected as tasks in the test cannot be controlled, hence is susceptible to race condition.
– Test with very few scenarios are better off being run synchronously, because the overhead required to collect and run very less scenarios asynchronously minimises its advantage.
– Projects that cannot be maintained regularly. The asyncio Python module has gone through drastic changes with each Python version and hence warrant frequent maintenance.


References

1. How do you test APIs that use asynchronous communication?
2. Using asyncio.to_thread to create non blocking functions
3. pytest-asyncio
4. asyncio Task Groups


Hire testers from Qxf2

We at Qxf2 services make a dedicated effort to constantly evaluate our Automation framework against new frameworks to identify and implement new useful features. You can find our open-source framework on GitHub here – Qxf2 Page Object Model Framework.
Our expertise extend beyond maintaining our own framework, we have used various other frameworks at clients and even helped maintain a few of them. If you are looking for testers to help create automation tests using our framework or maintain your own automation framework contact Qxf2.


Leave a Reply

Your email address will not be published. Required fields are marked *