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.LoginPageis aBasePage.- 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 toself.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 BasePageThe 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 availableProductPage 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) # FalseUse 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 upself.page, and your child overrode__init__without callingsuper(), thenself.pagedoesn't exist — every method that uses it raisesAttributeError. Always start the override withsuper().__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. Usesuper().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.
- Create
pages.py. - Define
class BasePage:with__init__(self, page)storingself.page = page. Addnavigate(self, path: str)that printsf"navigating to https://myapp.com{path}"(no real Playwright needed for this exercise — print is fine). - Add
def title(self)that returnsf"<title for path={getattr(self, 'last_path', '?')}>"— use the path tracking shown next. - In
BasePage.navigate, also setself.last_path = pathbefore printing. (This is the kind of breadcrumb a real framework would log.) - Define
class LoginPage(BasePage):with class constantURL = "/login"and an override ofnavigatethat defaultspath=URLand callssuper().navigate(path). Addlogin(self, email, password)that justprints what would be filled and clicked. - Define
class DashboardPage(BasePage):withURL = "/dashboard", an override ofnavigatethat callssuper().navigate(path)and then prints"waiting for dashboard...". - Define a mixin
class Searchable:with one methodsearch(self, query)that printsf"searching for {query}". Thenclass ProductPage(BasePage, Searchable):withURL = "/products". - Write a small driver at the bottom: build each page with a fake
page = "PLAYWRIGHT-PAGE-PLACEHOLDER", callnavigate()on each, and on theProductPagealso callsearch("widget"). - Add three
isinstancechecks at the bottom and print their results: isloginaBasePage? isproductaSearchable? isdashaLoginPage? - Stretch: add
class AuthenticatedBasePage(BasePage):that overridesnavigateto print"checking auth token..."before callingsuper().navigate(...). HaveDashboardPageinherit fromAuthenticatedBasePageinstead ofBasePage. Confirm the order of prints when you calldash.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.