Individual page objects handle single screens. Real user journeys cross multiple screens in sequence — login, browse, select, checkout, confirm. This lesson covers how to compose page objects into flows that read like user stories and fail with context.
The chaining pattern in practice
Each action method returns the page it navigates to. Tests compose those returns into a chain:
@Test
public void completePurchaseFlow() {
String orderId = new LoginPage(getDriver())
.login("user@example.com", "pass123") // → HomePage
.searchProduct("Wireless Headphones") // → SearchResultsPage
.selectFirstResult() // → ProductDetailPage
.addToCart() // → CartPage
.proceedToCheckout() // → CheckoutPage
.fillShippingAddress("123 Main St", "NY", "10001")
.placeOrder() // → OrderConfirmationPage
.getOrderId();
assertThat(orderId).startsWith("ORD-");
}The chain is executable documentation. A new engineer reading this test understands the flow without reading implementation details.
Intermediate assertions without breaking the chain
Sometimes you need to assert something mid-flow but continue navigating. Two approaches:
Capture the page object, assert, then continue:
CartPage cart = new LoginPage(getDriver())
.login("user@example.com", "pass123")
.searchProduct("Laptop Stand")
.selectFirstResult()
.addToCart();
assertThat(cart.getItemCount()).isEqualTo(1);
assertThat(cart.getSubtotal()).isEqualTo("$45.00");
OrderConfirmationPage confirmation = cart
.proceedToCheckout()
.placeOrder();
assertThat(confirmation.getOrderId()).isNotEmpty();Return this for assertions without navigation:
public CartPage verifyItemCount(int expected) {
assertThat(getItemCount()).isEqualTo(expected);
return this; // allows chaining to continue
}
// In test:
new LoginPage(getDriver())
.login("user@example.com", "pass123")
.addFirstProductToCart()
.verifyItemCount(1) // returns CartPage
.proceedToCheckout() // navigation continues
.placeOrder();Mixing assertions into page objects is a design trade-off. Keep it to verification helpers that read clearly in the chain (verifyItemCount, verifyErrorShown) rather than raw assertions.
Dealing with optional screens
Some flows have optional screens — a "first run" overlay, a location permission dialog, a push notification prompt. Page objects shouldn't assume these will always appear:
public class HomePage extends BasePage {
private static final By WELCOME_DISMISS = AppiumBy.accessibilityId("dismissWelcome");
public HomePage(AppiumDriver driver) {
super(driver);
dismissWelcomeOverlayIfPresent();
}
private void dismissWelcomeOverlayIfPresent() {
try {
WebElement dismiss = new WebDriverWait(driver, Duration.ofSeconds(3))
.until(ExpectedConditions.elementToBeClickable(WELCOME_DISMISS));
dismiss.click();
} catch (TimeoutException e) {
// overlay not present — continue
}
}
}Handling optional screens in the page object constructor keeps tests clean. The test doesn't need to know whether the overlay will show.
Shared setup with @BeforeMethod
For test classes where every test starts at the same screen, use @BeforeMethod to navigate there once:
public class CartTests extends BaseTest {
private CartPage cartPage;
@BeforeMethod
public void navigateToCart() {
cartPage = new LoginPage(getDriver())
.login("user@example.com", "pass123")
.addFirstProductToCart();
}
@Test
public void verifyCartItemCount() {
assertThat(cartPage.getItemCount()).isEqualTo(1);
}
@Test
public void verifyCheckoutEnabled() {
assertThat(cartPage.isCheckoutEnabled()).isTrue();
}
}Each test gets a fresh cartPage object because @BeforeMethod runs before each test method. The driver is shared (managed by DriverManager); only the navigation is repeated.
Parallel flow safety
When tests run in parallel across threads (via testng.xml or thread-count), each thread must have its own driver and page objects. The ThreadLocal<AppiumDriver> in DriverManager handles driver isolation; page objects are constructed from getDriver(), which returns the current thread's driver:
@BeforeMethod
public void setUp() {
// getDriver() is thread-local — different driver per thread
loginPage = new LoginPage(getDriver());
}Never share page object instances across threads. Since page objects hold a driver reference, sharing them means sharing a driver — which causes session conflicts.
Error messages that point to the failure
When a flow fails, the assertion message should name the screen and action, not just the value:
assertThat(confirmation.getOrderId())
.as("Order ID on confirmation screen after checkout flow")
.isNotEmpty();The as() string becomes part of the failure message. When CI shows a red test, the message tells you exactly where in the flow the assertion lives without needing to read the stack trace.