Your First Page Object in Selenium
On this page
In this chapter you write the LoginPage and LoggedInPage page objects for the practice site, in Java with TestNG and in Python with pytest. The two versions are deliberately parallel so you can read whichever language you work in and still follow the rest of the course.
Look at the page first#
Before writing a page object, open the page and list two things: what a user can do here, and what a user can see here. For https://practicetestautomation.com/practice-test-login/:
| Do | See |
|---|---|
Type a username (#username) |
An error box (#error) with "Your username is invalid!" or "Your password is invalid!" |
Type a password (#password) |
|
Press Submit (#submit) |
A successful login lands on /logged-in-successfully/, which has an h1 "Logged In Successfully", a bold "Congratulations student. You successfully logged in!" and a "Log out" link.
Those two columns become the public API of your page object: actions become methods, things to see become getters. Locators stay private.
One detail is worth checking in DevTools now, because it will bite later: #error is in the DOM from the moment the page loads, with the text "Your username is invalid!" already in it. It is hidden with opacity: 0 and only gets a show class after a failed attempt. Selenium's visibility check treats an element with opacity 0 as not displayed, so waiting for visibility works here. (Playwright does not, as chapter 02 shows.)
From the Do / See table, what should the login page object expose publicly?
Actions become methods and visible things become getters. Public locators or elements let tests start clicking around inside the page object's territory again.
Project setup#
Java, with Maven:
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.52.0</version>
</dependency>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Python:
python -m venv .venv && source .venv/bin/activate
pip install selenium==4.52.0 pytest==9.2.0
Selenium 4 ships Selenium Manager, which downloads a matching chromedriver for you, so neither setup needs a driver binary on the PATH.
A small base class#
Every page object needs the driver and a wait. Rather than repeating that, put it in a base class together with the two or three interactions every page uses. Keep this class small; chapter 08 explains what happens when base pages grow.
package com.example.pages;
import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public abstract class BasePage {
protected final WebDriver driver;
protected final WebDriverWait wait;
protected BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
}
protected WebElement visible(By locator) {
return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
}
protected void type(By locator, String text) {
WebElement field = visible(locator);
field.clear();
field.sendKeys(text);
}
protected void click(By locator) {
wait.until(ExpectedConditions.elementToBeClickable(locator)).click();
}
}
# pages/base_page.py
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
class BasePage:
def __init__(self, driver: WebDriver, timeout: float = 10):
self.driver = driver
self.wait = WebDriverWait(driver, timeout)
def visible(self, locator):
return self.wait.until(EC.visibility_of_element_located(locator))
def type(self, locator, text: str) -> None:
field = self.visible(locator)
field.clear()
field.send_keys(text)
def click(self, locator) -> None:
self.wait.until(EC.element_to_be_clickable(locator)).click()
Every interaction waits explicitly. That is the main reason page objects reduce flakiness: the waiting strategy is written once, correctly, instead of 300 times with varying care.
Why does routing every interaction through BasePage helpers such as visible() and click() reduce flakiness?
The wait is part of the helper, so every page object interaction waits the same correct way. Nothing is retried and no implicit wait is involved.
The login page#
package com.example.pages;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
public class LoginPage extends BasePage {
private static final String URL = "https://practicetestautomation.com/practice-test-login/";
private final By username = By.id("username");
private final By password = By.id("password");
private final By submit = By.id("submit");
private final By error = By.id("error");
public LoginPage(WebDriver driver) {
super(driver);
}
public LoginPage open() {
driver.get(URL);
visible(username);
return this;
}
public LoggedInPage loginAs(String user, String pass) {
submitCredentials(user, pass);
return new LoggedInPage(driver);
}
public LoginPage loginExpectingError(String user, String pass) {
submitCredentials(user, pass);
return this;
}
public String errorMessage() {
return visible(error).getText();
}
private void submitCredentials(String user, String pass) {
type(username, user);
type(password, pass);
click(submit);
}
}
# pages/login_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from pages.base_page import BasePage
class LoginPage(BasePage):
USERNAME = (By.ID, "username")
PASSWORD = (By.ID, "password")
SUBMIT = (By.ID, "submit")
ERROR = (By.ID, "error")
def __init__(self, driver, base_url: str):
super().__init__(driver)
self.base_url = base_url
def open(self) -> "LoginPage":
self.driver.get(f"{self.base_url}/practice-test-login/")
self.visible(self.USERNAME)
return self
def login_as(self, user: str, password: str) -> "LoggedInPage":
self._submit(user, password)
return LoggedInPage(self.driver)
def login_expecting_error(self, user: str, password: str) -> "LoginPage":
self._submit(user, password)
return self
@property
def error_message(self) -> str:
return self.visible(self.ERROR).text
def _submit(self, user: str, password: str) -> None:
self.type(self.USERNAME, user)
self.type(self.PASSWORD, password)
self.click(self.SUBMIT)
Three design decisions to notice:
-
Two login methods, not one.
loginAsreturns the page you land on after success;loginExpectingErrorreturns the login page itself. The return type documents the expected outcome, and a test that calls the wrong one fails at the next step with a clear message instead of an assertion on the wrong page. -
The getter returns data, not a
WebElement. Tests get aString, so they cannot accidentally start clicking around inside the page object's territory. -
open()waits for the page to be usable before returning. A page object that returns while the page is still loading moves the race condition into the test.
The landing page#
package com.example.pages;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
public class LoggedInPage extends BasePage {
private final By heading = By.tagName("h1");
private final By message = By.cssSelector(".post-content strong");
public LoggedInPage(WebDriver driver) {
super(driver);
// the one check a page object may make: are we on the right page?
wait.until(ExpectedConditions.urlContains("/logged-in-successfully/"));
}
public String heading() { return visible(heading).getText(); }
public String message() { return visible(message).getText(); }
}
In Python, put LoggedInPage in the same module as LoginPage (the two reference each other, and one module avoids a circular import):
# pages/login_page.py, continued
class LoggedInPage(BasePage):
HEADING = (By.TAG_NAME, "h1")
MESSAGE = (By.CSS_SELECTOR, ".post-content strong")
def __init__(self, driver):
super().__init__(driver)
self.wait.until(EC.url_contains("/logged-in-successfully/"))
@property
def heading(self) -> str:
return self.visible(self.HEADING).text
@property
def message(self) -> str:
return self.visible(self.MESSAGE).text
The constructor check follows the Selenium documentation's single exception to "page objects should never make assertions": it is fine to verify that the page "and possibly critical elements on the page, were loaded correctly" while instantiating the page object.
The tests#
package com.example.tests;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertTrue;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import com.example.pages.LoggedInPage;
import com.example.pages.LoginPage;
public class LoginTest {
private WebDriver driver;
@BeforeMethod
public void start() { driver = new ChromeDriver(); }
@AfterMethod(alwaysRun = true)
public void stop() { if (driver != null) driver.quit(); }
@Test
public void validUserSeesWelcomeMessage() {
LoggedInPage home = new LoginPage(driver).open().loginAs("student", "Password123");
assertEquals(home.heading(), "Logged In Successfully");
assertTrue(home.message().contains("successfully logged in"));
}
@Test
public void wrongPasswordShowsError() {
LoginPage login = new LoginPage(driver).open()
.loginExpectingError("student", "wrong-password");
assertEquals(login.errorMessage(), "Your password is invalid!");
}
}
# conftest.py
import pytest
from selenium import webdriver
@pytest.fixture
def base_url() -> str:
return "https://practicetestautomation.com"
@pytest.fixture
def driver():
drv = webdriver.Chrome()
yield drv
drv.quit() # runs even when the test fails
# tests/test_login.py
from pages.login_page import LoginPage
def test_valid_user_sees_welcome_message(driver, base_url):
home = LoginPage(driver, base_url).open().login_as("student", "Password123")
assert home.heading == "Logged In Successfully"
assert "successfully logged in" in home.message
def test_wrong_password_shows_error(driver, base_url):
login = LoginPage(driver, base_url).open().login_expecting_error(
"student", "wrong-password")
assert login.error_message == "Your password is invalid!"
Run them with mvn test, or with pytest from the project root (keep conftest.py there and add an empty pages/__init__.py so from pages.login_page import ... resolves). The browser lifecycle now lives in @BeforeMethod/@AfterMethod(alwaysRun = true) and a yield fixture, which fixes the leaked-browser bug from the introduction.
The Java test keeps the driver in an instance field. That is fine while tests run one at a time; chapter 07 shows exactly how it breaks under parallel="methods" and replaces it with a ThreadLocal. If you want a fuller TestNG walkthrough, our Selenium with Java and TestNG course covers the runner itself, and the pytest guide in our docs covers the Python side.
An assertion in the middle of a test fails. Which of these still quits the browser?
A failed assertion throws, so lines after it never run. Teardown that the runner owns, @AfterMethod(alwaysRun = true) or fixture teardown after yield, runs regardless.