Inheritance and Method Overriding

8 min read

Many classes share behaviour. Every page in your Playwright suite needs to navigate, every API endpoint needs auth headers, every test needs setup. Inheritance is how Python lets a child class reuse a parent's logic and add or override what's specific. The Page Object Model — the dominant pattern in modern UI test code — is built on inheritance. This lesson covers class Child(Parent):, super(), method overriding, multiple inheritance (Python's super-power that Java lacks), and isinstance() for type checks.

A parent and a child

class BasePage:
    def __init__(self, page):
        self.page = page
 
    def navigate(self, path: str) -> None:
        self.page.goto(f"https://myapp.com{path}")
 
    def get_title(self) -> str:
        return self.page.title()
 
 
class LoginPage(BasePage):
    def login(self, email: str, password: str) -> None:
        self.page.fill("[data-testid='email']", email)
        self.page.fill("[data-testid='password']", password)
        self.page.click("[data-testid='submit']")

Read it line by line:

  • class LoginPage(BasePage): — the parentheses contain the parent class. LoginPage is a BasePage.
  • LoginPage inherits everything from BasePage — __init__, navigate, get_title. We don't redeclare any of them.
  • LoginPage adds one new method, login. It still has access to self.page (set up in BasePage's __init__).

A test using both:

login = LoginPage(playwright_page)
login.navigate("/login")           # inherited from BasePage
login.login("alice@test.com", "...")  # defined on LoginPage
print(login.get_title())           # inherited from BasePage

The child class is strictly more capable than the parent. Anywhere a BasePage is expected, a LoginPage works.

When to override __init__

Sometimes the child needs extra setup. Override __init__, do the parent's setup with super().__init__(...), then do your own:

class BasePage:
    def __init__(self, page):
        self.page = page
 
class LoginPage(BasePage):
    def __init__(self, page):
        super().__init__(page)        # parent runs first — sets self.page
        self.email_field = "[data-testid='email']"
        self.password_field = "[data-testid='password']"

super() returns a proxy that lets you call methods on the parent class. super().__init__(page) runs BasePage.__init__(self, page) — which assigns self.page = page. Then we add our own attributes after.

Always call super().__init__() if the parent has its own setup. Skipping it means the parent's attributes never exist on the child, and methods that need them break with AttributeError. The one exception: if the child's __init__ does all the work and the parent had nothing to set up.

Method overriding

Override by redefining a method with the same name. Optionally call the parent's version with super():

class BasePage:
    def navigate(self, path: str) -> None:
        self.page.goto(f"https://myapp.com{path}")
 
 
class DashboardPage(BasePage):
    def navigate(self, path: str = "/dashboard") -> None:
        super().navigate(path)
        self.page.wait_for_selector("[data-testid='dashboard-loaded']")

DashboardPage.navigate does what BasePage.navigate does and waits for the dashboard's loaded marker. Tests can call dashboard.navigate() (no path needed thanks to the default) and trust that the page is ready when control returns.

The override pattern reads as: "do what the parent does, plus / instead of this extra thing." Use super().method() for "plus"; replace the body entirely for "instead of."

Multiple inheritance — Python's extra trick

Java has single inheritance only — a class extends exactly one parent. Python allows multiple parents:

class Searchable:
    def search(self, query: str) -> None:
        self.page.fill("[data-testid='search']", query)
        self.page.press("[data-testid='search']", "Enter")
 
 
class BasePage:
    def __init__(self, page):
        self.page = page
 
    def navigate(self, path: str) -> None:
        self.page.goto(f"https://myapp.com{path}")
 
 
class ProductPage(BasePage, Searchable):
    pass        # inherits from BOTH parents — all their methods available

ProductPage gets navigate from BasePage and search from Searchable:

products = ProductPage(playwright_page)
products.navigate("/products")
products.search("widget")

This is the pattern Java emulates with interfaces — a class implements multiple interfaces but only extends one class. Python collapses both into one mechanism.

The trade-off is the method resolution order (MRO): when two parents have the same method name, which one wins? Python uses the C3 linearisation algorithm — left-to-right, depth-first, with each class appearing only once. For most QA cases (where parents don't overlap), you'll never think about it. When they do, prefer composition (giving a class an instance of another) over multiple inheritance.

A common shape: a small abstract-ish base

Python doesn't have Java's abstract keyword (without an extra import), but you can express "this method must be overridden" by raising NotImplementedError:

class BasePage:
    def __init__(self, page):
        self.page = page
 
    def url_path(self) -> str:
        raise NotImplementedError("subclasses must define url_path()")
 
    def navigate(self) -> None:
        self.page.goto(f"https://myapp.com{self.url_path()}")
 
 
class LoginPage(BasePage):
    def url_path(self) -> str:
        return "/login"
 
 
class CheckoutPage(BasePage):
    def url_path(self) -> str:
        return "/checkout"

Now LoginPage(...).navigate() and CheckoutPage(...).navigate() both go to the right URL using the same shared logic in BasePage. The only thing each child has to provide is its own URL path.

For stricter "you must override" rules, the standard library's abc module (from abc import ABC, abstractmethod) refuses to even let you instantiate a class that hasn't implemented an abstract method. Useful for libraries; rarely needed in test code.

isinstance() — checking the family

isinstance(obj, ClassName) returns True if obj is an instance of ClassName or any of its subclasses:

login = LoginPage(playwright_page)
 
isinstance(login, LoginPage)     # True
isinstance(login, BasePage)      # True — LoginPage IS-A BasePage
isinstance(login, Searchable)    # False

Use isinstance rather than type(obj) == ClassName — the type form rejects subclasses, which usually isn't what you want.

For "is this an instance of any of these?", pass a tuple: isinstance(obj, (LoginPage, DashboardPage)).

A page-object hierarchy

Three patterns visible at once: pure extension (LoginPage adds), override (DashboardPage replaces and super()s), and composition via multiple inheritance (ProductPage gets behaviour from two parents). That's the toolkit a real Playwright suite uses.

A worked example — three-page Playwright scaffold

class BasePage:
    def __init__(self, page):
        self.page = page
 
    def navigate(self, path: str) -> None:
        self.page.goto(f"https://myapp.com{path}")
 
    def title(self) -> str:
        return self.page.title()
 
 
class LoginPage(BasePage):
    URL = "/login"
 
    def navigate(self, path: str = URL) -> None:
        super().navigate(path)
 
    def login(self, email: str, password: str) -> None:
        self.page.fill("[data-testid='email']", email)
        self.page.fill("[data-testid='password']", password)
        self.page.click("[data-testid='submit']")
 
 
class DashboardPage(BasePage):
    URL = "/dashboard"
 
    def navigate(self, path: str = URL) -> None:
        super().navigate(path)
        self.page.wait_for_selector("[data-testid='dashboard-loaded']")
 
    def open_profile(self) -> None:
        self.page.click("[data-testid='profile-menu']")
 
 
# In a test
def test_login_lands_on_dashboard(page):
    login = LoginPage(page)
    login.navigate()
    login.login("alice@test.com", "SecurePass")
 
    dash = DashboardPage(page)
    dash.navigate()
    assert "Dashboard" in dash.title()

That's a complete, idiomatic page-object scaffold. BasePage does the URL composition once; LoginPage and DashboardPage add only what's specific to each page; the test reads almost like English.

⚠️ Common mistakes

  • Forgetting super().__init__() in the child. If the parent's __init__ set up self.page, and your child overrode __init__ without calling super(), then self.page doesn't exist — every method that uses it raises AttributeError. Always start the override with super().__init__(...) unless you're certain the parent had nothing to do.
  • Calling BasePage.method(self, ...) directly. It works in single inheritance, but breaks under multiple inheritance — the MRO is bypassed and you may skip a sibling's method. Use super().method(...) always; it follows the right chain automatically.
  • Using inheritance when composition would be cleaner. "LoginPage is-a BasePage" is fine — pages share navigation. But "TestRunner is-a Database" is not — they don't have an "is-a" relationship. Reach for an attribute (self.db = Database()) instead of inheritance when the relationship is "has-a." Bad inheritance trees are the most common architectural mistake in test frameworks.

🎯 Practice task

Build a tiny page-object hierarchy. 25-30 minutes.

  1. Create pages.py.
  2. Define class BasePage: with __init__(self, page) storing self.page = page. Add navigate(self, path: str) that prints f"navigating to https://myapp.com{path}" (no real Playwright needed for this exercise — print is fine).
  3. Add def title(self) that returns f"<title for path={getattr(self, 'last_path', '?')}>" — use the path tracking shown next.
  4. In BasePage.navigate, also set self.last_path = path before printing. (This is the kind of breadcrumb a real framework would log.)
  5. Define class LoginPage(BasePage): with class constant URL = "/login" and an override of navigate that defaults path=URL and calls super().navigate(path). Add login(self, email, password) that just prints what would be filled and clicked.
  6. Define class DashboardPage(BasePage): with URL = "/dashboard", an override of navigate that calls super().navigate(path) and then prints "waiting for dashboard...".
  7. Define a mixin class Searchable: with one method search(self, query) that prints f"searching for {query}". Then class ProductPage(BasePage, Searchable): with URL = "/products".
  8. Write a small driver at the bottom: build each page with a fake page = "PLAYWRIGHT-PAGE-PLACEHOLDER", call navigate() on each, and on the ProductPage also call search("widget").
  9. Add three isinstance checks at the bottom and print their results: is login a BasePage? is product a Searchable? is dash a LoginPage?
  10. Stretch: add class AuthenticatedBasePage(BasePage): that overrides navigate to print "checking auth token..." before calling super().navigate(...). Have DashboardPage inherit from AuthenticatedBasePage instead of BasePage. Confirm the order of prints when you call dash.navigate() reflects the chain: auth check → BasePage → "waiting for dashboard...".

You can now share behaviour across related classes the way every modern Playwright suite does. The next lesson introduces @dataclass — Python's "make-me-a-data-class-with-no-boilerplate" decorator, perfect for test fixtures.

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