Python-Powered Performance Testing for QA Testers: A Beginner's Guide to Cloud API Load Testing

Written by shad0wpuppet | Published 2024/01/19
Tech Story Tags: software-qa | qa | software-testing | automated-testing | performance-testing | python | multiprocessing | asyncio

TLDRExplore Python scripts for QA testers conducting load testing on cloud app APIs. The article covers asynchronous and multiprocessing approaches, offering insights into customization, methodology selection, and practical monitoring tips. Leverage Python's technical capabilities for comprehensive performance analysis.via the TL;DR App

Are you a QA tester eager to dive into performance testing without the need for extensive programming expertise? In this article, we'll explore an approachable way for non-programmers to conduct sort of load testing on cloud apps APIs using Python. Load testing without the need for complex coding - discover how even regular QA testers can use Python to find serious bugs and uncover potential performance bottlenecks.

Performance testing is a critical aspect of ensuring your applications can handle real-world demands. I will try to explain my approach and Python scripts designed for load testing a cloud service managing browsers.

Load Testing Scenario Imagine a cloud service responsible for managing browser profiles (browsers for web scrapping). Users interact via API with the service to create, start, stop, delete, etc profiles. My Python script simulates this scenario, applying load to the cloud service by repeatedly performing these actions.

#  Dependencies
import asyncio
import httpx

#  Configuration
API_HOST = 'https://cloud.io'
API_KEY = 'qatest'
API_HEADERS = {
    "x-cloud-api-token": API_KEY,
    "Content-Type": "application/json"
}
CYCLES_COUNT = 3

#  Browser profile configuration
data_start = {
    "proxy": "http://127.0.0.1:8080",
    "browser_settings": {"inactive_kill_timeout": 120}
}

Asynchronous functions: The heart of my load tests

  • Fetching Browser Profiles: The get_profiles function retrieves existing browser profiles from the service, simulating a scenario where users request information.
async def get_profiles(cl: httpx.AsyncClient):
    resp = await cl.get(f'{API_HOST}/profiles', params={'page_len': 10, 'page': 0}, headers=API_HEADERS)
    return resp.json()

  • Starting Browsers: The script initiates cycles, each involving starting browser profiles asynchronously. This emulates a scenario where a user creates multiple browsers concurrently and uses browser profiles.
async def start_profile(cl: httpx.AsyncClient, uuid):
    resp = await cl.post(f'{API_HOST}/profiles/{id}/start', json=data_start, headers=API_HEADERS)
    if error := resp.json().get('error'):
        print(f'Profile {id} not started with error {error}')

  • Stopping browsers and deleting profiles: After starting profiles, the script fetches active profiles and stops them, followed by deleting all profiles. This load scenario assesses the cloud service's responsiveness to dynamic changes and its efficiency in resource cleanup.
async def stop_profile(cl: httpx.AsyncClient, uuid):
    resp = await cl.post(f'{API_HOST}/profiles/{id}/stop', headers=API_HEADERS)
    if error := resp.json().get('error'):
        print(f'Profile {id} not stopped with error {error}')

async def delete_profile(cl: httpx.AsyncClient, uuid):
    resp = await cl.delete(f'{API_HOST}/profiles/{id}', headers=API_HEADERS)
    if error := resp.json().get('error'):
        print(f'Profile {id} not stopped with error {error}')

  • Monitoring connections: understanding load impact The script concludes by checking and reporting on active connections. This step is crucial for understanding the load impact and identifying potential issues related to monitoring.
for conn in cl._transport._pool.connections:
    if conn._connection._state.value != 1:
        continue

    print(f'Connection in progress: {conn}')

Load testing in action

The main function orchestrates the load testing cycles, iterating through profiles and executing asynchronous tasks. Each cycle represents a simulated user interaction, creating, using, and deleting browser profiles.

async def main():
    async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=300)) as cl:
        for _ in range(CYCLES_COUNT):
            profiles = await get_profiles(cl)
            start_tasks = [asyncio.create_task(start_profile(cl, profile['id'])) for profile in profiles]
            await asyncio.gather(*start_tasks)

            active_browsers = await get_active_profiles(cl)
            stop_tasks = [asyncio.create_task(stop_profile(cl, active_browser['id'])) for active_browser in active_browsers['data']]
            await asyncio.gather(*stop_tasks)

            profiles = await get_profiles(cl)
            del_tasks = [asyncio.create_task(delete_profile(cl, profile['id'])) for profile in profiles]
            await asyncio.gather(*del_tasks)

# Monitor active connections for insights into load impact

Tailoring load tests to your needs

This script shows a foundation for QAs to tailor load-testing scenarios to their applications. By customizing the number of cycles, adjusting user interactions, and modifying the script to fit specific API endpoints, testers can gain valuable insights into their application's performance under different loads. Here, you'll need essential monitoring tools to gain info on server states, assess the server load, and track resource utilization and logs. Utilize tools like Grafana, Kibana, Prometheus, etc, for comprehensive monitoring. Additionally, keep a close eye on the responses your script receives, ensuring a thorough evaluation of your application's performance. This approach is invaluable in your effective load testing and performance analysis.

Additionally, for a more realistic load simulation, consider opening specific pages in your browser. While I personally used a start page in my browsers, you can also explore options like Pyppeteer or Playwright to open multiple tabs and navigate through various pages. This approach enhances the authenticity of your load-testing scenario, closely resembling user interactions with your application.

# Attempt to connect to the browser using the provided profile URL
try:
    browser = await connect({'browserWSEndpoint': browser_url, 'defaultViewport': None})
except Exception as e:
    # Handle connection errors and print a message
    print(f'Error occurred when connecting to the browser: {str(e)}')
    return

# Create a new page in the connected browser
page = await browser.newPage()

# Introduce a brief delay to ensure the page is ready
await asyncio.sleep(2)

# Set the viewport dimensions for the page
width, height = 1920, 1080
await page.setViewport({'width': width, 'height': height})

# Try to navigate to a specific URL 
try:
    await page.goto('https://{your_website}')
    # Wait for 10 seconds to simulate user interaction
    await page.waitFor(10000)
    # Introduce another delay for additional stability
    await asyncio.sleep(5)
except pyppeteer.errors.PageError as e:
    # Handle page navigation errors and print a message
    print(f'Error occurred during page navigation: {str(e)}')

# Attempt to take a screenshot of the page
try:
    await page.screenshot(path='screen.png', fullPage=True)
    # Print a success message if the screenshot is captured successfully
    print('Screenshot taken successfully.')
except Exception as e:
    # Handle screenshot capture errors and print a message
    print(f'Error occurred during taking a screenshot: {str(e)}')

Python's asynchronous capabilities, coupled with HTTP libraries, make it a versatile tool for load-testing cloud-based systems. This example serves as a starting point for QA engineers looking to learn Python's power in their load-testing attempts.

NOTE

In my scenario, the described script proved to be robust and impactful. It served as a useful tool in identifying and addressing numerous issues. The script's aggressive nature was okay in pinpointing critical issues, facilitating effective debugging, and leading the way for a seamless and improved user experience, which is pretty good for a QA.


In continuation, I will briefly discuss another script utilizing Python's multiprocessing module. This approach aims to enhance load generation by concurrently executing multiple instances of the testing script. The primary goal of multiprocessing is to parallelize the execution of a script, enabling simultaneous interactions with the service. This approach contrasts with the asynchronous approach discussed earlier, where tasks are executed sequentially but concurrently managed. This is more like spam/ddos with the same requests, but it also might be very useful.

Key Components

  • Function to retrieve profiles: Similar to the asynchronous script, we still need to fetch existing browser profiles from the cloud service.
def get_profiles():
    response = requests.get(url=f"{api}", params=PARAMS, headers=headers)
    return response

  • Functions: The core of the script revolves around two functions: start_profiles and stop_profiles. These functions initiate and terminate browser profiles, respectively.
def start_profiles(list_of_profiles_uuids):
    for uuid in list_of_profiles_uuids:
        # ... (API request to start profile)


def stop_profiles(internal_uuids):
    for uuid in internal_uuids:
        # ... (API request to stop profile)


def run_script():
    start_profiles(get_profile_ids())
    stop_profiles(list_of_ids)

  • Multiprocessing execution: The script utilizes the multiprocessing module to run the load testing cycle multiple times in parallel. For each cycle, a new process is spawned, and the run_script function is executed concurrently.
if __name__ == "__main__":
    for runs in range(0, 5):
        processes = []
        for i in range(20):
            p = multiprocessing.Process(target=run_script)
            processes.append(p)
            p.start()

        for p in processes:
            p.join()

How to generate load with multiprocessing

  1. Parallel execution: Each process spawned by the multiprocessing module operates independently, executing the run_script function concurrently. This parallelization significantly increases the number of requests sent to the service simultaneously.
  2. Simulating user interactions: By running multiple instances of the script in parallel, we simulate a higher volume of users interacting with the cloud service concurrently. This approach is particularly useful for scenarios where real-world usage involves a large number of simultaneous users.

Choosing the right approach

The multiprocessing provides a strategy for load-testing applications. It allows QA engineers to experiment with different methodologies based on the unique characteristics of their applications. While asynchronous testing offers efficiency in managing concurrent tasks, multiprocessing excels in parallelizing the entire testing process. You can choose the approach that aligns best with their specific load-testing goals and application requirements.

A quick reminder:

This demo aims to introduce basic concepts in a beginner-friendly format, highlighting Python's simplicity for QA testers venturing into performance testing.

If you have programming challenges, don't hesitate to google stuff and ask colleagues, use ChatGPT or similar tools, and use GitHub Copilot for extra assistance in writing your load-testing scripts.


Also published here.


Written by shad0wpuppet | I'm a Software QA Team Lead and Engineer/Analyst with 10+ years of experience working with all sorts of web apps
Published by HackerNoon on 2024/01/19