Motivation
The Page Object Model (POM) has been the de facto standard in UI automation using Selenium for years now. Here* is a typical primer on the subject. This primer does a perfectly reasonable job of showing how people normally use POM, and it also clearly shows the reason that using POM the way I've seen a lot of people use it is insufficient for writing maintainable tests.The problem is illuminated in the author's 5th reason for using POM: "Any change in UI can easily be implemented, updated and maintained into the Page Objects and Classes." If you've done this for a while, you'll notice what is not said here - that a change in business process or higher-level user interaction with the site cannot be easily implemented in our automation. For that type of change, if we were to only use POM, we'd have to update all of the related tests. That is to say, this style of POM is sufficient for encapsulation and maintainability of UI elements and structure, but we need something higher-level to encapsulate and maintain business processes.
Typical POM
As a trivial example, let's say you have this PO class and test:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class LoginPage | |
element username = driver.webElement(:id, "userName") | |
element password = driver.webElement(:id, "password") | |
element loginButton = driver.webElement(:id, "submit") | |
method loginTest | |
driver.goto("http://www.myapp.com") | |
loginPage.username.set("josh") | |
loginPage.password.set("p@ssw0rd") | |
loginPage.loginButton.click() | |
assertTrue(homePage.menu.exists(), "Error: we're not on the home page") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
void loginTest | |
driver.goto("http://www.myapp.com") | |
loginPage.username.set("josh") | |
loginPage.password.set("p@ssw0rd") | |
loginPage.acceptTermsAndConditionsCheckbox.check() // new line | |
loginPage.loginButton.click() | |
assertTrue(homePage.menu.exists(), "Error: we're not on the home page") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class LoginPage | |
element username = driver.webElement(:id, "userName") | |
element password = driver.webElement(:id, "password") | |
element loginButton = driver.webElement(:id, "submit") | |
element acceptTermsAndConditionsCheckbox = driver.webElement(:id, "acceptTOC") // new line | |
void login(uname, pwd) | |
driver.goto("http://www.myapp.com") | |
username.set(uname) | |
password.set(pwd) | |
acceptTermsAndConditionsCheckbox.check() // new line | |
loginButton.click() | |
void loginTest | |
loginPage.login("josh", "p@ssw0rd") | |
assertTrue(homePage.menu.exists(), "Error: we're not on the home page") | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class optionsPage | |
// page element definitions... | |
void selectOptions(color, leatherTrim) | |
case color: | |
"red": redColorRadio.click() | |
"blue": blueColorRadio.click() | |
"green": greenColorRadio.click() | |
if (leatherTrim) | |
leatherTrimOptionCheckbox.check() | |
void clickSubmit() | |
submitButton.click() | |
class paymentPage | |
// page element definitions... | |
void enterPaymentDetails(ccNummber, ccExpiration, ...) | |
//... | |
void clickSubmit() | |
submitButton.click() | |
void validateSale() | |
assertTrue(confirmationMessage.text == "Thanks for your order", "Error: sale not confirmed") | |
void checkoutTest | |
loginPage.login("josh", "p@ssw0rd") | |
// select a couch to buy... | |
optionsPage.goto() | |
optionsPage.selectOptions("red", false) | |
optionsPage.clickSubmit() | |
paymentPage.enterPaymentDetails("4312432143214321", "09/2022", ...) | |
paymentPage.clickSubmit() | |
paymentPage.validateSale() | |
The Issue...
The problem is that we're still modeling the interaction with the UI - we're focused on the website. What we should be modeling is what the user wants to do. The user doesn't want to select from a dropdown on pageA and click some buttons on pageB, they're trying to buy a couch, and we're not encapsulating that anywhere, except arguably in the test. That's too late, because we're going to have dozens of really similar tests where the user just wants to do a tiny thing different, such as choosing express shipping vs. regular shipping, so all that test code is going to be duplicated, and we're not going to be DRY at all.In this example our app changed, and now we have an "assembly and delivery" step. All our tests have to change again:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
void checkoutTest | |
loginPage.login("josh", "p@ssw0rd") | |
optionsPage.goto() | |
optionsPage.selectOptions("red", false) | |
optionsPage.clickSubmit() | |
assemblyPage.selectAssemblyAndDelivery(true, "express") // new line | |
assemblyPage.clickSubmit() // new line | |
paymentPage.enterPaymentDetails(4312432143214321, "09/2022", ...) | |
paymentPage.clickSubmit() | |
paymentPage.validateSale() | |
Flow classes
The Flow class is intended to do this encapsulation and abstraction for us. It models what the user is trying to accomplish (user stories?) when they're using your application. Let's check it out:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class LoginFlow | |
LoginPage loginPage | |
login(uname, pwd) | |
loginPage.login(uname, pwd) | |
class BuyFlow | |
OptionsPage optionsPage | |
AssemblyPage assemblyPage | |
PaymentPage paymentPage | |
buyItem(color, woodTrim, assembly, deliveryType, ccNum, ccExpiration, ...) | |
optionsPage.goto() | |
optionsPage.selectOptions(color, woodTrim) | |
optionsPage.clickSubmit() | |
assemblyPage.selectAssemblyAndDelivery(assembly, deliveryType) | |
assemblyPage.clickSubmit() | |
paymentPage.enterPaymentDetails(ccNum, ccExpiration, ...) | |
paymentPage.clickSubmit() | |
validateSale() | |
paymentPage.validateSale() | |
And now your tests can model the use cases, and they simply looks like:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
void checkoutTest | |
loginFlow.login("josh", "p@ssw0rd") | |
buyFlow.buyItem("red", true, false, "express", 4132443212343241, "09/2022", ...) | |
buyFlow.validateSale() | |
Looking at the test case, you probably see the potential problems:
- The more work we make our methods do, the more data we have to pass in to let them know how to do it. For this, we'll have to start consolidating data elements into their logical groups. I'll talk about that in the next post here.
- Using flows works great for positive tests, but what about negative tests? I'll talk about that in this post.