Building a Selenium Automation Framework with Java, TestNG, and Page Object Model

Share
Building a Selenium Automation Framework with Java, TestNG, and Page Object Model

There's a real difference between writing a Selenium script that clicks through a form once and building a framework that a team can actually rely on for months. That difference almost always comes down to three things: a sane structure (Page Object Model), disciplined use of TestNG's actual features instead of just its @Test annotation, and getting synchronization right so the suite doesn't fail randomly for reasons that have nothing to do with real bugs.

This article walks through building that structure including a flow that trips up a lot of automation efforts: verifying a signup form that requires an OTP sent to an email inbox.

Why Page Object Model, specifically

Page Object Model (POM) separates two concerns that get tangled together in beginner automation code: the logic of a test (what should happen and in what order) and the mechanics of finding and interacting with elements on a page (locators). Each page or major component gets its own class, a LoginPage class exposing methods like enterUsername(), enterPassword(), and clickLogin() and locators live only inside that class.

The payoff shows up the first time the UI changes. If a developer renames an element's ID, you fix it in exactly one place the page object instead of hunting through every test method that happens to reference that locator directly. Assertions belong in the test class, not inside the page object; a page object's job is to describe the page and expose actions on it, not to judge whether the result was correct.

TestNG essentials that actually matter

Annotations like @BeforeMethod, @Test, and @AfterMethod structure setup, execution, and teardown around every test, while @BeforeClass and @BeforeSuite handle setup that only needs to happen once per class or run. A TestNG suite XML file groups tests logically separating a fast smoke group from a slower full-regression group and can configure parallel execution, which matters once a suite grows large enough that sequential execution starts eating real time.

DataProviders let one test method run against multiple input sets without duplicating the method itself exactly the parameterization idea from test case design applied to code. And soft assertions (which collect multiple failures in one test run instead of stopping at the first one) versus hard assertions (which fail immediately) is a real design decision, not a minor detail soft assertions are useful when you want full visibility into everything that failed in one pass, hard assertions when a later step genuinely depends on an earlier one passing.

Waits and synchronization - where most flaky automation comes from

Thread.sleep() is the single most common cause of both slow and flaky Selenium suites. A fixed sleep either wastes time waiting longer than necessary, or worse doesn't wait long enough on a slower run and fails intermittently for a reason that has nothing to do with an actual bug. Implicit waits set a global timeout for element lookups across the driver, which is simple but blunt. Explicit waits, using WebDriverWait paired with ExpectedConditions, wait for a specific condition element clickable, element visible, text present and proceed the moment that condition is true rather than waiting a fixed, arbitrary duration. For any framework meant to run reliably in CI, explicit waits tied to real conditions aren't optional.

Automating flows that require OTP or email verification

Signup and password-reset flows that require an emailed OTP are a common blocker for automation, because the obvious approach of a human checking their inbox doesn't scale to CI. The practical solution is a disposable-inbox service (Mailinator is a common free option) that exposes an inbox via a predictable URL or API. The automation utility polls that inbox in a loop with a reasonable timeout, parses the OTP out of the email body once it arrives, and feeds it back into the verification field turning what looks like a manual-only flow into something a CI pipeline can run unattended, end to end.

Reporting and CI

TestNG's built-in HTML report is the simplest starting point; it's generated automatically after a run with zero extra setup and gives pass/fail counts, execution time, and stack traces for failures. Extent Reports is a common step up when a team wants richer, more shareable output screenshots on failure, a cleaner dashboard view, historical trend tracking. Either way, the report only delivers ongoing value once it's wired into CI (Jenkins or GitHub Actions), so failures are visible to the whole team automatically rather than living only on whoever's machine happened to run the suite that day.

Real-World Example

A signup automation suite for a web application uses two page objects SignupPage and OtpVerificationPage keeping form-filling logic and OTP-entry logic cleanly separated. A dedicated utility class handles the OTP retrieval: it polls a disposable email inbox every few seconds up to a defined timeout, extracts the six-digit code from the email body using a simple pattern match, and returns it to the test, which feeds it into OtpVerificationPage.

The suite is organized through a TestNG suite XML file that groups tests into smoke (login and signup only, run on every commit) and regression (the full flow set, run nightly). Early versions of this suite used Thread.sleep() to wait for the OTP email to arrive, and failed intermittently sometimes the email took eight seconds, sometimes twenty. Replacing that fixed wait with a polling loop against the inbox, combined with explicit WebDriverWait calls for UI elements elsewhere in the flow, cut flaky failures dramatically and made the suite something the team actually trusted enough to gate merges on.

Best Practices

  • One Page Object per page or major component never mixes assertions into page object methods.
  • Centralize locators inside their page object; never duplicate the same XPath or CSS selector across test classes.
  • Use explicit waits tied to real conditions, never Thread.sleep(), for anything running in CI.
  • Keep test data out of test logic using a properties file, JSON, or a TestNG DataProvider instead of hardcoded values.
  • Name test methods and classes descriptively so a failure is understandable directly from the report, without opening the code.
  • Run the automation suite in CI on every relevant build, not only manually before a release.

Common Mistakes to Avoid

  • Mistake: Putting assertions inside Page Object methods
    Fix: Keep page objects focused purely on actions and element access; assertions belong in the test class.
  • Mistake: Relying on Thread.sleep() for synchronization
    Fix: Replace fixed sleeps with WebDriverWait and ExpectedConditions tied to the actual state you're waiting for.
  • Mistake: Hardcoding test data and locators inline in test methods
    Fix: Externalize data via DataProviders or config files, and keep locators inside their page object only.
  • Mistake: One giant test class instead of a modular POM structure
    Fix: Split by page/component from the start retrofitting structure onto a monolithic class later is far more painful.
  • Mistake: Leaving OTP/email-verification flows permanently manual
    Fix: Automate inbox polling against a disposable email service so the entire flow can run unattended in CI.

Key Takeaways

  • POM's real value is maintainability: a broken locator gets fixed in exactly one place, not scattered across dozens of test methods.
  • TestNG's annotations, suite XML, and DataProviders are what turn a pile of scripts into an actual, organized test suite.
  • Explicit waits tied to real conditions are non-negotiable for a suite meant to run reliably in CI.
  • Even tricky flows like OTP verification can be fully automated with the right utility approach; it doesn't have to stay a manual gap.
  • A framework is only as good as its reporting and CI integration; a suite nobody looks at might as well not run.