A class is a blueprint. From the blueprint you stamp out objects that hold their own data. In QA work, classes are how you model the things tests touch — pages, API clients, test users, results — and how you bundle the data with the operations that act on it. Python's class syntax is dramatically lighter than Java's: no public, no private, no separate .h and .cpp, no boilerplate getters and setters. This lesson covers what a class is, how to create one, what self does, and how methods turn a passive data bag into a useful tool.
A class without methods — a blueprint
The minimum class:
class TestUser:
def __init__(self, name, email, role="tester"):
self.name = name
self.email = email
self.role = role
admin = TestUser("Alice", "alice@test.com", "admin")
viewer = TestUser("Bob", "bob@test.com") # role defaults to "tester"
print(admin.name) # "Alice"
print(admin.role) # "admin"
print(viewer.role) # "tester"Read each piece:
class TestUser:—classkeyword, PascalCase name (convention), colon to open the body. Python doesn't need parentheses orextends Object.def __init__(self, …):— the constructor, run automatically when you create an object. We'll cover it in detail in the next lesson.self.name = name— store an attribute on this specific instance.selfis the object being built;self.nameis the attribute we're setting.TestUser("Alice", …)— calling the class like a function creates an object. Nonewkeyword — that's a Java/JS-ism Python doesn't have.
That's a complete, working class. Compare to the equivalent in Java — class declaration, three private fields, a constructor, three getters, an optional toString. Twenty-plus lines vs five.
Creating multiple objects from the same class
The whole point of a class is that you can create as many objects as you need:
users = [
TestUser("Alice", "alice@test.com", "admin"),
TestUser("Bob", "bob@test.com"), # role default
TestUser("Carol", "carol@test.com", "viewer"),
]
for u in users:
print(f"{u.name:<10} {u.email:<20} {u.role}")Output:
Alice alice@test.com admin
Bob bob@test.com tester
Carol carol@test.com viewer
Each object has its own copy of the attributes. Changing users[0].role doesn't touch users[1].role.
What self actually is
self is a parameter that points at the current instance. Python passes it implicitly when you call a method on an object:
admin.name # equivalent to TestUser.name(admin) under the hood — but for attributes, no method callIn a method body, self.something reaches the data of the object the method was called on. The self name is a strong convention — Python doesn't enforce it, but every linter, reviewer, and tutorial uses it. Treat self as a magic word; renaming it is a style violation.
If you've seen Java's this, self is the same idea, just named and passed explicitly. The advantage of being explicit: there's no quiet capture, no surprise about which this a closure refers to.
Adding methods — behaviour on the data
Methods are functions defined inside the class. The first parameter is always self:
class TestUser:
def __init__(self, name, email, role="tester"):
self.name = name
self.email = email
self.role = role
def summary(self):
return f"{self.name} ({self.role}) — {self.email}"
def is_admin(self):
return self.role == "admin"
alice = TestUser("Alice", "alice@test.com", "admin")
print(alice.summary()) # "Alice (admin) — alice@test.com"
print(alice.is_admin()) # Truealice.summary() — Python translates that to TestUser.summary(alice). The self parameter receives alice; inside the method, self.name, self.role, and self.email reach Alice's data.
Methods are how a class becomes more than a data bag. is_admin() lives next to the data it depends on — role. Every reviewer, every IDE, every test that imports TestUser immediately sees the available behaviour.
No access modifiers — convention is the rule
In Java you'd write:
public class TestUser {
private String name;
private String email;
public String getName() { return name; }
public void setName(String n) { name = n; }
// ... same for every field ...
}Python has no public, no private. All attributes are accessible from outside. A leading underscore (_internal) is a hint to other developers that "this is intended as internal — touch at your own risk." Two leading underscores (__name) trigger Python's name-mangling, which is rare and usually a sign you're trying too hard to enforce privacy.
The Python community shrugged off enforced encapsulation long ago. The pragmatic effect: less ceremony, equally clean code. If a field is genuinely supposed to be read-only, see @property (next lesson).
A class is a blueprint; objects are the stamps
- – name = 'Alice'
- – email = 'alice@test.com'
- – role = 'admin'
- – name = 'Bob'
- – email = 'bob@test.com'
- – role = 'tester'
- – name = 'Carol'
- – email = 'carol@test.com'
- – role = 'viewer'
- __init__() –
- summary() –
- is_admin() –
Three objects, each with their own attribute values, all sharing the same methods (defined once on the class, callable on every instance). That separation — state per instance, methods on the class — is the core of object-oriented thinking.
Where classes pay off in QA code
Three patterns you'll see in real test codebases:
Page Object Model (Playwright / Selenium). A class per page in the app, with methods for the actions a test can take.
class LoginPage:
def __init__(self, page):
self.page = page
def login(self, email, password):
self.page.fill("[data-testid='email']", email)
self.page.fill("[data-testid='password']", password)
self.page.click("[data-testid='submit']")A test then reads as LoginPage(page).login("alice@test.com", "...") — the page is hidden behind a verb the test cares about.
API clients. A class that wraps requests.Session and exposes one method per endpoint.
class ApiClient:
def __init__(self, base_url, token):
self.base_url = base_url
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {token}"
def get_user(self, user_id):
return self.session.get(f"{self.base_url}/users/{user_id}").json()Every test gets the same auth, retry, and timeout policy without re-wiring it.
Test data models. A class per fixture type — User, Product, Order, TestResult. The dataclass form (lesson 4) makes these especially short.
A small but complete example
A TestResult class that holds the data a test reports back, plus methods for common questions about it:
class TestResult:
def __init__(self, name, status, duration_ms, priority="P2"):
self.name = name
self.status = status
self.duration_ms = duration_ms
self.priority = priority
def summary(self):
return f"{self.name:<20} {self.status:<6} {self.duration_ms:>5} ms"
def passed(self):
return self.status == "PASS"
def is_slow(self, threshold_ms=1000):
return self.duration_ms > threshold_ms
results = [
TestResult("login", "PASS", 1240, "P0"),
TestResult("checkout", "FAIL", 980, "P0"),
TestResult("search", "PASS", 320, "P2"),
]
for r in results:
print(r.summary())
slow_or_failed = [r for r in results if not r.passed() or r.is_slow()]
print(f"\n{len(slow_or_failed)} need attention")Output:
login PASS 1240 ms
checkout FAIL 980 ms
search PASS 320 ms
2 need attention
The class makes the loop expressive — r.passed() and r.is_slow() are clearer than r["status"] == "PASS" and r["duration_ms"] > 1000 would be.
Comparing to dicts and tuples
When should you reach for a class instead of a dict?
- Dict — when the keys are dynamic or many, when shape varies between records, or when you're mostly serialising to/from JSON. Most parsed API responses live in dicts.
- Tuple / NamedTuple — when the shape is small (2-3 fields), values are heterogeneous, and immutability matters.
- Class — when you want methods on the data, when types and field names are stable across many instances, when you need different behaviours via inheritance.
- Dataclass (lesson 4) — the same as a class but with the boilerplate auto-generated. The default for stable-shape test models in modern Python.
The pragmatic rule: if you'd write the same if d["status"] == "PASS" check in twenty places, lift it into an is_passed() method on a class.
⚠️ Common mistakes
- Forgetting
selfon a method.def summary(): return self.nameis missingselfas the first parameter — Python raisesTypeError: summary() takes 0 positional arguments but 1 was givenbecause Python automatically passes the instance. Every instance method takesselffirst. - Setting attributes outside
__init__for "shared" data. A line at class level (role = "tester"outside any method) creates a class attribute shared across all instances — not an instance attribute. Beginners hit this when they want a default; use__init__with a default parameter instead. - Using
new ClassName(...). Python has nonewkeyword. Just call the class:user = TestUser("Alice", ...). Writingnew TestUser(...)is aSyntaxError.
🎯 Practice task
Model a real piece of QA data. 25-30 minutes.
- Create
models.py. - Define
class TestUser:with__init__(self, name, email, role="tester", is_active=True). Store all four as attributes. - Add three methods:
summary(self)— returns a one-line description.is_admin(self)— returnsself.role == "admin".email_domain(self)— returns the substring after@(useself.email.split("@")[-1]).
- Create three users with different roles and active flags. Print each
summary(). - Use a list comprehension to build
admins = [u for u in users if u.is_admin()]. Print the count. - Define
class TestResult:with__init__(self, name, status, duration_ms). Addpassed(self)returning a bool, andis_slow(self, threshold_ms=1000)returning a bool. - Create five
TestResultobjects. Use list comprehensions to find the failed ones and the slow ones. Print both lists. - Stretch: add a method
def to_dict(self)to each class that returns a dict of all the attributes. Confirm it works byprint(user.to_dict()). (Hint: return{"name": self.name, "email": self.email, ...}.)
You can now define and use classes that hold both data and behaviour. The next lesson goes deeper into __init__, self, and the special "dunder" methods that customise how your objects look and compare.