Chapter 27Lesson 02~245 minutes

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.

pytestJUnitNUnitMochaEquivalent behavior

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.

Local target only

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

  1. Open the loopback page.
  2. Enter one synthetic name into [data-testid="name"].
  3. Activate [data-testid="save"] with the standard WebDriver interaction.
  4. Wait until [data-testid="status"] equals saved:<name>.
  5. Assert that exact business result.
  6. 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?

Expected reasoning

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.

Next lesson

Framework Integration: pytest, JUnit, TestNG, NUnit, and Language Bindings: Configuration, Design Patterns, and Trade-Offs

Continue with Framework Integration: pytest, JUnit, TestNG, NUnit, and Language Bindings: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Official references and current-version notes

Version and compatibility note

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?

Where does pytest parameterization live?

Why is afterEach async in the Mocha example?

If two parameter cases should run concurrently, should you share one driver to reduce startup?

Why does the lab use data-testid selectors?

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.