The previous lesson introduced class, __init__, and self. This one goes a level deeper — what runs when, what self actually refers to, why some methods start with double underscores, and how to write a real ApiClient that wraps a requests.Session. By the end you'll know enough OOP to read and modify any Python test framework you'll meet.
__init__ runs once per object
class ApiClient:
def __init__(self, base_url, api_key=None):
print(f"__init__ running for {base_url}")
self.base_url = base_url
self.api_key = api_key
c = ApiClient("https://api.example.com", "abc123")Output:
__init__ running for https://api.example.com
ApiClient(...) creates an empty object, then Python calls __init__(empty_object, "https://api.example.com", "abc123"). Inside __init__, you assign attributes onto self — that's how the empty object becomes the populated object that the call returns.
__init__ is one of Python's dunder methods (short for "double-underscore"). Dunders are the hooks Python uses to wire your class into the language — construction, comparison, printing, length. You don't call them directly; Python calls them in response to operations on the object.
A more interesting __init__ — wire up real state
__init__ doesn't have to be a list of self.x = x lines. Use it to do any setup the object needs to be ready:
import requests
class ApiClient:
def __init__(self, base_url, api_key=None, timeout=5):
self.base_url = base_url
self.api_key = api_key
self.timeout = timeout
self.session = requests.Session()
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
client = ApiClient("https://api.example.com", api_key="abc123")Inside __init__ we both store parameters as attributes and create a fresh requests.Session per client, configured with the auth header. Every call after this point can reuse self.session without rebuilding any of that wiring.
Two important rules:
- Don't call expensive code in
__init__just to "be ready." Arequests.Session()is fine. A blocking HTTP call or a database connection is borderline — many test frameworks build their objects lazily and connect on first use. __init__returns nothing. Don't writereturn selforreturn None; Python wires the return value automatically. Areturnin__init__is allowed but onlyreturn None(no value) is meaningful.
Calling methods on the object
Once the object exists, every method receives self automatically:
class ApiClient:
def __init__(self, base_url, api_key=None):
self.base_url = base_url
self.session = requests.Session()
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
def get(self, endpoint):
url = f"{self.base_url}{endpoint}"
response = self.session.get(url, timeout=5)
response.raise_for_status()
return response.json()
def post(self, endpoint, data):
url = f"{self.base_url}{endpoint}"
response = self.session.post(url, json=data, timeout=5)
response.raise_for_status()
return response.json()
def login(self, email, password):
return self.post("/login", {"email": email, "password": password})
client = ApiClient("https://api.example.com", api_key="abc123")
client.login("alice@test.com", "SecurePass")
users = client.get("/users")Three things to notice:
self.session.get(...)— methods reach instance state viaself. Drop theself.and you'd getNameError.- One method calling another —
logincallsself.post. Sameself.prefix needed. - Methods reuse construction-time wiring — every call goes through
self.session, which already has the auth header from__init__.
That's the shape of every test-framework helper class you'll meet.
__str__ — how the object prints
Default print(obj) looks like <__main__.ApiClient object at 0x10ab32f50>. Useful for nothing. Override __str__ to control what print() and str() produce:
class ApiClient:
def __init__(self, base_url, api_key=None):
self.base_url = base_url
self.api_key = api_key
def __str__(self):
return f"ApiClient({self.base_url})"
print(ApiClient("https://api.example.com"))
# ApiClient(https://api.example.com)Always implement __str__ for classes you'll print or log — it makes failure messages and debug output legible. The rule of thumb: __str__ should return something a human can read at a glance.
__repr__ — the developer view
__repr__ is what you see in the REPL and in repr(obj). It should be unambiguous — usually a string that could be pasted back to recreate the object:
class ApiClient:
def __init__(self, base_url, api_key=None):
self.base_url = base_url
self.api_key = api_key
def __repr__(self):
return f"ApiClient(base_url={self.base_url!r}, api_key={self.api_key!r})"
c = ApiClient("https://api.example.com", "abc123")
c
# ApiClient(base_url='https://api.example.com', api_key='abc123')The !r inside the f-string applies repr() to each value — preserving the quotes for strings, so the output is valid Python.
If you only define __repr__, Python falls back to it for str() too. Many small classes therefore implement only __repr__. In a dataclass (next lesson), both come for free.
The _underscore and __double_underscore conventions
Python has no enforced privacy. Conventions fill the gap:
_name— single leading underscore. "This is internal — use at your own risk." Linters and IDEs may hide these from autocompletion. Most "private" attributes you'll see use this style.__name— double leading underscore (no trailing). Triggers name mangling: Python renames__xto_ClassName__xto make subclass collisions less likely. Useful for libraries that worry about subclass override clashes; rarely needed in test code.__name__— double underscore on both sides. Reserved for Python's built-in protocol methods (__init__,__str__,__len__, …). Never invent your own dunders.
class ApiClient:
def __init__(self, base_url, api_key):
self.base_url = base_url
self._auth_token = api_key # internal
self._failed_attempts = 0 # internal
def _record_failure(self): # internal helper
self._failed_attempts += 1Calling client._record_failure() works (Python won't stop you), but the underscore signals "I'm not part of the public API." Treat the convention as binding.
@property — getter behaviour without parentheses
Sometimes you want an attribute that's computed but reads like a field. The @property decorator turns a method into a read-only attribute:
class TestResult:
def __init__(self, name, duration_ms):
self.name = name
self.duration_ms = duration_ms
@property
def duration_seconds(self):
return self.duration_ms / 1000
r = TestResult("login", 1250)
print(r.duration_seconds) # 1.25 — note: no parenthesesr.duration_seconds looks like an attribute but runs the method body each time. Useful for derived values that don't deserve their own setter. Setting it (r.duration_seconds = 5) raises AttributeError — that's why it's "read-only" by default.
A realistic ApiClient
Putting __init__, instance methods, __repr__, and a private helper into one class:
import requests
class ApiClient:
def __init__(self, base_url: str, api_key: str = None, timeout: int = 5):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self._authenticated = False
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
self._authenticated = True
def __repr__(self) -> str:
auth = "auth" if self._authenticated else "anon"
return f"ApiClient({self.base_url!r}, {auth})"
def login(self, email: str, password: str) -> dict:
data = self.post("/login", {"email": email, "password": password})
self.session.headers["Authorization"] = f"Bearer {data['token']}"
self._authenticated = True
return data
def get(self, endpoint: str, **params) -> dict:
url = self._url(endpoint)
r = self.session.get(url, params=params, timeout=self.timeout)
r.raise_for_status()
return r.json()
def post(self, endpoint: str, payload: dict) -> dict:
url = self._url(endpoint)
r = self.session.post(url, json=payload, timeout=self.timeout)
r.raise_for_status()
return r.json()
def _url(self, endpoint: str) -> str:
return f"{self.base_url}{endpoint if endpoint.startswith('/') else '/' + endpoint}"
c = ApiClient("https://api.example.com")
print(c) # ApiClient('https://api.example.com', anon)
# c.login("alice@test.com", "...")
# users = c.get("/users", role="admin")Three patterns at once: setup in __init__, public methods that act on self, and a private helper (_url) shared between them. Every test in your suite can now build a client once and forget about session, headers, and URL composition.
What happens when you create an object
Step 1 of 6
Call ApiClient(...)
Python sees the class being called like a function — that's the cue to build a new instance.
Six steps every time you build an object. Memorise the order — it explains both why methods need self and why something assigned at class level (outside __init__) is shared instead of per-instance.
⚠️ Common mistakes
- Forgetting
self.when reading an attribute inside a method.def get(endpoint): return self.session.get(self.base_url + endpoint)works;def get(endpoint): return session.get(base_url + endpoint)raisesNameErrorbecausesessionandbase_urlare attributes, not local variables. Always reach throughself. - Mutable default arguments leaking across instances.
def __init__(self, items=[])reuses the same list for every call without an explicititems=. Useitems=Noneand create a new list inside:self.items = items if items is not None else []. - Defining state at class level by accident. Lines outside any method (
results = []at class indent level) create class attributes shared by every instance. For per-instance state, assign in__init__. Reviewers spot this fast — class attributes that grow at runtime are nearly always a bug.
🎯 Practice task
Build a small TestRunner class. 25-30 minutes.
- Create
runner.py. - Define
class TestRunner:with__init__(self, name: str, max_retries: int = 3). In__init__, also setself.results = []andself.failed = 0. - Add
def add_result(self, name: str, status: str, duration_ms: int) -> None:that appends a dict toself.resultsand incrementsself.failedifstatus == "FAIL". - Add
def summary(self) -> str:returning"<runner_name>: P passed / F failed / T total". - Add
def __repr__(self) -> str:returningf"TestRunner({self.name!r}, {len(self.results)} results)". - Add a
@property def pass_rate(self) -> float:that returns(passed / total)or0.0iftotal == 0. - Add an internal helper
def _passed_count(self) -> int:that returns the count ofstatus == "PASS"results. Use it fromsummaryandpass_rate. - Build a runner with three or four results. Print the runner (uses
__repr__), printrunner.summary(), printrunner.pass_rate. - Stretch: override
__str__to return a multi-line report — header line plus one line per result. Callprint(runner)and confirm Python prefers__str__over__repr__when both are defined.
You can now build classes with realistic constructors, methods, repr/str, and computed properties. The next lesson covers inheritance — sharing behaviour between related classes, the way every Page Object Model is built.