Layered Architecture — Tests, Pages, Utils, Config

9 min read

A test that directly calls driver.findElement(By.id("submit")).click() inside the test method is doing two jobs at once: defining what the test is doing (submitting a form) and knowing how to do it (find this element by this ID, call click). Those two jobs don't belong in the same place. Layered architecture is the principle that each part of your framework has exactly one job, and dependencies flow in one direction — downward. When that discipline holds, changing how a page works never touches the test logic, and changing the test logic never touches the browser interaction. This lesson explains the layers, their responsibilities, and why violations compound into the maintenance crises that drive teams to rewrite their frameworks every two years.

The five layers

A well-structured test framework has five distinct layers, each with a single responsibility:

┌──────────────────────────────────────────┐
│  Test Layer                               │  "Given a logged-in user..."
│  Orchestrates scenarios, owns assertions  │
├──────────────────────────────────────────┤
│  Page / Service Layer                     │  "Click the submit button"
│  Encapsulates HOW to interact with app    │
├──────────────────────────────────────────┤
│  Utility Layer                            │  "Generate a random email"
│  Shared helpers with no app knowledge     │
├──────────────────────────────────────────┤
│  Config Layer                             │  "The base URL is staging.myapp.com"
│  Environment values, no test logic        │
├──────────────────────────────────────────┤
│  Driver / Engine Layer                    │  "Start a Chrome instance"
│  Browser/HTTP client lifecycle            │
└──────────────────────────────────────────┘

The golden rule: dependencies flow downward. The test layer knows about page objects. Page objects know about utilities and config. Config knows about nothing above it. A page object must never know what test is calling it. A utility must never depend on a page object. When you violate this — when a utility calls a page method, or when a test calls driver.findElement directly — you've collapsed two layers, and the isolation benefit disappears.

What each layer owns

Test layer defines the scenario. A well-written test reads like a description of the business behaviour being verified. It calls page object methods, reads their returned state, and makes assertions. It does not know selectors, URLs, or driver APIs. It does not know the base URL.

// Good test layer — no infrastructure details
@Test
public void adminCanPublishPost() {
    loginPage.navigateTo();
    loginPage.loginAs(Users.admin());
    PostEditorPage editor = dashboardPage.createNewPost();
    editor.fillTitle("Framework Architecture Deep Dive");
    editor.fillBody("Content here...");
    PublishedPostPage published = editor.publish();
    assertTrue(published.isLive());
}

Page / service layer knows selectors, endpoint paths, and interaction sequences — but nothing about the assertions a test wants to make. It hides the implementation of "how to log in" so the test only needs to say "log in."

// TypeScript page object — interaction only, no assertions
export class LoginPage {
  constructor(private page: Page) {}
 
  async goto() {
    await this.page.goto(config.baseUrl + "/login");
  }
 
  async login(email: string, password: string) {
    await this.page.fill("[data-testid='email']", email);
    await this.page.fill("[data-testid='password']", password);
    await this.page.click("[data-testid='submit']");
  }
}

Utility layer contains helpers that have no knowledge of the application — date formatters, random string generators, file readers, JSON parsers. They're reusable across any test in any project.

# Python utility — no app knowledge
def random_email(domain: str = "test.com") -> str:
    return f"user_{uuid.uuid4().hex[:8]}@{domain}"
 
def read_json(path: str) -> dict:
    with open(path) as f:
        return json.load(f)

Config layer holds environment-specific values. The base URL, timeout thresholds, feature flag states, API keys. Every value that differs between environments lives here and only here.

// Java Config singleton — single source of truth
public class Config {
    private static final Properties props = load();
 
    public static String baseUrl() {
        return System.getenv().getOrDefault("BASE_URL", props.getProperty("base.url"));
    }
 
    public static int timeout() {
        return Integer.parseInt(props.getProperty("timeout.ms", "5000"));
    }
}

Driver / engine layer manages the browser or HTTP client lifecycle — creation, configuration, cleanup. In a parallel suite, this layer uses ThreadLocal<WebDriver> to give each thread its own browser instance. Tests never call new ChromeDriver() directly; they receive the driver through the page object constructor or a dependency injection mechanism.

The before-and-after

Here's the same test — once violating every layer principle, once following them:

Before: all concerns collapsed into one method (40+ lines)

@Test
public void testCheckout() {
    // Driver management in the test — wrong layer
    WebDriver driver = new ChromeDriver();
    driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
 
    // Config hardcoded in the test — wrong layer
    driver.get("https://staging.myapp.com/login");
 
    // Selectors in the test — wrong layer
    driver.findElement(By.id("email")).sendKeys("admin@test.com");
    driver.findElement(By.id("password")).sendKeys("Admin123!");
    driver.findElement(By.cssSelector("[data-testid='submit']")).click();
 
    // More selectors in the test
    driver.findElement(By.cssSelector(".add-to-cart")).click();
    driver.findElement(By.id("checkout")).click();
 
    // Assertion buried in 30 lines of setup
    assertEquals("Order confirmed", driver.findElement(By.h1).getText());
    driver.quit();
}

After: each concern in its layer (8 lines)

@Test
public void adminCanCheckout() {
    loginPage.navigateTo();
    loginPage.loginAs(Users.admin());
    inventoryPage.addToCart("Wireless Keyboard");
    CartPage cart = inventoryPage.openCart();
    ConfirmationPage confirmation = cart.checkout();
    assertEquals("Order confirmed", confirmation.getHeading());
}

Same scenario. The after version has zero selectors, zero URLs, zero driver calls. When the submit button selector changes, the test is untouched. When the environment switches from staging to prod, the test is untouched. When the checkout flow adds a step, you update one page object method, not every test that checkout touches.

⚠️ Common mistakes

  • Test methods calling driver.findElement directly. This is the most common layer violation. Once one engineer does it, it spreads as copy-paste. Enforce it in code reviews: tests that import By or WebDriver have broken the layer boundary.
  • Page objects reading from System.getenv(). Config should flow through the config layer, not be read ad-hoc in page object constructors. When config is scattered, changing an environment variable requires finding every place it's read.
  • A BaseTest class that grows without bound. BaseTest is in the test layer — it owns @BeforeMethod and @AfterMethod for test-level setup. But it has a gravitational pull: people keep adding things to it. A 500-line BaseTest that manages drivers, reads config, initialises reporters, and declares page objects is a layer collapse. Distribute those responsibilities to their proper layers.

🎯 Practice task

Refactor a test to enforce layer discipline — 35 minutes.

  1. Find the worst-layered test in your existing project. Look for the test with the most findElement calls, the most hardcoded strings, and the longest method body. That's your target.
  2. Extract the page layer. Move every findElement call into a page object. The test method should have zero occurrences of By, findElement, or driver. Run the test — it should still pass.
  3. Extract the config layer. Find every hardcoded URL, timeout value, or credential in the test. Move them to a properties file or a Config class. Have the page object read from Config instead. Run again.
  4. Count the lines. How many lines did the test method shrink by? How many lines does the page object now have? The test method should be 5-10 lines; the page object 20-40. If the page object is already over 60 lines, it's doing too much — it probably models two pages and should be split.
  5. Stretch — draw the dependency graph. For your refactored test, draw the actual dependency chain: TestClass → LoginPage → Config and TestClass → CheckoutPage → Config → DriverFactory. Every arrow should point downward. Any arrow pointing upward (a utility depending on a page, a config class calling test code) is a layer violation to fix.

Next lesson: how Separation of Concerns applies specifically to the decisions inside each layer — particularly the hard question of what page objects should and should not do.

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