DEV Community

Cover image for How Python Descriptors Simplified Our PageObjects (And Still Do)
Siarhei Shukalovich
Siarhei Shukalovich

Posted on

How Python Descriptors Simplified Our PageObjects (And Still Do)

Not long ago I ran a small poll: who's still using Selenium in 2025?

Surprisingly most respondents said they are.

These days, I mostly work on backend tests, with just a bit of UI covered using Playwright.

But that poll brought back a technique we used with Selenium around seven years ago.

It's about using descriptors in PageObjects - a rare but elegant approach that still holds up today.

I'm not presenting this as the only right way, but rather as a small practice that once helped — and might be worth revisiting.

A quick refresher: what are descriptors in Python?

In simple terms, a descriptor is any object that implements one of the methods: __get__, __set__, or __delete__.

They’re the mechanism behind things like @property, but you can use them directly to control attribute access in custom ways.

Here’s a minimal example of how it works:

class LoggedAttribute:
    def __get__(self, instance, owner):
        print(f"Accessing attribute via __get__: instance={instance}, owner={owner}")
        return instance._value

    def __set__(self, instance, value):
        print(f"Setting attribute via __set__: instance={instance}, value={value}")
        instance._value = value

class MyClass:
    value = LoggedAttribute()

    def __init__(self, value):
        self.value = value  # Triggers __set__

obj = MyClass(10)
print(obj.value)  # Triggers __get__
Enter fullscreen mode Exit fullscreen mode

In this example, value is not a regular attribute — it’s a descriptor.

Whenever you read or write obj.value, Python routes the access through the descriptor’s __get__ and __set__ methods.

Inside __get__, you get access to:

  • instance — the actual object (obj), so you can fetch instance-specific data,
  • owner — the class (MyClass), in case you need class-level logic.

This is exactly what makes descriptors powerful:

they give you full control over attribute access — which, as I later learned, maps beautifully to things like lazy-loaded UI elements.

Back then I was a pretty green junior QAA, and this technique was already implemented in the project I joined —

but it immediately caught my attention. It felt like magic: clean, reusable, and somehow more Pythonic than anything I'd seen before.

Years later, I still think it’s an underrated trick, especially for building PageObjects that don’t rely on hard-coded find_element calls.

That small mechanism — __get__, and optionally __set__ — gives you precise control over how attributes behave.

And while most people encounter descriptors through @property, they can be used much more creatively.

One such case?

Injecting dynamic behavior into PageObjects — like locating elements only when they’re accessed.
Instead of writing repetitive self.driver.find_element(...) calls in every page method, we wrapped those lookups into descriptors.

It looked something like this:

class BasePage:
    driver: WebDriver
    accept_all_cookies_btn: Element = Element(By.CLASS_NAME, "fc-cta-consent")

    def __init__(self, driver: WebDriver):
        self.driver = driver

    def accept_all_cookies(self) -> Self:
        self.accept_all_cookies_btn.click()
        return self

    def is_page_opened(self) -> bool:
        pass
Enter fullscreen mode Exit fullscreen mode
class ATEHomePage(BasePage):
    logo: Element = Element(By.CSS_SELECTOR, "img[src='/static/images/home/logo.png']")

    def __init__(self, driver: WebDriver):
        super().__init__(driver)

    def is_page_opened(self) -> bool:
        return self.logo.is_displayed()
Enter fullscreen mode Exit fullscreen mode
class Element:
    def __init__(self, by: By, value: str, wait: bool=False, timeout: int=10):
        self.by = by
        self.value = value
        self.wait = wait
        self.timeout = timeout

    def __get__(self, instance, owner) -> Self | WebElement:
        if instance is None:
            return self
        return self._find(instance)

    def _find(self, instance) -> WebElement:
        driver: WebDriver = instance.driver
        if self.wait:
            wait = WebDriverWait(driver, self.timeout)
            return wait.until(EC.presence_of_element_located((str(self.by), self.value)))
        return driver.find_element(self.by, self.value)

    def exists(self, instance) -> bool:
        try:
            instance.driver.find_element(self.by, self.value)
            return True
        except NoSuchElementException:
            return False

    def is_displayed(self, instance) -> bool:
        try:
            return self._find(instance).is_displayed()
        except (NoSuchElementException, StaleElementReferenceException):
            return False

    def text(self, instance) -> str | None:
        try:
            return self._find(instance).text
        except (NoSuchElementException, StaleElementReferenceException):
            return None
Enter fullscreen mode Exit fullscreen mode

Let’s see how this works in practice with a simple scenario:

start_page: ATEHomePage = ATEHomePage(driver)
start_page.accept_all_cookies()
assert start_page.is_page_opened(), "Start page is not opened."
Enter fullscreen mode Exit fullscreen mode

What happens behind the scenes when, for example, start_page.is_page_opened() calls self.logo.is_displayed()?

  1. Accessing start_page.logo triggers the descriptor’s get method.
  2. Inside get, the descriptor receives instance — the start_page object — which has a driver attribute.
  3. Using instance.driver, the descriptor locates the element on the page (driver.find_element(...)).
  4. The located WebElement is returned.
  5. Finally, .is_displayed() is called on that WebElement.

Here’s a simplified visual of this flow:

Image description

Why use descriptors in PageObjects? The benefits

  • Lazy evaluation: Elements are located only when accessed, not upfront. This speeds up page initialization and reduces errors related to missing elements.

  • DRY (Don’t Repeat Yourself): The repetitive element lookup code is centralized in one place — the descriptor. You don’t have to write self.driver.find_element(...) all over your PageObject methods.

  • Clear and concise PageObjects: Page attributes look like regular properties, not methods with lookup logic — making your code more readable and declarative.

  • Single point of control: Need to change the lookup strategy, add waiting logic, or handle exceptions? Do it once inside the descriptor — and all pages inherit the updated behavior automatically.

  • Encapsulation of UI logic: You can extend functionality — like adding is_displayed(), exists(), text() methods — directly in the descriptor, rather than spreading them across PageObjects.


This approach effectively makes your PageObject attributes “smart” — they behave like regular fields but carry behind-the-scenes logic to interact with the UI in a robust and maintainable way.

The complete descriptor-based PageObject example is available on my GitHub:

https://github.com/shukal94/getdescriptor

The code is ready to run: you just need to have Python installed and set up the dependencies.

Feel free to clone the repo and try it out yourself!

I’d love to hear your feedback - let me know if it works well for you or if you have any questions.

If you found this helpful, be sure to check out the featured posts on my profile. Originally shared on LinkedIn, here's a more detailed write-up with code and diagrams. Also if you’re working on improving your framework design or want to see how I teach these patterns in depth — feel free to check out my course collection on Udemy

Stay tuned and follow along!

Top comments (0)