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:
- Put the cursor anywhere inside the class.
- Right-click → Generate (or Alt+Insert on Windows/Linux, Cmd+N on macOS).
- 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
isfor non-boolean getters.isName()returns a String — confusing. Convention:isFlagandhasThingfor booleans only;getXfor 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.
- Create
TestUser.java. Declare threeprivatefields:String email,String role,int loginAttempts. - Write a constructor
public TestUser(String email, String role)that setsloginAttempts = 0and uses setters to populate the other two — so validation runs at construction. - Write a
setEmail(String email)that throwsIllegalArgumentExceptionif the email is null or doesn't contain@. Otherwise store the lowercased version:this.email = email.toLowerCase();. - Write a
setRole(String role)that accepts only"admin","member", or"guest"— throw on anything else. - Write
getEmail(),getRole(), andgetLoginAttempts(). Don't expose a setter forloginAttempts; instead addvoid recordLoginAttempt() { loginAttempts++; }so only that method can mutate it. - In a small
Democlass with amain, build a few users — one valid, then deliberately trynew TestUser("noatsign", "admin")andnew TestUser("a@b.com", "wizard")to see the validation reject them. - Use
recordLoginAttempt()a few times, then printgetLoginAttempts(). Confirm there's no way for the caller to reset it directly. - Stretch: make
emailandrolefinal— the compiler will force you to remove their setters and set them only in the constructor. You've just turnedTestUserinto 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.