Python Requests API Automation Testing Framework Tutorial

Recommended: A Discussion on Visual Perception Testing in UI Automation

This article is edited from the oschina blog by author Nan Mu Dong Er. If it helps you, feel free to share it with your friends!

Recently, due to the company’s shift in testing focus from web page functional testing to API testing, most of the previous tests were conducted manually using Postman and JMeter. Later, someone in the group mentioned transitioning the original web automation testing framework to an automation framework for APIs, which was written in Java. However, as someone who learned Java but is currently learning Python, I find Python much simpler than Java.

Therefore, I decided to write my own Python API automation testing framework. Since I am also new to Python, this automation framework is now basically complete, and I wish to summarize it for future review. There are many imperfections, and I encountered numerous issues, so I hope the experts can provide guidance.

Now, let’s move on to the main content of today.

1. First, let’s clarify our thoughts.

What is the normal process for API testing?

Is the reaction in your mind like this:

Determine the tool for testing the API —> Configure the required API parameters —> Conduct the test —> Check the test results (some may require database assistance) —> Generate the test report (HTML report)

Based on this process, we will build our framework step by step. During this process, we need to separate business logic from data to achieve flexibility and the goal of writing the framework. As long as we do it well, we can succeed. This is what I said to myself at the beginning.

Next, let’s proceed with the structure division.

My structure looks like this, and you can refer to it:

Python Requests API Automation Testing Framework Tutorial

Now that we have a general structure, we can start filling in the entire framework step by step. First, let’s take a look at the config.ini and readConfig.py files, as I think it’s easier to start from them.

Let’s check what the contents of the files look like:

[DATABASE]host = 50.23.190.57username = xxxxxxpassword = ******port = 3306database = databasename
[HTTP]# API URLbaseurl = http://xx.xxxx.xx port = 8080timeout = 1.0
[EMAIL]mail_host = smtp.163.commail_user = [email protected]_pass = *********mail_port = 25sender = [email protected] = [email protected]/[email protected] = pythoncontent = "All interface tests have been completed\nPlease read the report file about the details of the result in the attachment."testuser = Someoneon_off = 1

As you can see, everyone is familiar with such configuration files. That’s right, we can place all the unchanging elements here. Haha, how about that?

Now that we have established a fixed “repository” to store our static elements, how do we retrieve them for use?

This is where the readConfig.py file comes into play, successfully helping us solve this problem. Let’s take a look at its true form.

import os
import codecs
import configparser
proDir = os.path.split(os.path.realpath(__file__))[0]
configPath = os.path.join(proDir, "config.ini")
class ReadConfig:
    def __init__(self):
        fd = open(configPath)
        data = fd.read()
        #  remove BOM
        if data[:3] == codecs.BOM_UTF8:
            data = data[3:]
            file = codecs.open(configPath, "w")
            file.write(data)
            file.close()
        fd.close()
        self.cf = configparser.ConfigParser()
        self.cf.read(configPath)
    def get_email(self, name):
        value = self.cf.get("EMAIL", name)
        return value
    def get_http(self, name):
        value = self.cf.get("HTTP", name)
        return value
    def get_db(self, name):
        value = self.cf.get("DATABASE", name)
        return value

Looks simple, right? We define methods to retrieve corresponding values by name. Isn’t that so easy?! Of course, we only used the get method here; there are other methods, such as set, which interested students can explore further.

Without further ado, let’s see what common methods we have.

Python Requests API Automation Testing Framework Tutorial

Now that we have completed the configuration file and reading it, and have seen the contents of common, we can proceed to write common methods. Which one should we start with? Today, let’s reveal the “Log.py” file, as it is relatively independent, and we will deal with it separately to lay a good foundation for its future service to us.

Here, I want to say a few more words about this log file. I have dedicated a separate thread to it, so during the entire execution process, writing logs will be more convenient. As the name suggests, this is where we handle all operations related to the output log, mainly defining the output format, output level, and other output definitions.

In summary, anything you want to do with logs can be placed here. Let’s take a look at the code; there’s no more direct and effective way than this.

import logging
from datetime import datetime
import threading

First, we need to import the required modules as shown above to proceed with the following operations.

class Log:
    def __init__(self):
        global logPath, resultPath, proDir
        proDir = readConfig.proDir
        resultPath = os.path.join(proDir, "result")
        # create result file if it doesn't exist
        if not os.path.exists(resultPath):
            os.mkdir(resultPath)
        # defined test result file name by localtime
        logPath = os.path.join(resultPath, str(datetime.now().strftime("%Y%m%d%H%M%S")))
        # create test result file if it doesn't exist
        if not os.path.exists(logPath):
            os.mkdir(logPath)
        # defined logger
        self.logger = logging.getLogger()
        # defined log level
        self.logger.setLevel(logging.INFO)
        # defined handler
        handler = logging.FileHandler(os.path.join(logPath, "output.log"))
        # defined formatter
        formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
        # defined formatter
        handler.setFormatter(formatter)
        # add handler
        self.logger.addHandler(handler)

Now, we have created the Log class above, and in the __init__ method, we performed relevant initialization operations for the log.

The specific operations are clearly annotated in the comments (my English might be a bit lacking, but as long as you can understand it, that’s fine, haha…), so the basic format of the log has been defined. As for other methods, it’s up to everyone to develop, as each person’s needs are different. We will only write common methods. Next, let’s put it into a thread; see the code below:

class MyLog:
    log = None
    mutex = threading.Lock()
    def __init__(self):
        pass
    @staticmethod
    def get_log():
        if MyLog.log is None:
            MyLog.mutex.acquire()
            MyLog.log = Log()
            MyLog.mutex.release()
        return MyLog.log

Doesn’t it seem less complicated than you imagined? Haha, it’s that simple. Python is much simpler than Java, which is why I chose it, even though I’m still learning and have many things I don’t understand.

Alright, that concludes the log content. Don’t you feel great? Actually, no matter when, never be afraid; believe that “Nothing in the world is difficult for the one who is willing to try.”

Next, we will continue building, this time focusing on the content of configHttp.py. Yes, we are starting to configure the API files now! (Finally writing about the API, aren’t you excited?)

Below is the main part of the API file, let’s take a look together.

import requests
import readConfig as readConfig
from common.Log import MyLog as Log
localReadConfig = readConfig.ReadConfig()
class ConfigHttp:
    def __init__(self):
        global host, port, timeout
        host = localReadConfig.get_http("baseurl")
        port = localReadConfig.get_http("port")
        timeout = localReadConfig.get_http("timeout")
        self.log = Log.get_log()
        self.logger = self.log.get_logger()
        self.headers = {}
        self.params = {}
        self.data = {}
        self.url = None
        self.files = {}
    def set_url(self, url):
        self.url = host + url
    def set_headers(self, header):
        self.headers = header
    def set_params(self, param):
        self.params = param
    def set_data(self, data):
        self.data = data
    def set_files(self, file):
        self.files = file
    # defined http get method
    def get(self):
        try:
            response = requests.get(self.url, params=self.params, headers=self.headers, timeout=float(timeout))
            # response.raise_for_status()
            return response
        except TimeoutError:
            self.logger.error("Time out!")
            return None
    # defined http post method
    def post(self):
        try:
            response = requests.post(self.url, headers=self.headers, data=self.data, files=self.files, timeout=float(timeout))
            # response.raise_for_status()
            return response
        except TimeoutError:
            self.logger.error("Time out!")
            return None

Here, let’s focus on the key points. First, you can see that I am using Python’s built-in requests library for API testing. Those who are attentive may have noticed that the Python + requests combination is very useful; it has already encapsulated the methods for testing APIs, making it very convenient to use.

Here, I’ll discuss the two methods: get and post (these are the most commonly used methods; others can be expanded based on this).

Get Method
In API testing, the most common methods are get and post. The get method is used to retrieve data from the API, meaning that using a get request does not alter any backend data. The format of the URL after passing parameters with the get method is: http://api_address?key1=value1&key2=value2. Doesn’t it look familiar? (It looks very familiar to me~\(≧▽≦)/~ lalala), so how do we use it? Please continue to read on.
For the get method provided by requests, there are several commonly used parameters:
url: Obviously, this is the API address
headers: Custom request headers (headers), e.g., content-type = application/x-www-form-urlencoded
params: Used to pass parameters required for testing the API, here we use the dictionary format (key: value) in Python.
timeout: Sets the maximum time for connecting to the API (exceeding this time will throw a timeout error)
Now that we know what each parameter means, all that’s left is to fill in the values, isn’t it a mechanical application? Haha, that’s how I learn mechanically!
For example:
url='http://api.shein.com/v2/member/logout'
header={'content-type': 'application/x-www-form-urlencoded'}
param={'user_id': 123456,'email': '[email protected]'}
timeout=0.5
requests.get(url, headers=header, params=param, timeout=timeout)
Post Method
Similar to the get method, just set the corresponding parameters. Below is a direct example with code:
url='http://api.shein.com/v2/member/login'
header={'content-type': 'application/x-www-form-urlencoded'}
data={'email': '[email protected]','password': '123456'}
timeout=0.5
requests.post(url, headers=header, data=data, timeout=timeout)
How about that? Isn’t it simple? Here we need to clarify that in the post method, we no longer use params to pass parameters; instead, we use data. Haha, finally finished explaining, let’s explore the API response values.
Still only discussing commonly used return value operations.
text: Get the API return value in text format
json(): Get the API return value in json() format
status_code: Return status code (200 for success)
headers: Return complete request header information (headers[‘name’]: return specified headers content)
encoding: Return character encoding format
url: Return the complete URL address of the API
These are the commonly used methods that you can use as needed.
Regarding failed requests that throw exceptions, we can use “raise_for_status()” to handle this. So when our request encounters an error, it will throw an exception.
Here, I remind everyone that if your API has an incorrect address, there will be corresponding error prompts (sometimes testing is also needed). At this time, do not use this method to throw an error, because Python itself has already thrown an error when connecting to the API, and then you will not be able to test the expected content.
Moreover, the program will directly crash here, counting as an error. (Don’t ask me how I know this; I discovered it while testing).
Alright, the API file has also been explained. Don’t you feel like success is just around the corner? If you have made it this far, congratulations! There is still a long way to go~ Haha, just being rebellious.
Take a deep breath and continue with the next content…
Quickly, I want to learn (see) the content in common.py.
import os
from xlrd import open_workbook
from xml.etree import ElementTree as ElementTree
from common.Log import MyLog as Log
localConfigHttp = configHttp.ConfigHttp()
log = Log.get_log()
logger = log.get_logger()
# Read test cases from excel file
def get_xls(xls_name, sheet_name):
    cls = []
    # get xls file's path
    xlsPath = os.path.join(proDir, "testFile", xls_name)
    # open xls file
    file = open_workbook(xlsPath)
    # get sheet by name
    sheet = file.sheet_by_name(sheet_name)
    # get one sheet's rows
    nrows = sheet.nrows
    for i in range(nrows):
        if sheet.row_values(i)[0] != u'case_name':
            cls.append(sheet.row_values(i))
    return cls
# Read SQL statements from xml file
database = {}
def set_xml():
    if len(database) == 0:
        sql_path = os.path.join(proDir, "testFile", "SQL.xml")
        tree = ElementTree.parse(sql_path)
        for db in tree.findall("database"):
            db_name = db.get("name")
            # print(db_name)
            table = {}
            for tb in db.getchildren():
                table_name = tb.get("name")
                # print(table_name)
                sql = {}
                for data in tb.getchildren():
                    sql_id = data.get("id")
                    # print(sql_id)
                    sql[sql_id] = data.text
                table[table_name] = sql
            database[db_name] = table

def get_xml_dict(database_name, table_name):
    set_xml()
    database_dict = database.get(database_name).get(table_name)
    return database_dict

def get_sql(database_name, table_name, sql_id):
    db = get_xml_dict(database_name, table_name)
    sql = db.get(sql_id)
    return sql
Here are the two main contents of our common file. What? You still don’t know what they are? Let me tell you:
  • We use xml.etree.Element to operate on XML files, and through our custom methods, we can get different values based on different parameters.

  • We use xlrd to operate on Excel files. Note that we manage test cases using Excel files.

Does it sound a bit confusing? I was also very confused when I first learned it, but it becomes easier to understand when you see the files.
Excel File:
Python Requests API Automation Testing Framework Tutorial
XML File:
Python Requests API Automation Testing Framework Tutorial
As for the specific methods, I won’t explain them one by one; I feel that everyone understands (I just started learning, please forgive me), but I personally need to document them in detail for easier review in the future.
Next, let’s take a look at the database and sending emails (this part can be omitted based on needs).
Let’s first look at our old friend, the “database”.
This time, I am using a MySQL database, so let’s take it as an example.
import pymysql
import readConfig as readConfig
from common.Log import MyLog as Log
localReadConfig = readConfig.ReadConfig()
class MyDB:
    global host, username, password, port, database, config
    host = localReadConfig.get_db("host")
    username = localReadConfig.get_db("username")
    password = localReadConfig.get_db("password")
    port = localReadConfig.get_db("port")
    database = localReadConfig.get_db("database")
    config = {
        'host': str(host),
        'user': username,
        'passwd': password,
        'port': int(port),
        'db': database
    }
    def __init__(self):
        self.log = Log.get_log()
        self.logger = self.log.get_logger()
        self.db = None
        self.cursor = None
    def connectDB(self):
        try:
            # connect to DB
            self.db = pymysql.connect(**config)
            # create cursor
            self.cursor = self.db.cursor()
            print("Connect DB successfully!")
        except ConnectionError as ex:
            self.logger.error(str(ex))
    def executeSQL(self, sql, params):
        self.connectDB()
        # executing sql
        self.cursor.execute(sql, params)
        # executing by committing to DB
        self.db.commit()
        return self.cursor
    def get_all(self, cursor):
        value = cursor.fetchall()
        return value
    def get_one(self, cursor):
        value = cursor.fetchone()
        return value
    def closeDB(self):
        self.db.close()
        print("Database closed!")
This is the complete database file. Since my requirements for database operations are not very complex, these are basically sufficient. Note that before this, please make sure to install pymysql! pymysql must be installed! pymysql must be installed! (This is important, so I repeat it three times). The installation method is simple; since I use pip to manage Python package installations, just navigate to the pip folder in the Python installation path and execute the following command:
pip install pymysql
Haha, now we can use Python to connect to the database! (Let’s applaud and celebrate).
Have you noticed that throughout the file, we haven’t used any specific variable values? Why? That’s right, because we wrote the config.ini file earlier, and all the database configuration information is in that file. Isn’t it convenient? In the future, even if the database changes, we only need to modify the contents of the config.ini file. Combined with the management of test cases (Excel files), storage of SQL statements (XML files), and the upcoming businessCommon.py and the folder storing specific cases, we have effectively separated data from business logic. Haha, just think about it; when modifying test case content or SQL statements in the future, we no longer need to modify each case individually; we only need to change a few fixed files. Isn’t that instantly uplifting?
Returning to the configDB.py file, the content is straightforward, and I believe everyone can understand it. It connects to the database, executes SQL, retrieves results, and finally closes the database. There’s nothing different about it. Anyone who has questions can check this link for learning: http://www.runoob.com/python/python-mysql.html
Now, let’s talk about emails. Have you ever encountered the issue where after every test, you need to send a test report to the developers? For a lazy person like me, I don’t want to keep bothering the developers, so I thought: after each test, we can let the program automatically send an email to the developers, informing them that the testing is complete and attaching the test report. Wouldn’t that be great?
This is how configEmail.py came into being. Ta-da… please see:
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from datetime import datetime
import threading
import readConfig as readConfig
from common.Log import MyLog
import zipfile
import glob
localReadConfig = readConfig.ReadConfig()
class Email:
    def __init__(self):
        global host, user, password, port, sender, title, content
        host = localReadConfig.get_email("mail_host")
        user = localReadConfig.get_email("mail_user")
        password = localReadConfig.get_email("mail_pass")
        port = localReadConfig.get_email("mail_port")
        sender = localReadConfig.get_email("sender")
        title = localReadConfig.get_email("subject")
        content = localReadConfig.get_email("content")
        self.value = localReadConfig.get_email("receiver")
        self.receiver = []
        # get receiver list
        for n in str(self.value).split("/"):
            self.receiver.append(n)
        # defined email subject
        date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        self.subject = title + " " + date
        self.log = MyLog.get_log()
        self.logger = self.log.get_logger()
        self.msg = MIMEMultipart('mixed')
    def config_header(self):
        self.msg['subject'] = self.subject
        self.msg['from'] = sender
        self.msg['to'] = ";".join(self.receiver)
    def config_content(self):
        content_plain = MIMEText(content, 'plain', 'utf-8')
        self.msg.attach(content_plain)
    def config_file(self):
        # if the file content is not null, then config the email file
        if self.check_file():
            reportpath = self.log.get_result_path()
            zippath = os.path.join(readConfig.proDir, "result", "test.zip")
            # zip file
            files = glob.glob(reportpath + '\*')
            f = zipfile.ZipFile(zippath, 'w', zipfile.ZIP_DEFLATED)
            for file in files:
                f.write(file)
            f.close()
            reportfile = open(zippath, 'rb').read()
            filehtml = MIMEText(reportfile, 'base64', 'utf-8')
            filehtml['Content-Type'] = 'application/octet-stream'
            filehtml['Content-Disposition'] = 'attachment; filename="test.zip"'
            self.msg.attach(filehtml)
    def check_file(self):
        reportpath = self.log.get_report_path()
        if os.path.isfile(reportpath) and not os.stat(reportpath) == 0:
            return True
        else:
            return False
    def send_email(self):
        self.config_header()
        self.config_content()
        self.config_file()
        try:
            smtp = smtplib.SMTP()
            smtp.connect(host)
            smtp.login(user, password)
            smtp.sendmail(sender, self.receiver, self.msg.as_string())
            smtp.quit()
            self.logger.info("The test report has been sent to the developer by email.")
        except Exception as ex:
            self.logger.error(str(ex))
class MyEmail:
    email = None
    mutex = threading.Lock()
    def __init__(self):
        pass
    @staticmethod
    def get_email():
        if MyEmail.email is None:
            MyEmail.mutex.acquire()
            MyEmail.email = Email()
            MyEmail.mutex.release()
        return MyEmail.email
if __name__ == "__main__":
    email = MyEmail.get_email()
This is the complete file content. However, unfortunately, I encountered a problem that remains unsolved. I am here to seek help from the experts!
Problem: Using the 163 free email server to send emails, but every time I send an email, it gets bounced back by the 163 email server with error code: 554
The official explanation is as follows:
Python Requests API Automation Testing Framework Tutorial
However, before integrating the email into this framework, the demo I wrote for sending emails worked normally. This issue has been troubling me, and I hope the experts can help me solve it.
We are not far from success; let me briefly explain the HTMLTestRunner.py file. This file was not written by me; I merely transported it. Haha, this file was downloaded from the internet, written by an expert, and is used to generate HTML format test reports. What? Want to know what the generated test report looks like? Alright, let me satisfy your curiosity:
Python Requests API Automation Testing Framework Tutorial
Looks good, right? Well, smart people can explore this file themselves, modify it, and change it to their own style!
Alright, the main event is here, which is our runAll.py. Please see the main character come on stage.
This is the entry point for our entire framework. After completing the above content, this is the last step. Once we write it, our framework will be complete. (Applause and flowers~)
import unittest
import HTMLTestRunner

def set_case_list(self):
    fb = open(self.caseListFile)
    for value in fb.readlines():
        data = str(value)
        if data != '' and not data.startswith("#"):
            self.caseList.append(data.replace("\n", ""))
    fb.close()

def set_case_suite(self):
    self.set_case_list()
    test_suite = unittest.TestSuite()
    suite_model = []
    for case in self.caseList:
        case_file = os.path.join(readConfig.proDir, "testCase")
        print(case_file)
        case_name = case.split("/")[-1]
        print(case_name + ".py")
        discover = unittest.defaultTestLoader.discover(case_file, pattern=case_name + '.py', top_level_dir=None)
        suite_model.append(discover)
    if len(suite_model) > 0:
        for suite in suite_model:
            for test_name in suite:
                test_suite.addTest(test_name)
    else:
        return None
    return test_suite

def run(self):
    try:
        suit = self.set_case_suite()
        if suit is not None:
            logger.info("********TEST START********")
            fp = open(resultPath, 'wb')
            runner = HTMLTestRunner.HTMLTestRunner(stream=fp, title='Test Report', description='Test Description')
            runner.run(suit)
        else:
            logger.info("Have no case to test.")
    except Exception as ex:
        logger.error(str(ex))
    finally:
        logger.info("*********TEST END*********")
        # send test report by email
        if int(on_off) == 0:
            self.email.send_email()
        elif int(on_off) == 1:
            logger.info("Doesn't send report email to developer.")
        else:
            logger.info("Unknown state.")
Above, I have pasted the main parts of runAll. First, we need to read the names of the cases to be executed from the caselist.txt file, then add them to the Python built-in unittest test suite, and finally execute the run() function to run the test suite.

Finally, the entire API automation framework has been explained. Do you understand it? What? The files listed in the directory structure mentioned earlier have not been discussed?

Hehe, I believe you can probably guess the roles of the remaining folders. Hmm~ After thinking for a long time, I decided to briefly talk about them. Let’s look at the images, which are simple and clear:
Python Requests API Automation Testing Framework Tutorial
The result folder will be created during the first execution of the case, and all subsequent test results will be stored in that folder. Each test folder is named using the system time and contains two files: a log file and a test report.
Python Requests API Automation Testing Framework Tutorial
The testCase folder stores the specific test cases that we write. The above are some that I have written. Note that all case names must start with test, because unittest automatically matches all .py files that start with test in the testCase folder during testing.
Python Requests API Automation Testing Framework Tutorial
The testFile folder contains the Excel files we use to manage test cases and the XML files used for SQL queries.
Finally, there is the caselist.txt file. Let me give you a glimpse:
Python Requests API Automation Testing Framework Tutorial
All case names that are not commented out will be executed. You just need to write the names of the cases you want to execute here.
Phew~ Taking a long breath, I have finally completed the entire process. I believe that those who have persevered through this will definitely gain something. Here, I want to say seriously: I hope the experts can provide solutions to the email issues mentioned in the article!!!

This article is transferred from: https://my.oschina.net/u/3041656/blog/820023

——————— End ———————

Testing Expert Show 5000 People QQ Group:
QQ Group Number: 636803769 Group Entry Password: Wuhan, cheer up! China, cheer up!
Testing Expert Show WeChat Group:
Please add the group owner WeChat 1327239410 and reply with the number 2
Video Open Class:
Scan the poster below with DingTalk to enter the live broadcast group!

Python Requests API Automation Testing Framework Tutorial

Leave a Comment