Up to now every line you've written has lived inside public class Whatever { public static void main(...) { ... } }. The class was just a wrapper to satisfy Java's "everything in a class" rule. This chapter takes the wrapper seriously: classes are how Java organises data and behaviour together, and they are the foundation of every page object, every test data model, and every framework abstraction you'll write. If you've ever wondered why the same Selenium tutorial mentions LoginPage and extends BasePage, this chapter is the answer.
Class = blueprint, object = instance
A class describes the shape of a thing. An object is one specific thing that follows that shape. The cookie-cutter analogy is overused but it's exactly right: the cutter (class) defines what cookies look like; each cookie (object) is a real piece of dough with its own decorations. The cutter is information — you cannot eat it. The cookies are instances — they live, they're different from each other, you can have many of them.
In QA terms:
LoginPage(the class) describes what every login page object knows and does. It has fields likeemailInputandsubmitButton, and methods likelogin(...).- The
loginPagevariable in your test (the object) is one specific login page tied to the currentWebDriver, with its own state. - Two different tests can have two different
LoginPageobjects, both built from the same class, both behaving the same way.
Defining a class
A simple test-data class:
public class TestUser {
String name;
String email;
String role;
}Three fields (also called instance variables): each TestUser object will have its own copy of name, email, and role. We've left them with no access modifier on purpose for now; lesson 3 covers private and the encapsulation pattern.
The class lives in a file named TestUser.java. The rule from chapter 1.3 still applies: a public class must live in a file with the matching name. If you have multiple classes in one project, each public class lives in its own .java file.
Creating an object — the new keyword
new TestUser() allocates a new instance in memory and returns a reference to it. You store the reference in a variable typed by the class:
public class UserPlayground {
public static void main(String[] args) {
TestUser admin = new TestUser();
admin.name = "Alice";
admin.email = "alice@test.com";
admin.role = "admin";
System.out.println(admin.name + " (" + admin.role + ") — " + admin.email);
}
}Output:
Alice (admin) — alice@test.com
Read each piece:
TestUser admin— declare a variable of typeTestUser. Currently it points at nothing (null).new TestUser()— build a fresh instance. The parentheses are calling the constructor (lesson 2).admin.name = "Alice"— dot syntax to access a field on the object. Same shape as JavaScript and Python.
The variable admin doesn't contain the user; it contains a reference to where the user lives in the heap. This matters for everything in chapter 3.2 (pass-by-value-of-references) — when you pass admin into a method, both names point at the same underlying object.
Many objects, one class
The whole reason classes exist is that you can stamp out many independent objects from the same blueprint:
public class UserDirectory {
public static void main(String[] args) {
TestUser admin = new TestUser();
admin.name = "Alice";
admin.role = "admin";
TestUser standard = new TestUser();
standard.name = "Bob";
standard.role = "member";
TestUser guest = new TestUser();
guest.name = "Carol";
guest.role = "guest";
TestUser[] users = {admin, standard, guest};
for (TestUser u : users) {
System.out.println(u.name + " is a " + u.role);
}
}
}Output:
Alice is a admin
Bob is a member
Carol is a guest
Three objects, each with its own name and role. Setting admin.role doesn't touch standard.role — they are separate instances. That independence is the property classes exist to provide.
Adding behaviour — methods on a class
Classes can hold methods, not just fields. Methods declared without static are instance methods — they belong to a specific object and operate on that object's fields:
public class TestCase {
String name;
String priority;
boolean passed;
public void printSummary() {
String label = passed ? "✅ PASS" : "❌ FAIL";
System.out.println(label + " [" + priority + "] " + name);
}
}Calling it:
public class CaseRunner {
public static void main(String[] args) {
TestCase login = new TestCase();
login.name = "Login as admin";
login.priority = "P0";
login.passed = true;
TestCase search = new TestCase();
search.name = "Search by SKU";
search.priority = "P2";
search.passed = false;
login.printSummary();
search.printSummary();
}
}Output:
✅ PASS [P0] Login as admin
❌ FAIL [P2] Search by SKU
Notice what changed compared to chapter 3: there's no static on printSummary, and we don't call it as TestCase.printSummary(login) — we call it as login.printSummary(). The method runs on a specific object, and inside the method name, priority, and passed refer to that object's fields. This is the dot syntax you've already seen in driver.findElement(...) and cy.get(...) — the method belongs to the object on the left of the dot.
One class, many instances
- – name = 'Alice'
- – role = 'admin'
- – email = 'alice@test.com'
- – name = 'Bob'
- – role = 'member'
- – email = 'bob@test.com'
- name = 'Carol' –
- role = 'guest' –
- email = 'carol@test.com' –
The class in the centre describes the shape (three fields). Each instance fills the shape with its own values. That mental picture — one class, many instances — is the whole foundation of OOP.
Why this matters for QA
The Selenium Page Object Model, the most common pattern in UI test code, is just classes and objects:
LoginPageis a class. It declares fields for the email input, password input, submit button, plus methods likelogin(email, password)andgetErrorMessage().- A test creates an object:
LoginPage loginPage = new LoginPage(driver); - The test calls behaviour through the object:
loginPage.login("alice@x.com", "secret");
Every test framework you'll use does this. TestNG @Test methods live on a test class. Rest Assured response objects expose data through methods. Reading and writing classes fluently is the difference between scripting tests and engineering a test suite.
⚠️ Common mistakes
- Forgetting
new.TestUser admin;declares a variable of typeTestUserbut doesn't create an object —adminisnull. The first time you calladmin.name = "...", you get aNullPointerException. The fix:TestUser admin = new TestUser();. - Confusing the class name with an instance.
TestUser.name = "Alice"is wrong —namebelongs to instances, not to the class. You can only useClass.somethingforstaticfields and methods. Field access goes through an instance:admin.name. - Multiple public classes in one file.
public class TestUserandpublic class LoginPagecannot share a.javafile. One public class per file, and the file must be named after it. Non-public (package-private) classes can share a file but rarely should — keep one class per file as a habit.
🎯 Practice task
Build and use a small data class. 25-30 minutes.
- Create two files in the same folder:
TestCase.javaandCaseRunner.java. - In
TestCase.java, declarepublic class TestCasewith fieldsString name,String priority,long durationMs,boolean passed. - Add an instance method
public void printSummary()that prints✅ PASS [P0] Login as admin (1450ms)style — pull the values from the object's own fields. - Add a second instance method
public boolean isCritical()that returnstrueifpriority.equals("P0"). - In
CaseRunner.java, declarepublic class CaseRunnerwith amainmethod. - Inside
main, create at least threeTestCaseobjects withnew TestCase()and set their fields. Mix priorities and pass/fail values. - Put them in a
TestCase[]array and loop with for-each. For each, callprintSummary(), then ifisCritical()returnstrueandpassedisfalse, also print⚠️ CRITICAL FAILURE. - Compile both files together:
javac TestCase.java CaseRunner.java. Thenjava CaseRunner. Confirm the output matches your inputs. - Stretch: add a
static int totalCases = 0;field onTestCase. Increment it inside aprintSummarycall (or extract to a method) and print it at the end of the run. Static fields belong to the class, not to instances — every object shares the same counter. Reading the difference is a fast way to see why most fields are not static.
You've now built the smallest interesting unit of OO code: a class with fields, a class with methods, and instances created with new. Lesson 2 fixes the awkward "set every field by hand" pattern with constructors.