Encapsulation — Getters, Setters, Access Modifiers

8 min read

A class with public fields lets any caller set anything to anything. user.email = "not-an-email", config.timeoutSeconds = -50, case.priority = null — all compile, all run, all blow up later in some entirely different file. Encapsulation is the OOP principle that fixes this: hide the internal data behind methods that control how it gets read and written. The data is private; the access methods (getters and setters) are public — and the setters can validate. This is one of the most quietly important ideas in Java, and it's what makes the Selenium Page Object Model robust enough for real test suites.

Access modifiers — who can see what

Every field, method, and class can be marked with an access modifier:

  • public — accessible from anywhere.
  • private — accessible only inside the same class. Nothing else, not even a subclass.
  • protected — accessible inside the same package and any subclass (we'll come back to this in lesson 4).
  • (default / package-private) — no keyword. Accessible inside the same package, nothing outside.

For everyday QA work the two that matter most are public (the API your tests will call) and private (the implementation details no one else should touch). The encapsulation pattern is built on those two:

public class TestUser {
    private String name;     // hidden
    private String email;    // hidden
 
    public String getName()              { return name; }
    public void   setName(String name)   { this.name = name; }
 
    public String getEmail()             { return email; }
    public void   setEmail(String email) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + email);
        }
        this.email = email;
    }
}

The fields are private — outside code can't write user.email = "garbage" because the field isn't reachable. The only way in is through setEmail, which validates first. The class becomes the gatekeeper for its own data.

Why bother — the price of public fields

Imagine your test suite has 200 test cases, all of which create users with user.email = ... directly. Six months in, product decides emails must be lowercased before storage. With public fields, you have 200 places to change. With a setter:

public void setEmail(String email) {
    if (email == null || !email.contains("@")) {
        throw new IllegalArgumentException("Invalid email: " + email);
    }
    this.email = email.toLowerCase();        // ← single line; every test now lowercases
}

One line, one place. That ability to evolve the implementation without changing every caller is the entire reason encapsulation exists. The same logic applies to validation, logging, normalisation, and every other "I want this to happen consistently" rule.

Direct vs encapsulated access

Public fields vs encapsulation

Public fields — direct access

  • user.email = "garbage"; — no validation, accepts anything

  • user.timeoutSeconds = -50; — silently invalid

  • Adding validation later means changing every caller

  • No place to log, normalise, or compute on read/write

  • Internal field name is part of the public API; renaming breaks callers

private fields + public getters/setters

  • user.setEmail("...") — validation in one place

  • user.setTimeoutSeconds(-50) — throws IllegalArgumentException

  • Future change: lowercase the email — one-line edit, callers unchanged

  • Easy to log every set, lazily compute values, raise events

  • Callers never depend on the internal field name; refactor freely

The pattern is sometimes mocked as "boilerplate" but the green column is what real test frameworks ship. Selenium's WebElement doesn't expose its internal state directly; its getText(), isDisplayed(), getAttribute(...) are all carefully chosen public methods. The internal fields are private and you can't see them from a test. That's why Selenium can rewrite its internals without breaking your tests — the public surface is stable, the private part is free to evolve.

A real example — TestConfig with validation

Here's a config class that guarantees its own correctness:

public class TestConfig {
    private String baseUrl;
    private int timeoutSeconds;
    private boolean dryRun;
 
    public TestConfig(String baseUrl, int timeoutSeconds, boolean dryRun) {
        setBaseUrl(baseUrl);                  // funnel through the setters
        setTimeoutSeconds(timeoutSeconds);
        this.dryRun = dryRun;
    }
 
    public String getBaseUrl() { return baseUrl; }
    public void setBaseUrl(String baseUrl) {
        if (baseUrl == null || !baseUrl.startsWith("http")) {
            throw new IllegalArgumentException("baseUrl must start with http: " + baseUrl);
        }
        this.baseUrl = baseUrl;
    }
 
    public int getTimeoutSeconds() { return timeoutSeconds; }
    public void setTimeoutSeconds(int timeoutSeconds) {
        if (timeoutSeconds <= 0) {
            throw new IllegalArgumentException("timeout must be positive: " + timeoutSeconds);
        }
        this.timeoutSeconds = timeoutSeconds;
    }
 
    public boolean isDryRun()           { return dryRun; }
    public void setDryRun(boolean v)    { this.dryRun = v; }
 
    public static void main(String[] args) {
        TestConfig cfg = new TestConfig("https://staging.myapp.com", 30, false);
        System.out.println("URL:     " + cfg.getBaseUrl());
        System.out.println("Timeout: " + cfg.getTimeoutSeconds() + "s");
        System.out.println("Dry-run: " + cfg.isDryRun());
 
        try {
            cfg.setTimeoutSeconds(-5);
        } catch (IllegalArgumentException e) {
            System.out.println("Refused: " + e.getMessage());
        }
    }
}

Output:

URL:     https://staging.myapp.com
Timeout: 30s
Dry-run: false
Refused: timeout must be positive: -5

Notice three things. First, the constructor calls the setters rather than assigning fields directly — every entry point goes through validation, including construction. Second, the boolean's getter is named isDryRun(), not getDryRun() — the convention for booleans uses is or has. Third, an invalid call doesn't silently corrupt the object; it throws. Tests fail loudly at the boundary, not three steps later.

Getters and setters in IntelliJ

Writing 30 getters and setters by hand is the kind of tedious work IDEs were invented for. In IntelliJ:

  1. Put the cursor anywhere inside the class.
  2. Right-click → Generate (or Alt+Insert on Windows/Linux, Cmd+N on macOS).
  3. Choose Getter and Setter, tick the fields you want, click OK.

IntelliJ generates them in the canonical form, including is for booleans. Real teams generate; nobody types these by hand for a class with five fields.

Immutable objects — only getters

For data that should never change after construction, omit the setters entirely and mark the fields final:

public class TestId {
    private final String suite;
    private final String name;
 
    public TestId(String suite, String name) {
        this.suite = suite;
        this.name = name;
    }
 
    public String getSuite() { return suite; }
    public String getName()  { return name; }
}

Once a TestId is built, no caller can change it. Immutable objects are easy to reason about — there are no unexpected mutations halfway through a test — and easy to share across threads safely (parallel tests, in chapter 8). Java's String, Integer, and Duration are all immutable. Most modern Java code prefers immutable data classes wherever the value really shouldn't change.

⚠️ Common mistakes

  • Public fields with "we'll add encapsulation later." "Later" never comes; instead, two years on, 80 callers depend on the public field and you can't change it without a coordinated migration. Make fields private from day one — IntelliJ's generator means the cost is zero.
  • Setters that don't validate. A setter that just does this.x = x; is no better than a public field — it's cosplaying encapsulation. The point of a setter is to enforce rules. If there's nothing to enforce, that's fine, but at least you've reserved the seam for when there is.
  • Using is for non-boolean getters. isName() returns a String — confusing. Convention: isFlag and hasThing for booleans only; getX for everything else. Frameworks like Jackson and Lombok generate based on this convention; breaking it confuses tooling as much as humans.

🎯 Practice task

Encapsulate a real test data class. 25-30 minutes.

  1. Create TestUser.java. Declare three private fields: String email, String role, int loginAttempts.
  2. Write a constructor public TestUser(String email, String role) that sets loginAttempts = 0 and uses setters to populate the other two — so validation runs at construction.
  3. Write a setEmail(String email) that throws IllegalArgumentException if the email is null or doesn't contain @. Otherwise store the lowercased version: this.email = email.toLowerCase();.
  4. Write a setRole(String role) that accepts only "admin", "member", or "guest" — throw on anything else.
  5. Write getEmail(), getRole(), and getLoginAttempts(). Don't expose a setter for loginAttempts; instead add void recordLoginAttempt() { loginAttempts++; } so only that method can mutate it.
  6. In a small Demo class with a main, build a few users — one valid, then deliberately try new TestUser("noatsign", "admin") and new TestUser("a@b.com", "wizard") to see the validation reject them.
  7. Use recordLoginAttempt() a few times, then print getLoginAttempts(). Confirm there's no way for the caller to reset it directly.
  8. Stretch: make email and role final — the compiler will force you to remove their setters and set them only in the constructor. You've just turned TestUser into an immutable object for those two fields. That's the Java idiom for "set once, never change."

Encapsulation, constructors, and classes together give you a real domain model. Lesson 4 introduces inheritance — the mechanism that lets one class build on another, and the foundation of every Page Object framework you'll meet.

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