This episode covers implementing the Page Object Model to organize tests, encapsulating page interactions and locator abstraction, organizing the tests, pages, and utils folders, and building reusable helpers for tests that are easy to read and maintain.

Episode 5 made your tests timing-stable. Episode 6 tackles the second, equally crucial problem: tidiness. Tests where all logic is glued into one function are hard to read, hard to change, and break quickly when the application changes. The solution proven over more than a decade is the Page Object Model (POM).
POM is a pattern where each page is represented by one class. That class stores the locators and the interaction methods characteristic of that page, while tests only call methods with readable names. The result: when the HTML structure changes, you only fix one place, not hundreds of lines of tests.
This episode takes you through building a project structure that becomes the backbone of all subsequent episodes: separate pages, tests, and utils folders, plus a POM implementation example you can use right away.
Without POM, tests like this are a common sight:
driver.find_element("id", "email").send_keys("user@example.com")
driver.find_element("id", "password").send_keys("rahasia123")
driver.find_element("id", "submit").click()Every login test repeats the same three locator lines. If the team changes the submit id to btn-masuk, all tests must be changed. POM collects these locators in one place.
POM's principle: one class represents one page or one major component. The class contains two things — locator attributes and interaction methods. Tests should never see find_element directly; they just call methods.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
EMAIL = (By.ID, "email")
PASSWORD = (By.ID, "password")
SUBMIT = (By.ID, "submit")
ERROR = (By.CLASS_NAME, "alert-error")
def isi_email(self, teks):
self.wait.until(EC.visibility_of_element_located(self.EMAIL)).send_keys(teks)
def isi_password(self, teks):
self.driver.find_element(*self.PASSWORD).send_keys(teks)
def klik_masuk(self):
self.wait.until(EC.element_to_be_clickable(self.SUBMIT)).click()
def pesan_error(self):
return self.driver.find_element(*self.ERROR).textNotice self.EMAIL = (By.ID, "email") — locators are written as tuples and reused with *self.EMAIL. This keeps the page's entire locator set centralized at the top of the class, easy to change when the HTML structure changes.
from selenium import webdriver
from pages.login_page import LoginPage
def test_login_gagal():
driver = webdriver.Chrome()
try:
driver.get("https://app.example.com/login")
halaman = LoginPage(driver)
halaman.isi_email("salah@example.com")
halaman.isi_password("salah")
halaman.klik_masuk()
assert "Email atau password salah" in halaman.pesan_error()
finally:
driver.quit()The test_login_gagal(driver) test above no longer contains locators. The test flow becomes a narrative: fill in email, fill in password, click login, check the error. This is POM's main goal — tests read like a business document.
The structure we've used since episode 2 is now filled in with POM:
belajar-selenium/
├── conftest.py -> shared driver fixture
├── pages/ -> one file per page
│ ├── __init__.py
│ ├── login_page.py
│ └── dashboard_page.py
├── tests/ -> pytest test files
│ ├── test_login.py
│ └── test_dashboard.py
├── utils/ -> helpers and wrappers
├── data/ -> test data (episode 9)
└── requirements.txt -> dependenciesThe short rule: files in pages/*_page.py only contain locators and interactions, tests/ only contains scenarios, and utils/ contains code reused across pages.
Wait helpers are stored in utils for consistency:
from selenium.webdriver.support.ui import WebDriverWait
def tunggu_klik(driver, locator, timeout=10):
return WebDriverWait(driver, timeout).until(
lambda d: d.find_element(*locator)
)The tunggu_klik(driver, locator, timeout=10) function is used by every page object. This way, a change in wait policy is done once in one file.
Page object methods should return the next page object, so tests can be chained:
from pages.dashboard_page import DashboardPage
class LoginPage:
def login_berhasil(self, email, password):
self.isi_email(email)
self.isi_password(password)
self.klik_masuk()
return DashboardPage(self.driver)With login_berhasil(...) returning DashboardPage, tests write dashboard = halaman.login_berhasil(...) and immediately continue interacting on the new page. This pattern keeps the test flow linear and easy to read.
Tip
Don't let page objects grow too large. If a page has many sections, split it into separate components — for example a NavbarComponent — then use it from the main page object.
Episode 6 changes how you write tests: from linear scripts piling up locators to a tidy page architecture. You understand why POM is needed, how to create a page object class, how to organize the pages, tests, and utils folders, and how to design chainable methods.
Key takeaways:
find_element directly.In episode 7 next, we'll cover assertions and test validation — using assertion libraries, validating page state, text, element visibility, and behavior, composing dynamic assertions for responsive UI, and adding error reporting and automatic screenshots when tests fail.