Guided Walkthrough

12 min read

The project brief listed what to build. This lesson walks through how to build it — in the right sequence, with working code at each step, and a verification check before moving to the next layer. The sequence is deliberate: each step produces something testable, so errors are caught immediately rather than discovered 8 steps later when debugging a failure with five possible root causes. Follow the steps in order. Do not skip ahead to write tests before the foundation is solid.

Step 1 of 11

Folder structure

Design the project layout and justify each directory before writing a single class.

Step 1 — Folder structure

Before creating any Java classes, create the directory skeleton and document why each folder exists:

src/test/java/
├── base/         # BaseTest (lifecycle) and BasePage (interactions) — imported by everything
├── pages/        # One class per page, one responsibility: locators + actions
├── tests/        # Test classes, mirroring application feature areas
├── data/         # Factories and builders — no test logic, only object construction
├── config/       # Config singleton — single source of all configuration
├── utils/        # Stateless helpers: ScreenshotHelper, WaitHelper, DateUtils
└── listeners/    # TestNG listeners — lifecycle hooks, not test logic

src/test/resources/
├── config.properties   # Default config values
├── log4j2.xml          # Logging configuration
└── testng.xml          # Suite definition, thread count, listeners

docs/adr/               # Architecture Decision Records
docs/tutorials/         # How-to guides for new engineers

Write this structure as a comment in your README before touching pom.xml. Naming a folder forces a decision about what belongs in it — which surfaces boundary ambiguities before they become architectural debt.

Step 2 — Driver management

DriverManager is the first class to implement because every other class depends on it. The ThreadLocal field is the critical implementation detail — without it, parallel tests share one driver and produce intermittent failures that are nearly impossible to diagnose:

public class DriverManager {
    private static final ThreadLocal<WebDriver> driver = new ThreadLocal<>();
 
    public static void initDriver(String browser, boolean headless) {
        WebDriver d;
        switch (browser.toLowerCase()) {
            case "firefox" -> {
                FirefoxOptions opts = new FirefoxOptions();
                if (headless) opts.addArguments("-headless");
                d = new FirefoxDriver(opts);
            }
            default -> {
                ChromeOptions opts = new ChromeOptions();
                if (headless) opts.addArguments("--headless=new");
                d = new ChromeDriver(opts);
            }
        }
        d.manage().timeouts().implicitlyWait(Duration.ofSeconds(Config.get().timeoutSeconds()));
        driver.set(d);
    }
 
    public static WebDriver getDriver() { return driver.get(); }
 
    public static void quitDriver() {
        if (driver.get() != null) {
            driver.get().quit();
            driver.remove();  // prevents memory leak in long-running parallel suites
        }
    }
}

Verify: Write a 3-line test that calls DriverManager.initDriver("chrome", true), opens the application URL, reads the title, and calls quitDriver(). Run it three times. Titles are correct every run. DriverManager works.

Step 3 — BaseTest

BaseTest owns the test lifecycle and wires together the infrastructure components:

@Listeners({ TestListener.class, ExtentReportListener.class })
public class BaseTest {
    protected WebDriver driver;
    protected WebDriverWait wait;
 
    @BeforeMethod(alwaysRun = true)
    public void setUp(Method method) {
        String browser = Config.get().browser();
        boolean headless = Config.get().headless();
        DriverManager.initDriver(browser, headless);
        driver = DriverManager.getDriver();
        wait = new WebDriverWait(driver, Duration.ofSeconds(Config.get().timeoutSeconds()));
        driver.get(Config.get().baseUrl());
        LoggerFactory.getLogger(getClass()).info("Starting: {} [{}]", method.getName(), browser);
    }
 
    @AfterMethod(alwaysRun = true)
    public void tearDown(ITestResult result) {
        LoggerFactory.getLogger(getClass()).info("Finished: {} — {}",
            result.getName(), result.isSuccess() ? "PASS" : "FAIL");
        DriverManager.quitDriver();
    }
}

The @Listeners annotation registers infrastructure concerns once at the base class level — no test class needs to redeclare them. The alwaysRun = true on both methods is non-negotiable: a test that throws in setUp must still reach tearDown to release the driver.

Step 4 — BasePage

BasePage encapsulates every browser interaction behind explicit waits. No page object ever calls driver.findElement directly — all interactions flow through these methods:

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(Config.get().timeoutSeconds()));
        PageFactory.initElements(driver, this);
    }
 
    protected void click(WebElement element) {
        wait.until(ExpectedConditions.elementToBeClickable(element)).click();
    }
 
    protected void type(WebElement element, String text) {
        WebElement el = wait.until(ExpectedConditions.visibilityOf(element));
        el.clear();
        el.sendKeys(text);
    }
 
    protected String getText(WebElement element) {
        return wait.until(ExpectedConditions.visibilityOf(element)).getText();
    }
 
    protected boolean isVisible(WebElement element) {
        try {
            return wait.until(ExpectedConditions.visibilityOf(element)).isDisplayed();
        } catch (TimeoutException e) {
            return false;
        }
    }
 
    protected void waitForUrl(String urlFragment) {
        wait.until(ExpectedConditions.urlContains(urlFragment));
    }
}

Every page object in the framework extends BasePage. When a locator needs a different timeout — a slow async operation — override the wait locally rather than changing the shared timeout.

Step 5 — Configuration

The Config singleton resolves values in strict priority order. Environment variables win over file values; file values win over defaults. This three-level chain makes the framework environment-agnostic — the same binary runs locally, in staging CI, and against production by changing environment variables only:

public class Config {
    private static final Config INSTANCE = new Config();
    private final Properties props;
 
    private Config() {
        props = new Properties();
        try (InputStream is = getClass().getResourceAsStream("/config.properties")) {
            if (is != null) props.load(is);
        } catch (IOException e) { throw new RuntimeException(e); }
    }
 
    public static Config get() { return INSTANCE; }
 
    public String baseUrl() { return resolve("BASE_URL", "base.url", "https://app.example.com"); }
    public String browser() { return resolve("BROWSER", "browser", "chrome"); }
    public boolean headless() { return Boolean.parseBoolean(resolve("HEADLESS", "headless", "false")); }
    public int timeoutSeconds() { return Integer.parseInt(resolve("TIMEOUT_SECONDS", "timeout.seconds", "10")); }
 
    private String resolve(String envKey, String propKey, String defaultValue) {
        String env = System.getenv(envKey);
        return (env != null && !env.isBlank()) ? env : props.getProperty(propKey, defaultValue);
    }
}

config.properties (committed to version control — no secrets):

base.url=https://app.example.com
browser=chrome
headless=false
timeout.seconds=10

Step 6 — Test data factory

The UserBuilder applies the Builder pattern for parallel-safe test data. Every call to build() produces a unique user — UUID-based email guarantees no collision even when 8 threads create users simultaneously:

public class UserBuilder {
    private String email = "user-" + UUID.randomUUID() + "@test.example.com";
    private String password = "Test@12345";
    private String role = "customer";
    private boolean emailVerified = true;
 
    public static UserBuilder defaults() { return new UserBuilder(); }
 
    public UserBuilder withRole(String role) { this.role = role; return this; }
    public UserBuilder withPassword(String pw) { this.password = pw; return this; }
    public UserBuilder unverified() { this.emailVerified = false; return this; }
 
    public User build() {
        return new User(email, password, role, emailVerified);
    }
}

Usage in tests:

User admin = UserBuilder.defaults().withRole("admin").build();
User unverified = UserBuilder.defaults().unverified().build();
User standard = UserBuilder.defaults().build();  // all defaults

Step 7 — Logging

log4j2.xml in src/test/resources/ controls log output. Separate appenders for console (readable during local development) and file (parseable in CI):

<Configuration>
  <Properties>
    <Property name="logLevel">${env:LOG_LEVEL:-INFO}</Property>
  </Properties>
  <Appenders>
    <Console name="Console" target="SYSTEM_OUT">
      <PatternLayout pattern="%d{HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>
    </Console>
    <File name="File" fileName="target/logs/test.log" append="false">
      <PatternLayout pattern="%d{ISO8601} [%t] %-5level %logger{36} - %msg%n"/>
    </File>
  </Appenders>
  <Loggers>
    <Root level="${logLevel}">
      <AppenderRef ref="Console"/>
      <AppenderRef ref="File"/>
    </Root>
  </Loggers>
</Configuration>

Setting LOG_LEVEL=DEBUG in CI exposes every page navigation and data creation event — invaluable when diagnosing a CI failure that doesn't reproduce locally.

Step 8 — Reporting

An ExtentReportListener hooks into TestNG's ITestListener interface. The listener creates the report in setUp, attaches screenshots on failure, and flushes to disk in the onFinish handler:

public class ExtentReportListener implements ITestListener, ISuiteListener {
    private static ExtentReports extent;
    private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
 
    @Override
    public void onStart(ISuite suite) {
        ExtentSparkReporter reporter = new ExtentSparkReporter("target/extent-report/index.html");
        reporter.config().setDocumentTitle("Test Results");
        extent = new ExtentReports();
        extent.attachReporter(reporter);
    }
 
    @Override
    public void onTestStart(ITestResult result) {
        test.set(extent.createTest(result.getMethod().getDescription(), result.getName()));
    }
 
    @Override
    public void onTestFailure(ITestResult result) {
        String screenshotPath = ScreenshotHelper.capture(result.getName());
        test.get().fail(result.getThrowable())
                  .addScreenCaptureFromPath(screenshotPath);
    }
 
    @Override
    public void onTestSuccess(ITestResult result) { test.get().pass("Test passed"); }
 
    @Override
    public void onFinish(ISuite suite) { extent.flush(); }
}

Note the ThreadLocal<ExtentTest> — the same reasoning as ThreadLocal<WebDriver>. Each thread running in parallel needs its own test logger; a static field would mix output from concurrent tests.

Step 9 — Sample tests

Three tests that demonstrate the framework working as a system:

// Smoke test
@Test(groups = {"smoke"}, description = "Verify application is reachable and login succeeds")
public void standardUserCanLogin() {
    ProductsPage products = new LoginPage(driver).loginAs(UserBuilder.defaults().build());
    assertFalse(products.getProductNames().isEmpty(), "Products page must show items after login");
}
 
// Data-driven test
@Test(dataProvider = "loginScenarios", groups = {"login"})
public void loginValidation(String username, String password, String expectedOutcome) {
    LoginPage login = new LoginPage(driver);
    login.enterCredentials(username, password);
    login.clickLogin();
    if (expectedOutcome.equals("success")) {
        wait.until(ExpectedConditions.urlContains("/products"));
    } else {
        assertTrue(login.hasError(), "Error message expected for: " + expectedOutcome);
    }
}
 
@DataProvider(name = "loginScenarios", parallel = true)
public Object[][] loginData() {
    return new Object[][] {
        { Config.get().standardUser(), Config.get().userPassword(), "success" },
        { "locked_out_user",           Config.get().userPassword(), "locked_out" },
        { "",                          "",                          "empty_fields" }
    };
}

Step 10 — CI pipeline

# .github/workflows/smoke.yml
name: Smoke suite
 
on:
  push:
    branches: [main, "feature/**"]
  pull_request:
    branches: [main]
 
jobs:
  smoke:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chrome, firefox]  # cross-browser in parallel
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { java-version: '21', distribution: 'temurin' }
      - name: Run smoke suite (${{ matrix.browser }})
        run: mvn test -Dgroups=smoke
        env:
          BROWSER: ${{ matrix.browser }}
          HEADLESS: "true"
          BASE_URL: ${{ secrets.STAGING_URL }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: report-${{ matrix.browser }}
          path: target/extent-report/

The matrix runs Chrome and Firefox in parallel. The BROWSER environment variable flows through Config.get().browser() into DriverManager.initDriver() — no code change needed to run a different browser, only a variable.

Step 11 — Documentation

docs/adr/001-driver-management.md — Write the context (parallel execution required from day one), the three options you considered (static field, new driver per test, ThreadLocal), why you rejected each alternative, and the consequences of the chosen approach (page objects must receive driver via constructor; teardown must call driver.remove()).

docs/adr/002-reporting-choice.md — Why ExtentReports over Allure or JUnit XML. The deciding factor for most teams is CI integration and team familiarity — document the actual reason your team would choose one over the other.

docs/adr/003-test-data-strategy.md — Why Builder-based factories over file-based CSV data or database seeding. The key argument: Builder factories are parallel-safe by construction; CSV files require careful index management across threads; database seeding adds external dependency.

The README must pass the 15-minute test: hand it to a colleague with the right tools installed. Can they clone, configure, and run mvn test -Dgroups=smoke in under 15 minutes without asking a question? If not, the README has a gap. Fix the gap, not the colleague.

// tip to track lessons you complete and pick up where you left off across devices.