step-by-step instructions for creating your own API testing framework

Hello, I’m Alexey, QA Automation Engineer in the Integration team at Petrovich-TECH. I am developing a framework for automated testing of integration services for REST and SOAP.

Observation: when you come to an interview for the position of Junior QA Automation, you are always asked to develop automated tests for the API. It sounds logical, but not so simple: when you are just starting your way in autotesting, it is not always obvious to you what a working test framework should look like, what it should consist of, how to write tests correctly, and test data to them. “Raw” tests, which are described in books and various sources, do not always help out.

In this article, I will talk about the development of a typical API testing framework – in Python, from scratch, step by step. As a result, we will get a completely ready-made test framework – I hope that with its help you will be able to make a test task for an interview or simply improve your already existing test framework.

I hope the article will be of interest to novice auto-testers and those who are already developing automated tests for the API.


Formulation of the problem

For our purposes, we will use an open API – ReqRes.

In the article, I will not describe all the methods of the selected API; I will limit myself to CRUD methods, as the main ones. This will suffice for an example; for other methods is done in the image and likeness.

Methods for which we will write tests: Get, Post, Put, Delete.

Project repository: https://github.com/ScaLseR/petrovich_test.

With the introductory conditions decided, let’s get started.

Implementing the main API class

In the root of the project, we will create the “api” directory, and in it the “api.py” file. We will describe there the main class for working with the API – the logic for sending requests will be implemented there and the received response will be processed. Let’s call the class – “Api”.

class Api:
    """Основной класс для работы с API"""
    _HEADERS = {'Content-Type': 'application/json; charset=utf-8'}
    _TIMEOUT = 10
    base_url = {}

    def __init__(self):
        self.response = None

A requirements.txt file has been added to the project root, in which we will store a list of required libraries.

Libraries we will work with:

import allure
import requests
from jsonschema import validate

Requests – Help us with sending requests and receiving responses.

Allure – will add the ability to generate reports in Allure to our project. This will allow you to get a convenient, well-read test report.

Jsonschema – from here we import the validate function, to implement schema validation.

In our Api class, we implement the functionality of sending requests and receiving responses. For a POST request, the code would look like this:

    def post(self, url: str, endpoint: str, params: dict = None,
             json_body: dict = None):        
            self.response = requests.post(url=f"{url}{endpoint}",
                                          headers=self._HEADERS,
                                          params=params,
                                          json=json_body,
                                          timeout=self._TIMEOUT)
        return self

Let’s add “@allure.step” – we will pass the steps to our Allure report.

@allure.step("Отправить POST-запрос")
def post(self, url: str, endpoint: str, params: dict = None,
         json_body: dict = None):
    with allure.step(f"POST-запрос на url: {url}{endpoint}"
                     f"\n тело запроса: \n {json_body}"):
        self.response = requests.post(url=f"{url}{endpoint}",
                                      headers=self._HEADERS,
                                      params=params,
                                      json=json_body,
                                      timeout=self._TIMEOUT)    
    return self

Additionally, we will log requests and responses. To do this, add the “helper” directory to the project – it will contain all our additional modules. Let’s write the first module for logging – logger.py.

"""Модуль логирования"""
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def log(response, request_body=None):
    logger.info(f"REQUEST METHOD: {response.request.method}")
    logger.info(f"REQUEST URL: {response.url}")
    logger.info(f"REQUEST HEADERS: {response.request.headers}")
    logger.info(f"REQUEST BODY: {request_body}\n")
    logger.info(f"STATUS CODE: {response.status_code}")
    logger.info(f"RESPONSE TIME: {response.elapsed.total_seconds() * 1000:.0f} ms\n")
    logger.info(f"RESPONSE HEADERS: {response.headers}")
    logger.info(f"RESPONSE BODY: {response.text}\n.\n.")

The module is ready, let’s add its use to our Api class:

from helper.logger import log

Let’s add logging for our methods; the POST method code will look like this:

@allure.step("Отправить POST-запрос")
def post(self, url: str, endpoint: str, params: dict = None,
         json_body: dict = None):
    with allure.step(f"POST-запрос на url: {url}{endpoint}"
                     f"\n тело запроса: \n {json_body}"):
        self.response = requests.post(url=f"{url}{endpoint}",
                                      headers=self._HEADERS,
                                      params=params,
                                      json=json_body,
                                      timeout=self._TIMEOUT)
    log(response=self.response, request_body=json_body)
    return self

Great, now we can send requests with the necessary data and receive responses.

To check the received responses in tests, we need assertions, we will also add them to the main Api class. Let’s add some of the most important ones.

@allure.step("Статус-код ответа равен {expected_code}")
def status_code_should_be(self, expected_code: int):
    """Проверяем статус-код ответа actual_code на соответствие expected_code"""
    actual_code = self.response.status_code
    assert expected_code == actual_code, f"\nОжидаемый результат: {expected_code} " \
                                         f"\nФактический результат: {actual_code}"
    return self
@allure.step("ОР: Cхема ответа json валидна")
def json_schema_should_be_valid(self, path_json_schema: str, name_json_schema: str="schema"):
    """Проверяем полученный ответ на соответствие json-схеме"""
    json_schema = load_json_schema(path_json_schema, name_json_schema)
    validate(self.response.json(), json_schema)
    return self

To implement checking the response for compliance with the schema, we need to add one more module to our “helper” – load.py. In it, add the function load_json_schema – to load the required json schema from a file. The module will look like:

"""Модуль для работы с файлами"""
from importlib import import_module

def load_json_schema(path: str, json_schema: str="schema"):
    """Подгрузка json-схемы из файла"""
    module = import_module(f"schema.{path}")
    return getattr(module, json_schema)

Don’t forget to add a new module from “helper” to the Api class.

from helper.load import load_json_schema

We will deserialize the received response into an object and compare it with the reference one.

@allure.step("ОР: Объекты равны")
def objects_should_be(self, expected_object, actual_object):
    """Сравниваем два объекта"""
    assert expected_object == actual_object, f"\nОжидаемый результат: {expected_object} " \
                                             f"\nФактический результат: {actual_object}"
    return self
@allure.step("ОР: В поле ответа содержится искомое значение")
def have_value_in_response_parameter(self, keys: list, value: str):
    """Сравниваем значение необходимого параметра"""
    payload = self.get_payload(keys)
    assert value == payload, f"\nОжидаемый результат: {value} " \
                                 f"\nФактический результат: {payload}"
    return self

To get the value of the required parameter from the response, let’s add one more method of the Api class.

def get_payload(self, keys: list):
    """Получаем payload, переходя по ключам,
    возвращаем полученный payload"""
    response = self.response.json()
    payload = self.json_parser.find_json_vertex(response, keys)       
    return payload

For the get_payload method to work correctly, let’s add the parser.py module to our “helper”:

"""Модуль для парсинга данных"""
from typing import Union

def get_data(keys: Union[list, str], data: Union[dict, list]):
    """Получение полезной нагрузки по ключам,
    если нагрузки нет, возвращаем пустой dict"""
    body = data
    for key in keys:
        try:
            body = body[key]
            if body is None:
                return {}
        except KeyError:
            raise KeyError(f'Отсутствуют данные для ключа {key}')
    return body

Don’t forget to add a new module from “helper” to the “Api” class:

from helper.parser import get_data

You probably already noticed that every method of the “Api” class returns self; a little lower we will see why this is so and how convenient it is.

The main class is ready, what’s next

We can say that the biggest and most difficult work has already been done by this point; The “skeleton” of the framework has been formed. It remains to increase the “meat”:

  • connector class for the tested API with a description of the methods;

  • model files – data classes for request and response;

  • json response schemas;

  • fixtures;

And finally, you will need to write the tests themselves.

Let’s start with the connector class. Let’s create a directory “reqres” in the “api” directory. In it, we will create the file “reqres_api.py” – in fact, our connector to the API under test. Let’s write the URL, Endpoint and methods of interaction with the API.

The code of our class, using the post request as an example:

class ReqresApi(Api):

    """URl"""
    _URL = 'https://reqres.in'

    """Endpoint"""
    _ENDPOINT = '/api/users/'

@allure.step('Обращение к create')
def reqres_create(self, param_request_body: RequestCreateUserModel):
    return self.post(url=self._URL,
                     endpoint=self._ENDPOINT,
                     json_body=param_request_body.to_dict())

Now we need to create data classes that will contain the data model.

Let’s make a new directory “model”, inside the directory – files with models for our data.

An example of data models for the create method:

"""Модели для create user"""
from dataclasses import dataclass, asdict

@dataclass
class RequestCreateUserModel:
    """Класс для параметров request"""
    name: str
    job: str

    def to_dict(self):
        """преобразование в dict для отправки body"""
        return asdict(self)

@dataclass
class ResponseCreateUserModel:
    """Класс для параметров респонса"""
    name: str
    job: str
    last_name: str
    id: str
    created_at: str

Let’s add the use of models to the “ReqresApi” class:

from model.reqres.create_model import RequestCreateUserModel, 
ResponseCreateUserModel

Models are ready and added. Now it’s time to deserialize the received response into a data object for use in tests.

For example, the deserialization method code for “single user” would look like this:

"""Собираем респонс в объект для последующего использования"""
def deserialize_single_user(self):
    """для метода get (single user)"""
    payload = self.get_payload([])
    return ResponseSingleUserModel(id=payload['data']['id'],
                                   email=payload['data']['email'],
                                   first_name=payload['data']['first_name'],
                                   last_name=payload['data']['last_name'],
                                   avatar=payload['data']['avatar'],
                                   url=payload['support']['url'],
                                   text=payload['support']['text'])

If necessary, we will do by analogy for other methods.

Our next step is to create json schemas to check the response. At the root of the project, create the “schema” directory, where the answer schemes will be located.

Scheme for “single user”:

"""Схема для ReqRes API, single user"""

schema = {
  "type": "object",
  "properties": {
    "data": {
      "type": "object",
      "properties": {
        "id": {
          "type": "integer"
        },
        "email": {
          "type": "string"
        },
        "first_name": {
          "type": "string"
        },
        "last_name": {
          "type": "string"
        },
        "avatar": {
          "type": "string"
        }
      },
      "required": [
        "id",
        "email",
        "first_name",
        "last_name",
        "avatar"
      ]
    },
    "support": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string"
        },
        "text": {
          "type": "string"
        }
      },
      "required": [
        "url",
        "text"
      ]
    }
  },
  "required": [
    "data",
    "support"
  ]
}

The next step is to write a fixture that will pass an instance of the “ReqresApi” connector class to the tests. Create a “fixture” directory at the root of the project.

Fixture code:

"""Фикстуры ReqRes API"""
import pytest
from api.reqres.reqres_api import ReqresApi

@pytest.fixture(scope="function")
def reqres_api() -> ReqresApi:
    """Коннект к ReqRes API"""
    return ReqresApi()

Everything is ready – you can proceed to the tests!

We write tests

In our “load.py” module, let’s add a method for loading data directly into tests; we will parametrize.

def load_data(path: str, test_data: str="data"):
    """Подгрузка из файла тестовых данных для параметризации тестов"""
    module = import_module(f"data.{path}")
    return getattr(module, test_data)

Let’s add the “test” directory to the root of the project, inside the “test_single_user.py” file. File code example:

"""Тест кейс для ReqRes API, single user"""
import allure
import pytest
from helper.load import load_data

pytest_plugins = ["fixture.reqres_api"]
pytestmark = [allure.parent_suite("reqres"),
              allure.suite("single_user")]

@allure.title('Запрос получения данных пользователя с невалидным значением')
@pytest.mark.parametrize(('user_id', 'expected_data'),
                         load_data('single_user_data', 'not_valid_data'))
def test_single_user_wo_parameters(reqres_api, user_id, expected_data):
    reqres_api.reqres_single_user(user_id).status_code_should_be(404).\
        have_value_in_response_parameter([], expected_data)

@allure.title('Запрос получения данных пользователя с валидным значением')
@pytest.mark.parametrize(('user_id', 'expected_data'),
                         load_data('single_user_data'))
def test_single_user_valid_parameters(reqres_api, user_id, expected_data):
    reqres_api.reqres_single_user(user_id).status_code_should_be(200).\
        json_schema_should_be_valid('single_user_schema').\
        objects_should_be(expected_data, reqres_api.deserialize_single_user())

We have two universal tests:

  1. for invalid parameters, with checking the response code and response body;

  2. for valid values: we check the response code for compliance with the json schema, deserialize the result into an object, compare it with the reference one (the code of other tests can be found in the repository).

I hope it is clear from the test code why the methods of the “Api” class return an object. The thing is that this allows you to write the test code quite beautifully and concisely, calling the necessary class methods in sequence and performing checks.

The test parameterization was moved to a separate file so as not to overload our code with test data; this has its advantages. When changing the test data, it will be enough to correct them only in one place – the data file. At the same time, do not check the entire code and fix it in the tests themselves, which can be problematic.

Let’s create a data file for our tests. Add the “data” directory to the root of the project; inside – the file “test_single_user.py”.

File code example:

"""Дата-файл для тестирования КуйКуы API, single user"""
# -*- coding: utf-8 -*-
from model.reqres.single_user_model import ResponseSingleUserModel

# эталонные модели данных для проверки в тестах
# user_id = 2
user_id_2 = ResponseSingleUserModel(id=2, email="janet.weaver@reqres.in", first_name="Janet",
                                    last_name="Weaver", avatar="https://reqres.in/img/faces/2-image.jpg",
                                    url="https://reqres.in/#support-heading",
                                    text="To keep ReqRes free, contributions towards server costs are appreciated!")

# user_id = 3
user_id_3 = ResponseSingleUserModel(id=3, email="emma.wong@reqres.in", first_name="Emma",
                                    last_name="Wong", avatar="https://reqres.in/img/faces/3-image.jpg",
                                    url="https://reqres.in/#support-heading",
                                    text="To keep ReqRes free, contributions towards server costs are appreciated!")

# Валидные данные для тестов ('user_id', 'expected_data')
data = ((2, user_id_2), (3, user_id_3))

# пустое тело ответа
empty_data = {}

# Невалидные данные для тестов ('user_id', 'expected_data')
not_valid_data = ((129398274923874, empty_data),
                  ('test', empty_data),
                  ('роывора', empty_data))

Running tests

Let’s run our tests in the console and look at the result:

The tests passed, but received not such a beautiful report as one might expect. Let’s start a test run with the formation of an Allure report.

The tests passed, the report was generated in the allure_report folder. Let’s open the report in the local Allur.

We see: there were 17 tests, all of them have the “passed” status.

On the Suites page, our tests are nicely laid out.

Plus, for each test we have a rather informative log:

Save: Checklist for Creating an API Testing Framework

In total, on the way to creating our framework, we went through the following steps:

  • added the “api” directory and the “api.py” file to work with the “Api” class;

  • made a requirements.txt file – to store a list of required libraries;

  • wrote the code for sending requests and receiving responses;

  • added logging

  • got assertions

  • made a class to get the value of the desired parameter from the response

  • in addition to the main class

    • connector class for the tested API with a description of the methods;

    • model files – data classes for request and response;

    • json response schemas;

    • fixtures

  • wrote tests

    • for invalid parameters, with checking the response code and response body;

    • for valid values

  • ran the tests

Having a checklist handy is the starting point. In addition to this and other checklists, there will be a lot more on the way for a beginner autotester: books, articles, videos, perhaps some training courses; questions to colleagues, discussions in thematic chats and public pages.

In my opinion, the key to success here is to try. Even if not everything is clear, even if not all questions have answers – the sooner you start doing it with your hands, the sooner you will figure it out. Let it follow the template by direct copying of the steps, albeit without a deep understanding of the methodology – but do it with your hands as early as possible.

The checklist works as expected – great, so the article was not in vain. It will turn out to find or come up with a more optimal checklist – even better! The main thing is to try.

Good luck with autotesting!

Similar Posts

Leave a Reply

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