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.findElementdirectly. This is the most common layer violation. Once one engineer does it, it spreads as copy-paste. Enforce it in code reviews: tests that importByorWebDriverhave 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
BaseTestclass that grows without bound.BaseTestis in the test layer — it owns@BeforeMethodand@AfterMethodfor test-level setup. But it has a gravitational pull: people keep adding things to it. A 500-lineBaseTestthat 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.
- Find the worst-layered test in your existing project. Look for the test with the most
findElementcalls, the most hardcoded strings, and the longest method body. That's your target. - Extract the page layer. Move every
findElementcall into a page object. The test method should have zero occurrences ofBy,findElement, ordriver. Run the test — it should still pass. - Extract the config layer. Find every hardcoded URL, timeout value, or credential in the test. Move them to a properties file or a
Configclass. Have the page object read fromConfiginstead. Run again. - 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.
- Stretch — draw the dependency graph. For your refactored test, draw the actual dependency chain:
TestClass → LoginPage → ConfigandTestClass → 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.