DEV Community

Cover image for # Modern Python for Automation: OOP, Async, and Type Hints
Himanshu Agarwal
Himanshu Agarwal

Posted on

# Modern Python for Automation: OOP, Async, and Type Hints

A Detailed Guide for Test Automation Engineers

Python has quietly become the default language for test automation, scripting, and tooling — not because it's the fastest language, but because it's the most forgiving one to read, maintain, and extend under deadline pressure. Yet most engineers who write automation code learned Python "on the job," picking up just enough syntax to get a script running. That's fine for a five-line helper function. It falls apart the moment your test suite grows into hundreds of files, dozens of page objects, and a handful of engineers who all need to understand each other's code six months later.

This article walks through the core ideas that separate "Python that works" from "Python that scales" — object-oriented design, type hints, and asynchronous programming — the same three pillars covered in Modern Python for Automation, Vol. 2. Think of it as a tour of the reasoning behind modern Python idioms, with enough detail to actually apply them in a real framework.


Part 1: Object-Oriented Programming, Properly Applied

The Four Pillars, Revisited

Most engineers can recite the four pillars of OOP — encapsulation, abstraction, inheritance, polymorphism — without ever having applied them deliberately. In automation code, these aren't academic concepts; they solve specific, recurring pain points.

Encapsulation means bundling data with the methods that operate on it, and controlling what's exposed to the outside world. In test automation, this shows up constantly in Page Object Models: a LoginPage class should expose login(username, password), not the raw locator strings and driver calls needed to type into a field and click a button. When the underlying HTML changes, only the internals of the class need to change — every test that calls login() keeps working untouched. Without encapsulation, a single UI tweak can mean editing fifty test files instead of one class.

Abstraction is the discipline of hiding the "how" behind a simple "what." A well-designed automation framework lets a test author write checkout_page.complete_purchase() without knowing whether that involves three API calls, a WebDriver click sequence, or a mobile gesture. The complexity still exists — it's just been pushed down a layer, out of the way of people who don't need to see it every day.

Inheritance lets you share behavior between related classes. A BasePage class might hold common logic like wait_for_load() or take_screenshot(), and every specific page class inherits it. This is genuinely useful — until it's overused, which is a theme we'll return to.

Polymorphism means different classes can respond to the same method call in their own way. If LoginPage, CheckoutPage, and SettingsPage all implement a load() method, a test runner can loop over a list of pages and call page.load() on each one without caring which specific class it's dealing with. This is what makes generic helpers and test loops possible — the runner doesn't need a special case for every page type.

These four ideas aren't independent tools you pick from a menu. They compound: abstraction is what encapsulation makes possible; polymorphism is what a well-designed inheritance (or composition) hierarchy enables. Understanding them together, rather than as four separate vocabulary words, is what actually changes how you structure a framework.

Dunder Methods: Making Your Objects Speak Python

Python's "data model" is a set of special hooks — methods with double underscores, or "dunders" — that Python calls automatically when you use built-in syntax. Write print(obj), and Python calls obj.__str__(). Write a == b, and Python calls a.__eq__(b). You almost never call these directly; you implement them once, and Python invokes them for you at the right moment forever after.

For test automation specifically, two dunders pay for themselves immediately: __repr__ and __eq__.

__str__ gives the friendly, human-readable form of an object — the one you'd want a non-technical stakeholder to see. __repr__ gives the unambiguous, developer-facing form, and it's what shows up in debuggers, logs, and — critically — pytest assertion failures. A class without a custom __repr__ prints as something like <User object at 0x7f...>, which tells you nothing when a test fails. A class with a good __repr__ prints User(email='a@x.com', role='admin'), and suddenly a failed assertion is readable at a glance instead of requiring a debugger session.

__eq__ controls what == means for your objects. By default, Python compares object identity — two User instances with identical data are considered unequal unless you tell Python otherwise. Implementing __eq__ to compare meaningful fields means assert expected_user == actual_user works the way test authors intuitively expect. One common trap: defining __eq__ without also defining __hash__ makes your objects unhashable, meaning they can no longer be used in sets or as dictionary keys — something the dataclasses module (covered later) handles automatically and correctly.

Properties, Class Methods, and Static Methods

Three decorators reshape how methods behave, and each solves a different, common framework need.

@property lets a method be called like an attribute — no parentheses. This matters because it lets you compute or validate a value on the fly while keeping the calling code clean: user.full_name instead of user.full_name(). It also lets you retrofit validation onto existing attributes without breaking every caller — you can turn a plain attribute into a property later, and nothing that reads user.email needs to change, even though a validation check now runs behind the scenes.

@classmethod receives the class itself (conventionally named cls) rather than an instance. Its most common use is as an alternative constructor — a "factory" method that builds an object from some other shape of data. This is enormously useful in test frameworks, where raw data commonly arrives as a dictionary, a row of CSV, or a parsed JSON blob. A User.from_dict(data) classmethod turns that raw data into a properly typed object in one line, and every test that needs a User fixture can share the same construction logic instead of re-implementing it.

@staticmethod takes neither self nor cls — it's a plain function that happens to live inside a class for organizational reasons. It's the right choice for a helper that's conceptually related to the class (like Email.is_valid(address)) but doesn't need any instance or class state to do its job. Grouping these utilities inside the relevant class, rather than scattering them as free-floating functions, makes a codebase easier to navigate — an engineer looking for email-related logic knows to look at the Email class first.

Abstract Base Classes: Enforcing a Contract

As a framework grows past a handful of page objects, an informal expectation like "every page class should have a load() method" stops being reliable. Someone forgets. A new page class ships without it. The bug doesn't surface until a test calls .load() deep into a run and gets an AttributeError — an unhelpful failure mode that wastes debugging time.

Abstract Base Classes (ABCs) turn that informal expectation into an enforced rule. A class inheriting from ABC can declare methods as @abstractmethod — meaning they have no implementation, and any subclass must implement them or Python will refuse to let you instantiate that subclass at all. Try to construct a LoginPage that forgot to implement is_loaded(), and you get an immediate, clear TypeError at the moment of construction — not a mysterious failure buried in test output ten minutes later.

This matters most as a team scales. ABCs give a framework a spine: every page class is guaranteed to share the same core interface, so generic helpers, retry logic, and test loops can rely on that interface without special-casing each page type. It's polymorphism with a safety net.

Composition, Mixins, and the Limits of Inheritance

Inheritance is the OOP tool most engineers reach for first, and the one most likely to cause long-term pain if overused. Deep inheritance chains are fragile: a change to a base class ripples unpredictably through every subclass beneath it, and it's easy to end up with a BasePage that's grown fifteen unrelated responsibilities because "it seemed easiest to just add it to the base class."

Composition offers a different default: instead of a class being a kind of another class, it has other objects it delegates to. A CheckoutPage doesn't need to inherit from a Logger class — it can simply hold a Logger instance as an attribute and call it when needed. This keeps responsibilities separate and makes it much easier to swap implementations later (a different logger, a different retry strategy) without touching the page class itself.

Mixins offer a middle ground: small, focused classes that add exactly one capability — waiting, retrying, screenshotting — that gets combined into a page class via inheritance. Because a mixin does one thing, many unrelated page classes can share it without forcing them into a single rigid hierarchy. The rule of thumb worth internalizing: reach for composition by default, use a mixin when you genuinely need to inject a small reusable behavior across otherwise-unrelated classes, and reserve deep inheritance for cases where an "is-a" relationship is genuinely stable and unlikely to need reshaping later.

Dataclasses: Less Boilerplate, Fewer Bugs

Writing a plain Python class to hold structured data — a test fixture, a config object, an API response model — traditionally meant writing __init__, __repr__, and __eq__ by hand, every time, for every class. The dataclasses module (built into Python 3.7+) generates all of that for you from a simple field declaration, which means less code to write and, more importantly, less code to get subtly wrong.

A @dataclass-decorated class gets a sensible __init__ based on its annotated fields, a readable __repr__ for free, and a correct __eq__ that compares field values — exactly the dunder behavior described earlier, without writing it manually. For test data models specifically, this eliminates an entire class of bugs where a hand-written __eq__ forgets to compare a newly added field.

Frozen dataclasses (@dataclass(frozen=True)) take this further by making instances immutable after creation — any attempt to reassign a field raises an error. This is valuable for test fixtures and expected-result objects: once you've built the "expected" object for an assertion, you generally don't want any code path accidentally mutating it mid-test and silently invalidating the comparison. Immutability turns an entire category of "why did this fixture change underneath me" bugs into something Python catches for you automatically.


Part 2: Type Hints — Documentation That Never Goes Stale

Why Bother, in a Dynamically Typed Language?

Python has always let you write code without declaring types, and that flexibility is part of its appeal. Type hints don't take that away — Python remains dynamically typed at runtime, and hints are not enforced unless you run a separate tool. What they do provide is documentation that can't silently drift out of date the way a comment can, plus a safety net that catches an entire category of bugs before a test ever runs.

Consider a function def find_user(user_id):. Does user_id expect a string or an integer? Does the function return None if no user is found, or raise an exception? A comment can answer this, but comments rot — someone changes the behavior and forgets to update the docstring. A type hint like def find_user(user_id: int) -> User | None: answers the same questions in a form your editor actively checks against every call site, and that a static type checker can verify never drifted out of sync with reality.

For automation frameworks specifically, hints pay off disproportionately because these codebases tend to have long-lived, widely reused utility functions — page object constructors, API client wrappers, fixture factories — that get called from dozens of test files written by different people over years. A hint on a widely used function is documentation that reaches every one of those call sites simultaneously.

The typing Toolkit

Beyond basic hints like str and int, Python's typing module (and, increasingly, built-in generic syntax) covers the shapes that show up constantly in real code:

  • Collectionslist[str], dict[str, int], tuple[int, ...] describe not just "this is a list" but "this is a list of strings," catching a whole class of mix-up bugs where the wrong element type sneaks into a collection.
  • Optional and UnionOptional[User] (equivalent to User | None) makes it explicit when a function might return nothing, forcing callers to handle that case rather than discovering it via a runtime crash. Union[int, str] documents that a value can legitimately be more than one type.
  • Callable and AnyCallable[[int, int], bool] describes a function argument's own signature (useful for callback-heavy code, like custom wait conditions). Any is an intentional escape hatch for genuinely dynamic values — used sparingly, it signals "this part hasn't been typed yet" rather than "this can't be typed."

Static Checking, TypedDict, and Protocols

Type hints only pay off fully once something actually checks them. mypy is the standard tool for this: it reads your hints and flags mismatches — passing a string where an int is expected, calling a method that doesn't exist on a given type — as errors, before the code ever runs. Wiring mypy into CI turns type hints from optional documentation into an enforced contract, catching an entire category of bugs during code review instead of during a flaky midnight test run.

TypedDict solves a specific and common pain point: dictionaries with a fixed, known shape. A raw API response is often a dict, but not just any dict — it has specific expected keys with specific value types. A plain dict[str, Any] hint documents none of that. TypedDict lets you declare exactly which keys exist and what type each maps to, so a typo in a key name (respones["staus"] instead of response["status"]) becomes a caught error rather than a silent KeyError at runtime.

Protocol brings structural typing to Python — the idea that a type is defined by what it can do, not by what it explicitly inherits from. A Protocol describing "anything with a .load() method" is satisfied by any class that happens to have a load() method, regardless of its inheritance tree. This is a lighter-weight alternative to Abstract Base Classes for cases where you want to type-check an interface without forcing every implementer into a shared inheritance hierarchy — useful when integrating third-party classes you don't control into your typed codebase.


Part 3: Asynchronous Python — Concurrency Without Threads

The Event Loop, Conceptually

Modern browser automation, API testing against multiple services, and any I/O-heavy workload benefits enormously from concurrency — running many waiting operations at once instead of one after another. Python's asyncio achieves this without traditional multi-threading, using a single-threaded event loop that juggles many in-progress tasks, switching between them whenever one is waiting on I/O (a network response, a file read, a timer).

The mental model that clicks for most people: think of the event loop as a single chef managing many dishes at once. The chef doesn't stand and stare at a pot of boiling water — while that pot heats, the chef chops vegetables for a different dish, checking back on the pot periodically. Nothing happens in true parallel (it's one chef), but nothing sits idle either. async/await is the syntax for marking the points where a "dish" (a coroutine) can be paused so the chef can go work on something else.

Coroutines, Tasks, and create_task

A function defined with async def is a coroutine function — calling it doesn't run the function immediately; it returns a coroutine object that needs to be awaited (or scheduled) to actually execute. await is where a coroutine yields control back to the event loop, saying "I'm waiting on something — feel free to run someone else while I wait."

Awaiting coroutines one after another (await a(); await b()) still runs them sequentially — you get the readability of async syntax without any concurrency benefit. Real concurrency comes from tasks: asyncio.create_task(coro()) schedules a coroutine to start running in the background immediately, without blocking the current line. Kick off several tasks this way, and they run concurrently — each yielding control back to the loop whenever it hits an await, letting the others make progress in the meantime. This is the pattern behind "check five API endpoints at once" instead of "check five API endpoints, one after another, each waiting for the last."

async with and async for

Just as regular context managers (with) handle setup and teardown for synchronous resources, async with handles setup and teardown for resources that need to await something during that process — an async database connection that needs to await its handshake, for instance. async for similarly iterates over an async iterator — a stream of values that arrive over time, like paginated API results being fetched page by page, or messages arriving from a websocket. Both are direct async counterparts of syntax you already know, extended to work with anything requiring an await internally.

The Pitfalls: Where Async Goes Wrong

Async code has a smaller number of ways to fail than sync code, but each one tends to be more confusing when it does.

Blocking calls poison the loop. The entire concurrency model depends on every coroutine yielding control at await points. A single call to a slow, synchronous, blocking function (a time.sleep() instead of asyncio.sleep(), or a synchronous HTTP request library used inside an async function) doesn't yield — it freezes the entire event loop, stalling every other task that was supposed to be running concurrently. This is the single most common way async code silently loses all its performance benefit while still looking correct.

Forgetting await. Calling a coroutine function without await doesn't run it — it just creates a coroutine object that sits there, unexecuted, and Python will (usually) warn about it. It's an easy typo to make and a confusing one to debug the first time you hit it, because nothing crashes — code that was supposed to do something simply does nothing.

When sync is the right choice. Async isn't free — it adds real complexity, and for genuinely CPU-bound work (as opposed to I/O-bound work) or simple scripts with no meaningful concurrency to exploit, synchronous code is not just simpler but often just as fast. Reaching for async by default, everywhere, is its own kind of overuse — the same trap as reaching for inheritance by default.


Part 4: Bringing It All Together

Small Idioms That Add Up

A handful of standard-library tools consistently make test code more readable without much ceremony:

  • Enum replaces magic strings and numbers with named, self-documenting constants — Status.PASSED instead of the bare string "passed", catching typos at definition time instead of at comparison time.
  • pathlib replaces error-prone string concatenation for file paths (folder + "/" + filename) with an object-oriented, cross-platform-safe API (folder / filename) that handles the OS-specific separator differences for you.
  • The match statement (Python 3.10+) brings structural pattern matching to Python — a more expressive alternative to long if/elif chains, particularly useful for branching on the shape of API responses or command objects.

The Capstone: A Typed, Modern Page Object

The payoff of all of the above is a Page Object class that combines every idea covered here: an ABC-enforced interface guaranteeing every page implements load() and is_loaded(); composition holding a Logger and a WaitMixin rather than inheriting from either; full type hints on every method signature so mypy catches misuse before a test ever runs; and a frozen dataclass representing the page's expected state for clean, reliable assertions. None of these techniques is complicated in isolation. What makes a framework genuinely maintainable is applying all of them consistently, as defaults, rather than as occasional flourishes reached for only when something breaks.


Get the Full Ebook

This article summarizes the reasoning behind Modern Python for Automation, Vol. 2 — the full ebook goes deeper into each topic with complete, runnable code examples, "why it matters" framing for every technique, common-mistake call-outs, and further-reading links.

📘 Vol. 2 — Modern Python for Automation (OOP, Async & Type Hints):
https://himanshuai.gumroad.com/l/Vol2-Modern-Python-for-Automation-OOP-Async-TypeHints

📚 Full Series — Playwright/Python AI Pro: The Complete 24-Volume Master Bundle
Use coupon code PRO85 at checkout:
https://himanshuai.gumroad.com/l/Playwright-Python-AIPro-The-Complete-24-Volume-Master-Bundle

Top comments (0)