Integrating Selenium with pytest and JUnit — Test Runners and Assertions

Writing Selenium code in a standalone script works for learning, but real automation projects need a test framework — a structured way to organise tests, run them selectively, report results, and manage setup and teardown. In Python, the standard choice is pytest. In Java, it is JUnit 5 (or TestNG). This lesson shows you how to integrate Selenium with both frameworks, turning standalone scripts into professional test suites with fixtures, assertions, and lifecycle management.

Selenium + pytest (Python) and Selenium + JUnit (Java)

A test framework provides three critical capabilities: lifecycle hooks (setup/teardown), rich assertions, and test discovery and execution. Integrating Selenium with a framework transforms your automation from scripts into a maintainable test suite.

# ── Selenium + pytest ──
# File: tests/test_login.py

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


# ── Fixture: shared browser instance with automatic cleanup ──
@pytest.fixture
def browser():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")  # Run without visible browser
    driver = webdriver.Chrome(options=options)
    driver.implicitly_wait(5)

    yield driver  # Test runs here

    driver.quit()  # Cleanup — always runs, even if test fails


# ── Test: successful login ──
def test_login_with_valid_credentials(browser):
    browser.get("https://www.saucedemo.com")

    browser.find_element(By.ID, "user-name").send_keys("standard_user")
    browser.find_element(By.ID, "password").send_keys("secret_sauce")
    browser.find_element(By.ID, "login-button").click()

    WebDriverWait(browser, 10).until(
        EC.url_contains("inventory")
    )

    assert "inventory" in browser.current_url
    products = browser.find_elements(By.CLASS_NAME, "inventory_item")
    assert len(products) == 6, f"Expected 6 products, found {len(products)}"


# ── Test: login with wrong password ──
def test_login_with_invalid_password(browser):
    browser.get("https://www.saucedemo.com")

    browser.find_element(By.ID, "user-name").send_keys("standard_user")
    browser.find_element(By.ID, "password").send_keys("wrong_password")
    browser.find_element(By.ID, "login-button").click()

    error_msg = WebDriverWait(browser, 5).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "[data-test='error']"))
    )

    assert "do not match" in error_msg.text.lower()


# ── Test: login with empty fields ──
def test_login_with_empty_fields(browser):
    browser.get("https://www.saucedemo.com")

    browser.find_element(By.ID, "login-button").click()

    error_msg = WebDriverWait(browser, 5).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "[data-test='error']"))
    )

    assert "username is required" in error_msg.text.lower()


# Run with: pytest tests/test_login.py -v
// ── Selenium + JUnit 5 ──
// File: src/test/java/LoginTest.java

import org.junit.jupiter.api.*;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.*;
import org.openqa.selenium.support.ui.*;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;

class LoginTest {
    private WebDriver driver;
    private WebDriverWait wait;

    @BeforeEach
    void setUp() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new");
        driver = new ChromeDriver(options);
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(5));
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    @AfterEach
    void tearDown() {
        if (driver != null) {
            driver.quit();
        }
    }

    @Test
    @DisplayName("Login with valid credentials shows inventory page")
    void testValidLogin() {
        driver.get("https://www.saucedemo.com");
        driver.findElement(By.id("user-name")).sendKeys("standard_user");
        driver.findElement(By.id("password")).sendKeys("secret_sauce");
        driver.findElement(By.id("login-button")).click();

        wait.until(ExpectedConditions.urlContains("inventory"));
        assertTrue(driver.getCurrentUrl().contains("inventory"));
    }
}
Note: The pytest yield fixture pattern is the cleanest way to manage browser lifecycle. Code before yield runs as setup, the test receives the yielded object (the driver), and code after yield runs as teardown — guaranteed, even if the test throws an exception. This replaces the try/finally pattern from standalone scripts with a framework-managed lifecycle that works automatically for every test that requests the browser fixture.
Tip: Use pytest -v for verbose output showing each test name and its pass/fail status. Use pytest -k "login" to run only tests with “login” in the name. Use pytest --tb=short for concise tracebacks. These command-line options let you run subsets of your suite quickly during development without modifying any code.
Warning: Do not mix implicit waits and explicit waits in the same test. Implicit waits set a global timeout on find_element calls, while explicit waits (WebDriverWait) set a per-condition timeout. When both are active, the timeouts can interact unpredictably — for example, an explicit wait of 5 seconds combined with an implicit wait of 10 seconds can cause the test to wait up to 15 seconds in some edge cases. Pick one strategy and use it consistently. Most professionals prefer explicit waits only.

Common Mistakes

Mistake 1 — Creating a new browser instance inside each test function

❌ Wrong: Writing driver = webdriver.Chrome() at the start of every test function and driver.quit() at the end, duplicating setup code across 50 tests.

✅ Correct: Creating a shared pytest fixture (or JUnit @BeforeEach) that handles browser creation and cleanup in one place. Every test simply requests the fixture.

Mistake 2 — Not running tests in headless mode during CI

❌ Wrong: Tests that open visible browser windows fail in CI/CD pipelines because there is no display server.

✅ Correct: Configuring headless mode via ChromeOptions when running in CI, while allowing headed mode during local development for visual debugging.

🧠 Test Yourself

In a pytest Selenium fixture, what does the yield keyword accomplish?