A cross-browser (Chrome / Firefox / Edge) UI test framework built with Selenium 4, pytest, and Allure reporting, following the Page Object Model (POM). Deliberately kept to a single worked example - reading a web page's title - so the whole framework can be read end to end.
- Page Object Model: an object repository (
pages/) keeps every locator and UI interaction method separate from the test scripts. Test files never calldriver.find_elementor reference aBylocator directly - they only call methods on a page object. - Cross-browser: run against Chrome, Firefox, or Edge via a CLI flag,
using manually installed driver binaries (see
drivers/). - Screenshots on failure: automatically captured and attached to the Allure report, along with the failing page's URL and HTML source.
- Allure reporting, generated automatically: every test run produces a fresh Allure HTML report with no extra command needed (see below).
- Logging: every run writes a timestamped log file to
logs/. - Data-driven ready:
utils/data_reader.pyreads test data from CSV (csvmodule), Excel (openpyxl), JSON (jsonmodule), and YAML (PyYAML) - no pandas dependency - currently unused by the single example, ready for when a data-driven test is added. - Single source of truth for the URL: the target URL (
APP_URL) lives only inconftest.py. Thedriverfixture navigates there automatically before every test, so test files never hardcode or reference it. - CI/CD ready: a
Jenkinsfileruns the same tests in Jenkins (see "Jenkins CI/CD Integration" below). - Email notifications: an opt-in summary email (total/passed/failed and percentages), sent by the exact same code whether triggered locally or from Jenkins (see "Email Notifications" below).
selenium_test_framework/
├── conftest.py # fixtures, CLI options, screenshot & Allure-report hooks
├── pytest.ini
├── requirements.txt
├── Jenkinsfile # Jenkins declarative pipeline (see "Jenkins CI/CD" below)
├── base/
│ └── base_driver.py # BaseDriver - common driver handle + logger shared by all page objects
├── pages/
│ └── title_page.py # TitlePage(BaseDriver) - the framework's single page object
├── utils/
│ ├── logger.py # shared logging setup
│ ├── data_reader.py # CSV / Excel / JSON / YAML test data readers (no pandas)
│ └── email_report.py # total/passed/failed summary email (see "Email Notifications" below)
├── test_data/
│ └── README.md # empty for now - see "Adding Test Data" below
├── tests/
│ └── test_title.py # the framework's single test - uses the `title_page` fixture only
├── drivers/ # manually installed driver binaries go here
│ └── README.md
├── logs/ # generated per run
├── screenshots/ # generated on failure
└── reports/
├── allure-results/ # raw results (generated every run)
└── allure-report/ # HTML report (generated every run, automatically)
base/base_driver.py—BaseDriverholds the sharedself.driverhandle and aself.logger. Every page object inherits from it.pages/— one class per page of the application. Each class holds that page's locators (as class attributes) and the methods used to interact with it.pages/title_page.pyis the framework's only page object right now, exposingget_title().tests/— test scripts depend on a page-object fixture (title_page, defined inconftest.py) and only call its methods. If the UI changes, only the page object needs updating - the tests stay untouched.
To add a new page: create pages/<new_page>.py with a class inheriting
BaseDriver, add its locators and methods - that's it, no changes to
conftest.py needed. Use it in a test via the generic page factory
fixture:
from pages.login_page import LoginPage
def test_login(page):
login = page(LoginPage)
login.enter_username("...")page(SomeClass) builds the page object with the current driver, calls
its wait_for_page_to_load() if it defines one, and caches it per test.
This is what lets the framework scale to many pages without conftest.py
accumulating one near-identical fixture per page. title_page still
exists as a named convenience fixture (it's just page(TitlePage)
under the hood) purely for readability in the existing test - it's
optional, not a pattern you need to repeat for every new page.
-
Create and activate a virtual environment (recommended):
python3 -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate
-
Install dependencies:
pip install -r requirements.txt
-
Install the Allure command-line tool. This is required both to view the report and for the framework's auto-generation hook to build it (result collection itself is handled by
allure-pytest):- macOS:
brew install allure - Windows:
scoop install allure - Linux / manual: see https://allurereport.org/docs/install/
- macOS:
-
Driver binaries are manual — this framework does not auto-download them. Download the driver matching your installed browser version and place it in
drivers/using the exact filename below (seedrivers/README.mdfor links):Browser File to place in drivers/Chrome chromedriver.exeFirefox geckodriver.exeEdge msedgedriver.exeIf a browser isn't installed in its default Windows location, set its path in
BROWSER_BINARY_PATHSat the top ofconftest.py.If a required driver binary is missing, the framework fails fast with a clear error naming the exact file it expected and where.
pytest.ini already passes --alluredir=reports/allure-results by
default, so you don't need to add that flag yourself.
Run everything against Chrome (default):
pytestRun against a specific browser:
pytest --browser=firefox
pytest --browser=edgeRun headless (useful in CI):
pytest --browser=chrome --headlessRun only smoke tests:
pytest -m smokeTested against Jenkins 2.375.3 with the Pipeline, Git, and Allure
Jenkins plugins installed, running a Windows agent (matching the rest of
this framework's Windows-specific setup - drivers/ binaries, bat
steps).
- Jenkins 2.375.3 (or compatible) up and running.
- Python (matching the version this framework was built against) on the agent's PATH.
- Chrome, Firefox, and/or Edge installed on the agent, with the matching
driver binaries already placed in
drivers/inside the job's workspace (or committed into the repository the job checks out). - The Allure commandline tool installed on the agent, or installable via Jenkins' own tool auto-installer (see step 2).
- Manage Jenkins → Plugins → Available plugins.
- Search for and install: Allure Jenkins Plugin (Pipeline and Git plugins are bundled with Jenkins by default in most installations; install them too if missing).
- Restart Jenkins if prompted.
- Manage Jenkins → Tools → Allure Commandline installations → Add
Allure Commandline. Give it a name (e.g.
allure) and either point it at an existing installation or tick "Install automatically".
- New Item → enter a job name → select Pipeline → OK.
- Under Pipeline, set Definition to Pipeline script from SCM.
- Set SCM to Git, enter the repository URL and credentials, and set the branch to build.
- Leave Script Path as
Jenkinsfile(the default) - the pipeline definition already sits at the project root. - Save.
- Click Build with Parameters (this appears after the first run,
once Jenkins has read the
parametersblock in theJenkinsfile- the very first build may need to be triggered once with Build Now before the parameterised form appears). - Choose BROWSER (
chrome/firefox/edge) and whether to run HEADLESS, then Build.
- The build page shows an Allure Report link (added by the Allure Jenkins plugin), including trend graphs across builds.
- Console Output shows the same pytest terminal output you would see running locally.
- Screenshots and logs for the run are available under the
build's Build Artifacts, archived by the
post { always { ... } }block in theJenkinsfile.
Note that the framework's own pytest_sessionfinish hook (see
conftest.py) still tries to build reports/allure-report/ locally on
the agent after every run, exactly as it does outside Jenkins - this is
harmless and independent of the Jenkins-hosted Allure report the plugin
publishes.
utils/email_report.py can send a summary email (total tests, passed,
failed, and the passed/failed percentages) after a run finishes, to
fullstack_tester@zohomail.in. The same code runs identically whether
pytest is invoked locally or from Jenkins - Jenkins just runs the same
pytest command with the same flag.
It is opt-in via --send-email, and reads SMTP credentials from two
environment variables rather than anything hardcoded:
EMAIL_SENDER- the Zoho Mail address to send from.EMAIL_PASSWORD- that account's SMTP (app-specific) password.
Running locally:
set EMAIL_SENDER=your_address@zohomail.in
set EMAIL_PASSWORD=your_smtp_app_password
pytest --send-email(PowerShell: use $env:EMAIL_SENDER = "..." instead of set.)
If either variable is missing, the run still completes normally - the hook logs a warning and skips sending, rather than failing the test run.
Running via Jenkins: the Jenkinsfile already passes --send-email
and supplies EMAIL_SENDER / EMAIL_PASSWORD from a Jenkins credential
named zoho-email-credentials (a "Username and password" credential:
username = sender address, password = SMTP app password). Create that
credential under Manage Jenkins → Credentials before running the
pipeline; no other setup or code change is needed.
At the end of every pytest run, conftest.py's pytest_sessionfinish
hook shells out to the Allure commandline tool and rebuilds
reports/allure-report/ from the fresh results in
reports/allure-results/ — no manual allure generate step needed.
Open the report after a run:
allure open reports/allure-report(or allure serve reports/allure-results to build and open in one step.)
If the allure command isn't found on your PATH, the run still
completes and raw results still land in reports/allure-results/ - the
framework logs a warning instead of failing the run, and you can
generate the HTML report manually once Allure is installed.
Each test run writes a fresh log file to logs/test_run_<timestamp>.log,
in addition to echoing to the console.
Any failing test automatically has a screenshot saved to screenshots/
and attached to the Allure report, along with the page URL and full page
source at the moment of failure — useful for debugging without needing
to rerun the test.
- CSV: drop a
.csvfile intotest_data/and read it withutils.data_reader.read_csv_data("your_file.csv"). - Excel: drop a
.xlsxfile intotest_data/and read it withutils.data_reader.read_excel_data("your_file.xlsx", sheet_name="Sheet1")(omitsheet_nameto use the active sheet). - JSON: drop a
.jsonfile intotest_data/and read it withutils.data_reader.read_json_data("your_file.json"). A list root is returned as-is; a single object root is wrapped in a one-item list. - YAML: drop a
.yaml/.ymlfile intotest_data/and read it withutils.data_reader.read_yaml_data("your_file.yaml")(same wrapping rule as JSON). - All four return the same shape (
list[dict], one dict per row/record), so they can be fed straight into@pytest.mark.parametrize. Or callutils.data_reader.read_test_data("your_file.<ext>")and let it pick the right reader from the file extension automatically.