Classes and Objects in Python

8 min read

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:class keyword, PascalCase name (convention), colon to open the body. Python doesn't need parentheses or extends 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. self is the object being built; self.name is the attribute we're setting.
  • TestUser("Alice", …) — calling the class like a function creates an object. No new keyword — 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 call

In 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())      # True

alice.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

TestUser (class)
  • – 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 self on a method. def summary(): return self.name is missing self as the first parameter — Python raises TypeError: summary() takes 0 positional arguments but 1 was given because Python automatically passes the instance. Every instance method takes self first.
  • 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 no new keyword. Just call the class: user = TestUser("Alice", ...). Writing new TestUser(...) is a SyntaxError.

🎯 Practice task

Model a real piece of QA data. 25-30 minutes.

  1. Create models.py.
  2. Define class TestUser: with __init__(self, name, email, role="tester", is_active=True). Store all four as attributes.
  3. Add three methods:
    • summary(self) — returns a one-line description.
    • is_admin(self) — returns self.role == "admin".
    • email_domain(self) — returns the substring after @ (use self.email.split("@")[-1]).
  4. Create three users with different roles and active flags. Print each summary().
  5. Use a list comprehension to build admins = [u for u in users if u.is_admin()]. Print the count.
  6. Define class TestResult: with __init__(self, name, status, duration_ms). Add passed(self) returning a bool, and is_slow(self, threshold_ms=1000) returning a bool.
  7. Create five TestResult objects. Use list comprehensions to find the failed ones and the slow ones. Print both lists.
  8. Stretch: add a method def to_dict(self) to each class that returns a dict of all the attributes. Confirm it works by print(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.

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