Separation of Concerns in Test Code

8 min read

There's a specific failure mode that appears in every fast-growing test suite: the page object that does too much. It starts as a LoginPage with three sensible methods. Over six months, it accumulates: an assertion that checks the dashboard loaded, a hardcoded admin email address, a method that calls the user API to verify something, and a Thread.sleep that someone added to "fix" a flaky test. Now it's a 300-line class that no one fully understands, and every change to the login flow breaks something unexpected elsewhere. This is what happens when Separation of Concerns isn't enforced. SoC is simpler than it sounds: each unit of code has one job, and only that job. This lesson translates that principle into specific, actionable rules for test frameworks.

What SoC means in a test framework

In application code, SoC means splitting business logic, data access, and presentation into separate modules. In test code, the same principle applies to four distinct concerns:

  • Test scenario logic — what business behaviour are we verifying?
  • UI/API interaction — how do we communicate with the application?
  • Assertions — did the application behave correctly?
  • Test data — what inputs are we using?

Each concern belongs in a specific place. When two concerns live in the same class or method, a change to one concern unexpectedly breaks the other — and the root cause is hidden.

The page object contract

The most important SoC rule for UI frameworks: page objects interact, tests assert.

A page object's job is to model the interactions with one page of the application. It exposes methods that correspond to user actions and returns observable state through getters. It does not make assertions. It does not embed business rules. It does not manage test data.

// Page object — correct: interaction and state exposure, no assertions
public class LoginPage {
    private final By emailInput = By.id("email");
    private final By passwordInput = By.id("password");
    private final By submitButton = By.cssSelector("[data-testid='submit']");
    private final By errorMessage = By.cssSelector("[data-testid='error']");
 
    public void enterEmail(String email) {
        driver.findElement(emailInput).sendKeys(email);
    }
 
    public void enterPassword(String password) {
        driver.findElement(passwordInput).sendKeys(password);
    }
 
    public void clickSubmit() {
        driver.findElement(submitButton).click();
    }
 
    // Getter — exposes state; test decides what to assert
    public String getErrorText() {
        return driver.findElement(errorMessage).getText();
    }
 
    public boolean isErrorDisplayed() {
        return !driver.findElements(errorMessage).isEmpty();
    }
}

The test owns what to assert:

@Test
public void loginWithInvalidPasswordShowsError() {
    loginPage.enterEmail("alice@test.com");
    loginPage.enterPassword("wrong");
    loginPage.clickSubmit();
    // Assertion lives here, not in the page object
    assertTrue(loginPage.isErrorDisplayed());
    assertThat(loginPage.getErrorText(), containsString("Invalid credentials"));
}

Why the split? Different tests make different assertions about the same page state. A test for the error message only needs isErrorDisplayed(). A test that verifies the error text also calls getErrorText(). A third test might verify that login works and never call either. If the assertion lived in the page object, every test would be forced into the same assertion — making the page object useful only in one scenario.

What page objects must NOT contain

These are the most common violations that collapse concerns:

Assertions inside page methods:

# Wrong — assertion inside the page object
def login(self, email: str, password: str):
    self.page.fill("#email", email)
    self.page.fill("#password", password)
    self.page.click("#submit")
    # This assertion belongs in the test, not here
    expect(self.page).to_have_url(re.compile("/dashboard"))  # ❌

When you put the assertion in the page object, every test that calls login() implicitly asserts the same thing. A test designed to verify a failed login will fail with a confusing assertion error in the page object rather than failing at the assertion the test actually cares about.

Business rules encoded in page objects:

// Wrong — business logic in a page object
async loginAs(role: string) {
  // Business rule: admin email format — this belongs in test data
  const email = role === "admin" ? "admin@company.com" : `${role}@test.com`;
  await this.page.fill("#email", email);
  // ...
}

When the email format changes, you're editing a page object. Business rules belong in test data factories or test helper methods, not in page interactions.

Test data hardcoded in page objects:

// Wrong — hardcoded credential in the page
public void loginAsDefaultAdmin() {
    driver.findElement(emailInput).sendKeys("admin@test.com");  // ❌ hardcoded
    driver.findElement(passwordInput).sendKeys("Admin123!");
    driver.findElement(submitButton).click();
}

Page objects should receive data as parameters, not own it. When the admin password changes in the test environment, you shouldn't be editing a page object.

Mixed concerns vs separated concerns — same login scenario

Mixed concerns

  • LoginPage asserts dashboard loaded

  • LoginPage hardcodes admin@test.com

  • LoginPage encodes redirect business logic

  • Test changes required for any data, UI, or rule change

  • 50-line test method doing 4 jobs

Separated concerns

  • LoginPage only clicks, fills, returns state

  • Test data from Users factory

  • Business rules in domain helpers

  • Test changes only when the scenario changes

  • 8-line test method doing 1 job

The test's job — only the scenario

Tests should read like a description of the business behaviour being verified, not a description of the implementation. Everything that isn't directly the scenario under test should be invisible.

// Good — the test reads as a scenario, not as driver instructions
test("admin user is redirected to the admin dashboard after login", async ({
  loginPage,
  dashboardPage,
}) => {
  await loginPage.goto();
  await loginPage.login(Users.admin.email, Users.admin.password);
  await expect(dashboardPage.roleIndicator).toHaveText("Admin");
});

Three lines that read as English. Where the admin button is on the DOM, what the selector looks like, what URL was navigated to — none of that is visible. If the login form changes from a modal to a dedicated page, the test doesn't change. The page object changes.

SoC for API tests

The same principle applies in API testing. API client classes (the equivalent of page objects) handle request construction, authentication headers, and response parsing. Tests call clients and assert on the parsed response.

# API client — interaction only
class UserApiClient:
    def __init__(self, base_url: str, token: str):
        self.base = base_url
        self.headers = {"Authorization": f"Bearer {token}"}
 
    def create_user(self, email: str, role: str) -> dict:
        response = requests.post(
            f"{self.base}/users",
            json={"email": email, "role": role},
            headers=self.headers
        )
        response.raise_for_status()
        return response.json()
 
# Test — assertion only
def test_admin_can_create_user(user_api: UserApiClient):
    user = user_api.create_user("newuser@test.com", "viewer")
    assert user["role"] == "viewer"
    assert "id" in user

If the API adds a required header, you change UserApiClient in one place. Every test using the client gets the fix automatically.

⚠️ Common mistakes

  • "Shortcut" page methods that assert. loginPage.loginAndVerifyDashboard() is tempting because it reads clearly. But it forces every test that logs in to also assert the dashboard — even tests that are testing a failed login. Keep login and assertion separate; let the test compose them.
  • Utility methods that carry application context. A utility called generateAdminEmail() is not a utility — it's business logic about what an admin email looks like. Real utilities are application-agnostic: randomEmail(), randomUuid(), readCsv(path).
  • Large page objects that cover multiple pages. A CheckoutPage that also models the payment page and the confirmation page is three concerns in one class. When the payment form changes, it breaks a class that also contains confirmation-page logic. Split by page (or by screen/component on a SPA).

🎯 Practice task

Refactor a concern-tangled page object — 30 minutes.

  1. Find a page object with too many jobs. Look for any page object class that: contains an assertion (imports or calls an assertion library), hardcodes a credential or URL, or has more than two responsibilities. This is your subject.
  2. Extract assertions to the test. Remove every assertion call from the page object. Replace with a getter that returns the state the test was asserting. Update the calling test to assert using the getter.
  3. Extract hardcoded data. Move every hardcoded string (emails, passwords, product names) out of page object methods into method parameters. Update calling tests to pass the values. If a test always uses the same value, create a TestData or Users class that holds named constants.
  4. Measure the result. Count the public method count of your page object before and after. Count the lines in the calling test before and after. The page object should be smaller; the test should be more readable.
  5. Stretch — write a concern checklist. For your team, draft a 5-item checklist that reviewers run on every page object PR: (1) No assertion imports? (2) All data flows as parameters, not hardcoded? (3) Methods named after user actions, not implementation steps? (4) No Thread.sleep or wait calls? (5) No direct driver API calls in the test layer? Add this checklist to your PR template.

Next lesson: SOLID principles — the five object-oriented design guidelines that prevent page objects, factories, and base classes from turning into unmaintainable god objects over time.

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