Frontend Development 32 min read

Building a Selenium + Pytest Automation Framework in Python

This tutorial walks through creating a modular Selenium automation framework with Pytest, covering project structure, utility modules, configuration handling, logging, Page Object Model implementation, test case writing, HTML reporting, and email distribution, all illustrated with complete Python code examples.

Python Programming Learning Circle
Python Programming Learning Circle
Python Programming Learning Circle
Building a Selenium + Pytest Automation Framework in Python

Preface

Selenium automation + Pytest testing framework.

Prerequisites for this chapter:

A basic understanding of Python classes, objects, and inheritance.

A basic understanding of Selenium; if you are unfamiliar, refer to the Chinese Selenium documentation.

Test Framework Overview

Advantages of a test framework: 1. High code reuse – without a framework the code becomes redundant. 2. Ability to assemble logs, reports, emails, and other advanced features. 3. Improves maintainability of elements; when an element changes only the config file needs updating. 4. Flexible PageObject design pattern.

Overall directory layout (illustrated below).

The simple framework structure is now clear.

Let's start building!

First, create the project directories as shown above.

Note: Every Python package directory must contain an __init__.py file.

Managing Time

Many modules need timestamps or date strings, so we encapsulate time utilities in utils/times.py :

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import time
import datetime
from functools import wraps

def timestamp():
    """Timestamp"""
    return time.time()

def dt_strftime(fmt="%Y%m"):
    """
    datetime formatting
    :param fmt "%Y%m%d %H%M%S"
    """
    return datetime.datetime.now().strftime(fmt)

def sleep(seconds=1.0):
    """Sleep time"""
    time.sleep(seconds)

def running_time(func):
    """Function execution time"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = timestamp()
        res = func(*args, **kwargs)
        print("Check element done! Time %.3f seconds!" % (timestamp() - start))
        return res
    return wrapper

if __name__ == '__main__':
    print(dt_strftime("%Y%m%d%H%M%S"))
</code>

Adding Configuration Files

Configuration files are essential for any project.

We create config/conf.py to manage project directories and constants:

conf.py

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import os
from selenium.webdriver.common.by import By
from utils.times import dt_strftime

class ConfigManager(object):
    # Project root directory
    BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    # Page element directory
    ELEMENT_PATH = os.path.join(BASE_DIR, 'page_element')
    # Report file
    REPORT_FILE = os.path.join(BASE_DIR, 'report.html')
    # Element locating strategies
    LOCATE_MODE = {
        'css': By.CSS_SELECTOR,
        'xpath': By.XPATH,
        'name': By.NAME,
        'id': By.ID,
        'class': By.CLASS_NAME
    }
    # Email settings (replace with your own)
    EMAIL_INFO = {
        'username': '[email protected]',
        'password': 'QQ email authorization code',
        'smtp_host': 'smtp.qq.com',
        'smtp_port': 465
    }
    # Recipients list
    ADDRESSEE = ['[email protected]']

    @property
    def log_file(self):
        """Log directory"""
        log_dir = os.path.join(self.BASE_DIR, 'logs')
        if not os.path.exists(log_dir):
            os.makedirs(log_dir)
        return os.path.join(log_dir, f"{dt_strftime()}.log")

    @property
    def ini_file(self):
        """Configuration file"""
        ini_file = os.path.join(self.BASE_DIR, 'config', 'config.ini')
        if not os.path.exists(ini_file):
            raise FileNotFoundError(f"Configuration file {ini_file} does not exist!")
        return ini_file

cm = ConfigManager()

if __name__ == '__main__':
    print(cm.BASE_DIR)
</code>
Note: The QQ email authorization code can be found in QQ mail help. This conf.py mimics Django's settings.py style with some differences.

In the config directory we also create config.ini to store the URL to be tested:

<code>[HOST]
HOST = https://www.baidu.com
</code>

Reading Configuration Files

We create common/readconfig.py to read config.ini using Python's built‑in configparser :

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import configparser
from config.conf import cm

HOST = 'HOST'

class ReadConfig(object):
    """Configuration file wrapper"""
    def __init__(self):
        self.config = configparser.RawConfigParser()
        self.config.read(cm.ini_file, encoding='utf-8')
    def _get(self, section, option):
        """Get value"""
        return self.config.get(section, option)
    def _set(self, section, option, value):
        """Set value"""
        self.config.set(section, option, value)
        with open(cm.ini_file, 'w') as f:
            self.config.write(f)
    @property
    def url(self):
        return self._get(HOST, HOST)

ini = ReadConfig()

if __name__ == '__main__':
    print(ini.url)
</code>

Running this shows that the URL is correctly read.

Recording Operation Logs

We add utils/logger.py to log test steps:

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import logging
from config.conf import cm

class Log:
    def __init__(self):
        self.logger = logging.getLogger()
        if not self.logger.handlers:
            self.logger.setLevel(logging.DEBUG)
            fh = logging.FileHandler(cm.log_file, encoding='utf-8')
            fh.setLevel(logging.INFO)
            ch = logging.StreamHandler()
            ch.setLevel(logging.INFO)
            formatter = logging.Formatter(self.fmt)
            fh.setFormatter(formatter)
            ch.setFormatter(formatter)
            self.logger.addHandler(fh)
            self.logger.addHandler(ch)
    @property
    def fmt(self):
        return '%(levelname)s\t%(asctime)s\t[%(filename)s:%(lineno)d]\t%(message)s'

log = Log().logger

if __name__ == '__main__':
    log.info('hello world')
</code>

Running the file prints an INFO line and creates a monthly log file.

Simple Understanding of the POM Model

The Page Object Model (POM) offers several benefits:

Source: "Selenium Automation Testing – Based on Python"

Encapsulating page objects reduces the impact of UI changes on tests.

Reusable code across multiple test cases.

Test code becomes more readable, flexible, and maintainable.

Key components of a POM implementation:

basepage – Selenium base class that wraps common Selenium methods.

pageelements – Separate file storing element locators.

searchpage – Page object class that combines Selenium actions with element locators.

testcase – Pytest test cases that use the page object.

By splitting these four parts, code duplication is reduced and maintainability improves, especially when the number of test cases grows.

Simple Element Locating

Copy‑paste XPaths from the browser are often unstable; small front‑end changes can break them. Therefore, we should strengthen our locating skills and prefer stable strategies such as XPath and CSS selectors. Because CSS syntax can be hard for beginners, we choose XPath for this tutorial.

XPath

Syntax Rules

XPath is a language for navigating XML documents.

Locating Tools

ChroPath – Chrome plugin similar to Firepath; user‑friendly but requires VPN.

Katalon Recorder – Generates scripts with element locators.

Write your own – Recommended for experienced users; offers concise and clear locators.

Managing Page Elements

This tutorial uses Baidu's homepage as the test target.

All element locators are stored in the page_element directory. We choose YAML format for its readability.

Example search.yaml :

<code>搜索框: "id==kw"
候选: "css==.bdsug-overflow"
搜索候选: "css==#form div li"
搜索按钮: "id==su"
</code>

To read these files we create common/readelement.py :

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import os
import yaml
from config.conf import cm

class Element(object):
    """Element loader"""
    def __init__(self, name):
        self.file_name = f"{name}.yaml"
        self.element_path = os.path.join(cm.ELEMENT_PATH, self.file_name)
        if not os.path.exists(self.element_path):
            raise FileNotFoundError(f"{self.element_path} does not exist!")
        with open(self.element_path, encoding='utf-8') as f:
            self.data = yaml.safe_load(f)
    def __getitem__(self, item):
        """Get locator"""
        data = self.data.get(item)
        if data:
            name, value = data.split('==')
            return name, value
        raise ArithmeticError(f"{self.file_name} does not contain key: {item}")

if __name__ == '__main__':
    search = Element('search')
    print(search['搜索框'])
</code>

We also provide an inspection script script/inspect.py to validate all YAML element files:

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import os
import yaml
from config.conf import cm
from utils.times import running_time

@running_time
def inspect_element():
    """Simple validation of element files"""
    for files in os.listdir(cm.ELEMENT_PATH):
        _path = os.path.join(cm.ELEMENT_PATH, files)
        with open(_path, encoding='utf-8') as f:
            data = yaml.safe_load(f)
        for k in data.values():
            try:
                pattern, value = k.split('==')
            except ValueError:
                raise Exception("Element expression missing '=='")
            if pattern not in cm.LOCATE_MODE:
                raise Exception(f"%s element [%s] missing type" % (_path, k))
            if pattern == 'xpath':
                assert '//' in value, f"%s element [%s] xpath invalid" % (_path, k)
            elif pattern == 'css':
                assert '//' not in value, f"%s element [%s] css invalid" % (_path, k)
            else:
                assert value, f"%s element [%s] type/value mismatch" % (_path, k)

if __name__ == '__main__':
    inspect_element()
</code>

Running the script quickly validates all element definitions.

Encapsulating Selenium Base Class

Raw Selenium code is fragile; we wrap common actions with explicit waits and logging.

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
"""Selenium base class"""
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import TimeoutException
from config.conf import cm
from utils.times import sleep
from utils.logger import log

class WebPage(object):
    """Base class for Selenium operations"""
    def __init__(self, driver):
        self.driver = driver
        self.timeout = 20
        self.wait = WebDriverWait(self.driver, self.timeout)
    def get_url(self, url):
        """Open URL with verification"""
        self.driver.maximize_window()
        self.driver.set_page_load_timeout(60)
        try:
            self.driver.get(url)
            self.driver.implicitly_wait(10)
            log.info("Open page: %s" % url)
        except TimeoutException:
            raise TimeoutException("Opening %s timed out, check network or server" % url)
    @staticmethod
    def element_locator(func, locator):
        """Locate element using configured strategy"""
        name, value = locator
        return func(cm.LOCATE_MODE[name], value)
    def find_element(self, locator):
        """Find a single element"""
        return WebPage.element_locator(lambda *args: self.wait.until(EC.presence_of_element_located(args)), locator)
    def find_elements(self, locator):
        """Find multiple elements"""
        return WebPage.element_locator(lambda *args: self.wait.until(EC.presence_of_all_elements_located(args)), locator)
    def elements_num(self, locator):
        """Count elements"""
        number = len(self.find_elements(locator))
        log.info("Elements count: {}".format((locator, number)))
        return number
    def input_text(self, locator, txt):
        """Clear and input text"""
        sleep(0.5)
        ele = self.find_element(locator)
        ele.clear()
        ele.send_keys(txt)
        log.info("Input text: {}".format(txt))
    def is_click(self, locator):
        """Click element"""
        self.find_element(locator).click()
        sleep()
        log.info("Click element: {}".format(locator))
    def element_text(self, locator):
        """Get element text"""
        _text = self.find_element(locator).text
        log.info("Get text: {}".format(_text))
        return _text
    @property
    def get_source(self):
        """Page source"""
        return self.driver.page_source
    def refresh(self):
        """Refresh page (F5)"""
        self.driver.refresh()
        self.driver.implicitly_wait(30)
</code>

The class uses explicit waits for click, send_keys, etc., improving stability.

Creating Page Objects

We now create page_object/searchpage.py that uses the element loader and the base class:

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
from page.webpage import WebPage, sleep
from common.readelement import Element

search = Element('search')

class SearchPage(WebPage):
    """Search page actions"""
    def input_search(self, content):
        """Enter search keyword"""
        self.input_text(search['搜索框'], txt=content)
        sleep()
    @property
    def imagine(self):
        """Search suggestions"""
        return [x.text for x in self.find_elements(search['候选'])]
    def click_search(self):
        """Click the search button"""
        self.is_click(search['搜索按钮'])
</code>

Comments are added to improve readability.

Simple Introduction to Pytest

Visit the official Pytest website: http://www.pytest.org/en/latest/

<code># content of test_sample.py
def inc(x):
    return x + 1

def test_answer():
    assert inc(3) == 5
</code>

pytest.ini

Project‑wide configuration for Pytest:

<code>[pytest]
addopts = --html=report.html --self-contained-html
</code>

Explanation of addopts options:

--html=report.html – generate an HTML report with styling.

-s – show print statements.

-q – quiet mode.

-v – verbose output (file and test name).

Writing Test Cases

We create TestCase/test_search.py :

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import re
import pytest
from utils.logger import log
from common.readconfig import ini
from page_object.searchpage import SearchPage

class TestSearch:
    @pytest.fixture(scope='function', autouse=True)
    def open_baidu(self, drivers):
        """Open Baidu"""
        search = SearchPage(drivers)
        search.get_url(ini.url)

    def test_001(self, drivers):
        """Search"""
        search = SearchPage(drivers)
        search.input_search("selenium")
        search.click_search()
        result = re.search(r'selenium', search.get_source)
        log.info(result)
        assert result

    def test_002(self, drivers):
        """Test search suggestions"""
        search = SearchPage(drivers)
        search.input_search("selenium")
        log.info(list(search.imagine))
        assert all(["selenium" in i for i in search.imagine])

if __name__ == '__main__':
    pytest.main(['TestCase/test_search.py'])
</code>

The two test cases:

Test 001 – Search for "selenium" on Baidu, click the button, and verify the result page contains the keyword.

Test 002 – Verify that all suggestion items contain the keyword "selenium".

conftest.py

We add a fixture that creates a shared Selenium driver for the whole session and integrates screenshots into the HTML report:

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import pytest
from py.xml import html
from selenium import webdriver

driver = None

@pytest.fixture(scope='session', autouse=True)
def drivers(request):
    global driver
    if driver is None:
        driver = webdriver.Chrome()
        driver.maximize_window()
    def fn():
        driver.quit()
    request.addfinalizer(fn)
    return driver

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item):
    """Capture screenshot on failure and embed in HTML report"""
    pytest_html = item.config.pluginmanager.getplugin('html')
    outcome = yield
    report = outcome.get_result()
    report.description = str(item.function.__doc__)
    extra = getattr(report, 'extra', [])
    if report.when in ('call', 'setup'):
        xfail = hasattr(report, 'wasxfail')
        if (report.skipped and xfail) or (report.failed and not xfail):
            file_name = report.nodeid.replace('::', '_') + '.png'
            screen_img = _capture_screenshot()
            if file_name:
                html_img = '<div><img src="data:image/png;base64,%s" alt="screenshot" style="width:1024px;height:768px;" onclick="window.open(this.src)" align="right"/></div>' % screen_img
                extra.append(pytest_html.extras.html(html_img))
        report.extra = extra

def pytest_html_results_table_header(cells):
    cells.insert(1, html.th('Test Name'))
    cells.insert(2, html.th('Test NodeID'))
    cells.pop(2)

def pytest_html_results_table_row(report, cells):
    cells.insert(1, html.td(report.description))
    cells.insert(2, html.td(report.nodeid))
    cells.pop(2)

def pytest_html_results_table_html(report, data):
    if report.passed:
        del data[:]
        data.append(html.div('Passed test – no log output captured.', class_='empty log'))

def _capture_screenshot():
    '''Capture screenshot as base64''' 
    return driver.get_screenshot_as_base64()
</code>

Running the Tests

Execute the test suite from the project root:

<code>pytest</code>

Sample output shows both tests passed and an HTML report is generated at report/report.html .

Sending Email

After the test run we can email the report using utils/send_mail.py :

<code>#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import zmail
from config.conf import cm

def send_report():
    """Send the HTML report via email"""
    with open(cm.REPORT_FILE, encoding='utf-8') as f:
        content_html = f.read()
    try:
        mail = {
            'from': '[email protected]',
            'subject': 'Latest Test Report',
            'content_html': content_html,
            'attachments': [cm.REPORT_FILE]
        }
        server = zmail.server(*cm.EMAIL_INFO.values())
        server.send_mail(cm.ADDRESSEE, mail)
        print('Test email sent successfully!')
    except Exception as e:
        print('Error: Unable to send email, %s!' % e)

if __name__ == '__main__':
    '''Configure QQ email account and password in config/conf.py before running''' 
    send_report()
</code>

Running the script prints a success message and the report arrives in the inbox.

Allure Report Generation

Allure report generation is covered in another blog post (link provided):

https://www.cnblogs.com/wxhou/p/13160922.html

Open‑Source Repository

The complete demo project is open‑sourced on Gitee:

https://gitee.com/wxhou/web-demotest

Original article link:

https://www.cnblogs.com/wxhou/p/selenium-pytest-test-framework.html

长按或扫描下方二维码,免费获取Python公开课和几百GB的学习资料,包括电子书、教程、项目源码等。

Scan the QR code to receive the free Python course and resources.

Recommended reading:

Python Movie Ticket Booking System

Python argparse Guide

Python 50 Essential Functions

Build a Simple Desktop Calculator with Python

Click "Read Original" to learn more.

PythonAutomationtestingframeworkSeleniumpytestPageObject
Python Programming Learning Circle
Written by

Python Programming Learning Circle

A global community of Chinese Python developers offering technical articles, columns, original video tutorials, and problem sets. Topics include web full‑stack development, web scraping, data analysis, natural language processing, image processing, machine learning, automated testing, DevOps automation, and big data.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.