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.
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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.