Jobs Platform
Selenium with Java and TestNG: Level 2

Lesson 1 of 8 · 13 min

Framework design: page objects, base classes and config

In Level 1 you wrote single tests and one LoginPage class. A real project has hundreds of tests, so the structure around them matters as much as the tests. A good framework has a few small layers, and each layer has one job. When a locator changes, you fix one line. When the team moves from Chrome to Firefox, you change one setting.

The layers

  • Config: values that change between machines, such as browser, headless mode and timeout, kept in a file called config.properties.
  • DriverFactory: the only class that creates and quits drivers.
  • BasePage: shared helpers that every page object uses, such as click and type with built-in waits.
  • Page objects: one class per page or component, with locators and actions.
  • Tests and BaseTest: the test cases, and a base class that starts and stops the browser around each test.

Config that can be overridden

Put settings in src/test/resources/config.properties, then read them in one Config class. Let a value given on the command line win over the file. This lets a CI server change the browser without editing any file.

public static String get(String key) {
    // -Dbrowser=firefox on the command line wins over the file
    return System.getProperty(key, PROPS.getProperty(key));
}

Run mvn test -Dbrowser=firefox -Dheadless=true and the same code uses those values. Never put passwords in this file; read secrets from environment variables instead.

DriverFactory and ThreadLocal

The factory starts the browser chosen in the config and keeps the driver in a ThreadLocal. A ThreadLocal gives each thread its own copy of a variable. Later, when you run tests in parallel, every thread will have its own browser. A plain static WebDriver field would be shared, and tests would click in each other's windows.

public final class DriverFactory {
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();

    public static WebDriver start(String browser) {
        WebDriver driver = switch (browser.toLowerCase()) {
            case "chrome" -> new ChromeDriver(chromeOptions());
            case "firefox" -> new FirefoxDriver(firefoxOptions());
            default -> throw new IllegalArgumentException("Unknown browser: " + browser);
        };
        DRIVER.set(driver);
        return driver;
    }

    public static WebDriver get() { return DRIVER.get(); }

    public static void quit() {
        if (DRIVER.get() != null) { DRIVER.get().quit(); DRIVER.remove(); }
    }
}

BaseTest calls DriverFactory.start in @BeforeMethod and DriverFactory.quit in @AfterMethod(alwaysRun = true). Test classes extend it and call a small driver() method that returns DriverFactory.get(). They do not keep their own driver field. The next lessons show why.

BasePage: waits in one place

Every page object extends BasePage. It holds the driver and a WebDriverWait built from the config timeout, and offers helpers that wait first and then act. Page classes stay short and tests rarely fail because of timing.

protected void click(By by) {
    wait.until(ExpectedConditions.elementToBeClickable(by)).click();
}

protected void type(By by, String text) {
    WebElement e = wait.until(ExpectedConditions.visibilityOfElementLocated(by));
    e.clear();
    e.sendKeys(text);
}

Plain page objects or PageFactory?

PageFactory is a helper in the selenium-support library. You declare fields with @FindBy and call PageFactory.initElements(driver, this). Each field becomes a lazy proxy that looks up the element every time you use it.

public class LoginPageFactory {
    @FindBy(id = "username") private WebElement username;
    @FindBy(id = "login")    private WebElement loginButton;

    public LoginPageFactory(WebDriver driver) {
        PageFactory.initElements(driver, this);
    }
}

It still works in Selenium 4, and it makes short classes. But the Selenium documentation teaches page objects with plain By locators, and many teams prefer them. With plain By fields you control the wait for each action, and you can build a locator from a parameter, for example a row for a given name. PageFactory also adds @CacheLookup, which stores the element after the first search. On a page that redraws, the stored element goes stale. We tested a list that is rebuilt after a button click:

PF before: One/One
PF plain after: One
PF cached after: STALE

Utilities

Code that does not know about any page goes into a utils package: reading test data files, taking screenshots, creating random email addresses. If two page classes need the same logic, move it to BasePage or a utility instead of copying it.

Try it yourself

  • Create config.properties with browser, headless and timeoutSeconds, and a Config class where a -D value overrides the file.
  • Write DriverFactory with a ThreadLocal driver and BaseTest that uses it.
  • Rewrite one page object with PageFactory and one with plain By fields. Compare the length and what happens after the page redraws.
  • Move one repeated piece of code from a page class into BasePage.

Quick check

Three quick questions on this lesson. Your answers are not saved.

  1. Question 1 of 3

    Your DriverFactory.get() returns the driver from a ThreadLocal. What does ThreadLocal guarantee?

  2. Question 2 of 3

    Why does DriverFactory.quit() call DRIVER.remove() after quitting the driver?

  3. Question 3 of 3

    Which call makes the @FindBy fields of a page class usable?