Framework Integration: pytest, JUnit, TestNG, NUnit, and Language Bindings: Guided Hands-On Workflow
Implement one canonical browser behavior with Python and pytest, then compare equivalent lifecycle, wait, assertion, and async patterns in other official Selenium bindings.
Learning objectives
- Run a disposable loopback AUT with one canonical behavior specification.
- Implement the canonical flow with Selenium Python 4.47.0 and pytest 9.1.1.
- Compare setup/teardown, waits, assertions, and asynchronous calls in Java, .NET, and JavaScript.
- Prove that framework syntax changes while browser meaning remains constant.
- Choose the correct framework or WebDriver control in a short migration challenge.
1. Disposable workflow and preflight
The lab uses one static loopback application and synthetic names. No
account, database, proxy, paid Grid, or production endpoint is
required. Use a clean working directory, verify Python 3.10+, and
install the primary path with
python -m pip install selenium==4.47.0 pytest==9.1.1.
Keep BASE_URL at
http://127.0.0.1:8777/ for the mandatory exercise. Do
not substitute a public application just to make the examples
“more realistic.”
2. Generate the loopback AUT
The following example makes the Generate the loopback AUT behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
# file: make_framework_fixture.py
from pathlib import Path
root = Path("framework-lab")
root.mkdir(exist_ok=True)
(root / "index.html").write_text(r'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Framework Contract Lab</title></head>
<body>
<main>
<h1>Framework Contract Lab</h1>
<label>Name <input id="name" data-testid="name"></label>
<button id="save" data-testid="save">Save</button>
<p id="status" data-testid="status">idle</p>
</main>
<script>
const name = document.querySelector('[data-testid="name"]');
const status = document.querySelector('[data-testid="status"]');
document.querySelector('[data-testid="save"]').addEventListener('click', () => {
const value = name.value.trim();
status.textContent = value ? `saved:${value}` : 'error:name-required';
document.body.dataset.lastSaved = value;
});
</script>
</body></html>''', encoding="utf-8")
print(root.resolve())
print("Serve with: python -m http.server 8777 --bind 127.0.0.1 --directory framework-lab")
Run the generator once, then start
python -m http.server 8777 --bind 127.0.0.1 --directory
framework-lab. The browser changes only the page DOM: typing changes the input
value; clicking Save changes #status and the synthetic
data-last-saved marker. Refreshing restores the static
fixture to idle.
3. Write the canonical behavior specification before choosing framework syntax
- Open the loopback page.
-
Enter one synthetic name into
[data-testid="name"]. -
Activate
[data-testid="save"]with the standard WebDriver interaction. -
Wait until
[data-testid="status"]equalssaved:<name>. - Assert that exact business result.
- Quit the session even when setup/action/assertion fails.
This list is the portable contract. A framework can change discovery and lifecycle syntax; it should not silently change these browser semantics.
4. Primary implementation: Python + pytest
The following example makes the Primary implementation: Python + pytest behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
# file: test_framework_contract.py
import os
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8777/")
@pytest.fixture
def driver():
# Function scope means one independent WebDriver session per test invocation.
d = webdriver.Chrome()
try:
yield d
finally:
d.quit()
@pytest.mark.parametrize("name", ["Ada", "Grace"])
def test_save_name(driver, name):
driver.get(BASE_URL)
driver.find_element(By.CSS_SELECTOR, '[data-testid="name"]').send_keys(name)
driver.find_element(By.CSS_SELECTOR, '[data-testid="save"]').click()
status = WebDriverWait(driver, 5).until(
EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, '[data-testid="status"]'), f"saved:{name}"
)
)
assert status is True
assert driver.find_element(By.CSS_SELECTOR, '[data-testid="status"]').text == f"saved:{name}"
The fixture owns the browser and uses yield so teardown
is registered around the test. Parameterization creates independent
test invocations. The explicit wait belongs near the state
transition it observes; pytest’s assertion reports the final
semantic mismatch. The browser object does not escape to a global
variable or session-scoped singleton.
5. Java/JUnit: annotations replace pytest fixtures, not WebDriver meaning
The following example makes the Java/JUnit: annotations replace pytest fixtures, not WebDriver meaning behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
// Java 17+; Selenium Java 4.47.0; JUnit 6.1.3/Jupiter
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.*;
class FrameworkContractTest {
WebDriver driver;
String baseUrl = System.getenv().getOrDefault("BASE_URL", "http://127.0.0.1:8777/");
@BeforeEach void start() { driver = new ChromeDriver(); }
@AfterEach void stop() { if (driver != null) driver.quit(); }
@ParameterizedTest
@ValueSource(strings = {"Ada", "Grace"})
void savesName(String name) {
driver.get(baseUrl);
driver.findElement(By.cssSelector("[data-testid='name']")).sendKeys(name);
driver.findElement(By.cssSelector("[data-testid='save']")).click();
WebElement status = new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("[data-testid='status']")));
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.textToBePresentInElement(status, "saved:" + name));
assertEquals("saved:" + name, status.getText());
}
}
JUnit’s @BeforeEach/@AfterEach are
lifecycle mechanics. Selenium still creates one
ChromeDriver, sends the same standard interactions,
waits for the same DOM contract, and quits.
@ValueSource expresses parameters; JUnit’s assertion
class evaluates the result in the runner process.
6. Java/TestNG: same binding, different framework lifecycle
The following example makes the Java/TestNG: same binding, different framework lifecycle behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
// TestNG 7.9.0: lifecycle and parameterization differ, WebDriver semantics do not.
@BeforeMethod
public void start() { driver = new ChromeDriver(); }
@AfterMethod(alwaysRun = true)
public void stop() { if (driver != null) driver.quit(); }
@DataProvider(name = "names")
public Object[][] names() { return new Object[][] {{"Ada"}, {"Grace"}}; }
@Test(dataProvider = "names")
public void savesName(String name) {
// Same get/find/sendKeys/click/wait/assert contract as the JUnit example.
}
This fragment intentionally focuses on what changes: TestNG uses
method-level annotations and a @DataProvider. The
Selenium Java objects and browser commands do not need a second
abstraction just because the runner changed. TestNG parallel
settings must still respect one-driver-per-concurrent-invocation
ownership.
7. .NET/NUnit: attributes and constraints around the same session boundary
The following example makes the .NET/NUnit: attributes and constraints around the same session boundary behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
// .NET 8+ example; Selenium.WebDriver 4.47.0; NUnit 4.6.1
using System;
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
public class FrameworkContractTests {
private IWebDriver? driver;
private readonly string baseUrl = Environment.GetEnvironmentVariable("BASE_URL")
?? "http://127.0.0.1:8777/";
[SetUp] public void Start() => driver = new ChromeDriver();
[TearDown] public void Stop() => driver?.Quit();
[TestCase("Ada")]
[TestCase("Grace")]
public void SavesName(string name) {
driver!.Navigate().GoToUrl(baseUrl);
driver.FindElement(By.CssSelector("[data-testid='name']")).SendKeys(name);
driver.FindElement(By.CssSelector("[data-testid='save']")).Click();
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(5));
wait.Until(d => d.FindElement(By.CssSelector("[data-testid='status']")).Text == $"saved:{name}");
Assert.That(driver.FindElement(By.CssSelector("[data-testid='status']")).Text,
Is.EqualTo($"saved:{name}"));
}
}
NUnit uses [SetUp], [TearDown], and
[TestCase]. The constraint assertion reads differently
from pytest/JUnit, but the expected observable value remains
saved:<name>. The nullable driver field is
test-instance state; do not share one instance across parallel test
cases unless isolation is explicitly engineered.
8. JavaScript/Mocha: asynchronous binding semantics are visible
The following example makes the JavaScript/Mocha: asynchronous binding semantics are visible behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.
// file: test_framework_contract.js
// Node.js 22+; selenium-webdriver 4.47.0; Mocha 11.8.0
const assert = require('node:assert/strict');
const {Builder, By, until} = require('selenium-webdriver');
describe('framework contract', function () {
this.timeout(15000);
let driver;
const baseUrl = process.env.BASE_URL || 'http://127.0.0.1:8777/';
beforeEach(async function () {
driver = await new Builder().forBrowser('chrome').build();
});
afterEach(async function () {
if (driver) await driver.quit();
});
for (const name of ['Ada', 'Grace']) {
it(`saves ${name}`, async function () {
await driver.get(baseUrl);
await driver.findElement(By.css('[data-testid="name"]')).sendKeys(name);
await driver.findElement(By.css('[data-testid="save"]')).click();
const status = await driver.wait(
until.elementLocated(By.css('[data-testid="status"]')), 5000
);
await driver.wait(async () => (await status.getText()) === `saved:${name}`, 5000);
assert.equal(await status.getText(), `saved:${name}`);
});
}
});
Here the important difference is not Mocha’s
describe/it vocabulary—it is JavaScript’s
Promise-based Selenium API. Navigation, element lookup, typing,
click, waits, capability reads, and quit must be awaited when their
completion/result matters. afterEach is also
asynchronous so cleanup completes before the next test reuses runner
resources.
9. Compare state ownership rather than syntax
The following table organizes the key choices and evidence for Compare state ownership rather than syntax. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.
| Concern | pytest | JUnit Jupiter | NUnit | Mocha |
|---|---|---|---|---|
| Per-test setup | function fixture | @BeforeEach | [SetUp] | beforeEach(async) |
| Per-test teardown | yield/finalizer | @AfterEach | [TearDown] | afterEach(async) |
| Parameters | @pytest.mark.parametrize | @ParameterizedTest | [TestCase] | generate it() cases / data helper |
| Assertion | Python assert | Assertions.assertEquals | Assert.That | node:assert or chosen library |
| WebDriver async style | blocking-looking calls | blocking-looking calls | blocking-looking calls | Promise + await |
| Browser owner | fixture/test invocation | test instance/method lifecycle | fixture/test instance | hook/test closure |
10. Before/after inspection and causal evidence
For each binding, capture the Selenium package version, session ID, browser name/version, current URL, parameter value, final status text, and test framework outcome. If one language fails, first compare this evidence. A framework syntax difference does not prove a browser difference; a browser capability difference does not prove an assertion-library problem.
11. Challenge: choose the control from the mental model
A team wants to run the two parameter values concurrently. Where should you change the suite: add a WebDriver retry, share one driver to reduce startup, add a framework-level parallel setting, or duplicate Grid sessions manually?
Parallel scheduling belongs to the test runner/framework, but only after proving the Grid/local host and AUT can support the concurrency. Keep one independent driver/data/evidence namespace per concurrent invocation. Do not add retries or share the driver.
12. Cleanup and bridge
Stop the loopback server, remove framework-lab/, and
verify no browser process remains from the examples. Lesson 3 turns
the mechanics into design choices: fixture scope, async style,
annotations/configuration, plugins, sharding, and ownership
boundaries.
Official references and current-version notes
- Selenium downloads — Stable 4.47.0 for Java, Python, .NET, JavaScript, Ruby, and Grid as of August 10, 2026.
- Organizing and Executing Selenium Code — Official examples list JUnit, TestNG, pytest/unittest, NUnit/MSTest, RSpec/Minitest, and Jest/Mocha test runners.
- Install a Selenium library — Current Selenium examples pin selenium==4.47.0, pytest==9.1.1, and JUnit BOM 6.1.3.
- pytest changelog — pytest 9.1.1 release baseline.
- JUnit 6.1.3 User Guide — JUnit 6.1.3; Java 17+ runtime requirement.
- TestNG documentation — Current documentation reports TestNG 7.9.0.
- NUnit framework release notes — NUnit 4.6.1 release baseline.
- selenium-webdriver npm package — JavaScript binding 4.47.0 and Node.js 22+ runtime floor.
- Mocha package — Mocha 11.8.0 stable baseline used by the comparison.
Version-sensitive statements in this lesson retain the pinned baseline used when the lesson was authored. Before changing Selenium, browser, driver, Grid, BiDi, container, or framework dependencies, compare that baseline with current primary documentation instead of silently substituting an unverified “latest” environment.
Knowledge checks
What remains identical between the pytest and JUnit examples?
The browser behavior: navigate, locate stable test IDs, type,
click, wait for saved:
Where does pytest parameterization live?
In the test framework via @pytest.mark.parametrize; it does not change WebDriver protocol semantics.
Why is afterEach async in the Mocha example?
driver.quit() returns a Promise; cleanup must complete before the runner moves on and reuses resources.
If two parameter cases should run concurrently, should you share one driver to reduce startup?
No. Schedule with the framework/CI only after capacity checks, while keeping an independent driver/data/evidence namespace per concurrent invocation.
Why does the lab use data-testid selectors?
They express a stable automation contract independent of translated text or fragile generated DOM structure.
Summary and next bridge
This lesson keeps test-framework mechanics subordinate to the WebDriver/browser contract: lifecycle, assertions, parameters, async behavior, scheduling, and reports remain explicit rather than hiding session ownership or failure meaning.
Next: Framework Integration: pytest, JUnit, TestNG, NUnit, and Language Bindings: Configuration, Design Patterns, and Trade-Offs
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.