__init__, self, and Instance Methods

8 min read

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." A requests.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 write return self or return None; Python wires the return value automatically. A return in __init__ is allowed but only return 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 via self. Drop the self. and you'd get NameError.
  • One method calling anotherlogin calls self.post. Same self. 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 __x to _ClassName__x to 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 += 1

Calling 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 parentheses

r.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) raises NameError because session and base_url are attributes, not local variables. Always reach through self.
  • Mutable default arguments leaking across instances. def __init__(self, items=[]) reuses the same list for every call without an explicit items=. Use items=None and 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.

  1. Create runner.py.
  2. Define class TestRunner: with __init__(self, name: str, max_retries: int = 3). In __init__, also set self.results = [] and self.failed = 0.
  3. Add def add_result(self, name: str, status: str, duration_ms: int) -> None: that appends a dict to self.results and increments self.failed if status == "FAIL".
  4. Add def summary(self) -> str: returning "<runner_name>: P passed / F failed / T total".
  5. Add def __repr__(self) -> str: returning f"TestRunner({self.name!r}, {len(self.results)} results)".
  6. Add a @property def pass_rate(self) -> float: that returns (passed / total) or 0.0 if total == 0.
  7. Add an internal helper def _passed_count(self) -> int: that returns the count of status == "PASS" results. Use it from summary and pass_rate.
  8. Build a runner with three or four results. Print the runner (uses __repr__), print runner.summary(), print runner.pass_rate.
  9. Stretch: override __str__ to return a multi-line report — header line plus one line per result. Call print(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.

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