Page Objects
The Page Objects design pattern is outlined in the Selenium wiki, but to summarize, Page Objects are meant to encapsulate the messy internal state of a page. Changes in the presentation code should only require changes only to the Page Objects, not to the actual test code. Using a Page Object promotes consistency; there may be five different ways to legitimately determine that you are on the login page, but adhering to the one definition in the Page Object will prevent you from having to maintain the other variants.
Page Objects should be ignorant of an application's business logic, they should only be aware of page state and how to interact with it. By clearly delineating the test code from the page objects, you will be able to use the same page objects in a variety of tests cases and achieve code re-use.
The Sample Code
The application that we will be using is the Spring Security Contact Example that I deployed locally in a Tomcat 6 container. No modifications of the web archive are necessary.
The first two classes are Page Objects: the Login Page and the Home Page. Both pages validate that the page is currently loaded by checking the current page's title. A Page Object's validation code may execute before the page has been loaded in the browser, so we will leverage the WebDriver asynchronous validation mechanism. Both Pages will wait up to thirty seconds, polling every two seconds to see if the expected title is present before giving up. WebDriver used to have a somewhat klunky way of doing this, but the new FluentWait and ExpectedConditions makes this code much more concise and readable.
LoginPage.java
public class LoginPage { private final WebDriver driver; public LoginPage(WebDriver driver) { super(); this.driver = driver; Wait wait = new FluentWait(driver) .withTimeout(30, SECONDS) .pollingEvery(2, SECONDS); wait.until(ExpectedConditions.titleIs("Login")); } public HomePage loginAs(String username, String password) { executeLogin(username, password); return new HomePage(driver); } public void failLoginAs(String username, String password) { executeLogin(username, password); } private void executeLogin(String username, String password) { driver.findElement(By.name("j_username")).sendKeys(username); driver.findElement(By.name("j_password")).sendKeys(password); driver.findElement(By.name("submit")).submit(); } public String getErrorMessage() { return driver.findElement(By.xpath("/html/body/p/font[@color='red']")).getText(); } }
HomePage.java
public class HomePage { private final WebDriver driver; private final static Logger logger = LoggerFactory .getLogger(HomePage.class); public HomePage(WebDriver driver) { super(); this.driver = driver; Wait wait = new FluentWait(driver) .withTimeout(30, SECONDS) .pollingEvery(2, SECONDS); wait.until(ExpectedConditions.titleIs("Your Contacts")); } public String getHomePageWelcomeMessage() throws Exception{ return driver.findElement(By.xpath("/html/body/h1")).getText(); } }
Each Page Object also contains an additional method that checks for the presence or content of an element. The Page Object only locates and retrieves these values. We will let the test case interpret the correctness of these values.
I've created a single junit test that will test a successful and unsuccessful login attempt. The test instantiates a Firefox WebDriver to pass to the Page Objects. In a more sophisticated test suite you would inject the WebDriver implementation class to leverage the same test against different browsers. To start each test, we navigate to a protected URI. This should trigger the authentication process and bring the user to the login page. After each test we make an attempt to clean up by accessing the logout URI and cleaning up any session cookies that remain in the browser. Finally, after all of the tests are completed, we gracefully shutdown the WebDriver.
LoginTest.java
public class LoginTest { private final static Logger logger = LoggerFactory.getLogger(LoginTest.class); private final static WebDriver driver = new FirefoxDriver(); private final static String hostAndPortAndContext = "http://localhost:8081/spring-security-samples-contacts-3.0.8.CI-20110909.121734-1"; private final static String securedURI = "/secure/index.htm"; private final static String logoutURI = "/j_spring_security_logout"; @AfterClass public static void afterAllIsSaidAndDone() { driver.quit(); } @After public void after() { driver.get(hostAndPortAndContext+logoutURI); driver.manage().deleteAllCookies(); } @Before public void before() { driver.get(hostAndPortAndContext+securedURI); } @Test public void testLogin() throws Exception { LoginPage loginPage = new LoginPage(driver); HomePage homePage = loginPage.loginAs("rod", "koala"); assertEquals("rod's Contacts",homePage.getHomePageWelcomeMessage()); } @Test public void testFailedLogin() throws Exception { LoginPage loginPage = new LoginPage(driver); loginPage.failLoginAs("nobody", "WRONG"); assertTrue(loginPage.getErrorMessage().contains("Reason: Bad credentials.")); } }
Running the JUnit test will spawn an instance of Firefox to run the two test cases. The browser will use a unique profile, so your tests will not be affected by your current browser cache or configuration.
Shortcomings of Page Objects and Web Driver
If you change the password of the valid user and re-run the test, you will need to wait the entire duration of the timeout period (currently thirty seconds) before moving onto the next test. This is because the Home Page is only checking to see if the Home Page has loaded, not if the login page is being re-displayed with an error. The problem with this is twofold
- it is unclear as to why a test failed. You are only given a stack trace that indicates the expected Home Page element could not be loaded within the timeout period. But what happened? Was the account locked, was the server not up, or did the application blow up? If you are watching the tests run, the underlying cause of failure may be obvious, but if you are running the tests on a CI server, or executing them remotely with Selenium Grid. it may be difficult to diagnose.
- it takes a long time for the test to fail. If the failure is on a main navigation path (e.g. Login) it will take a long time for all of the tests to fail. Automated test suites that used to take 10 minutes to run on the CI server may now take several hours to complete before you are notified that something is wrong.
Another concern is that we are now coupling page state/interaction with page flow. The Login page executes the same login logic twice, once for failed logins, the other for successful logins. Both methods do the same thing, but have different return types as the expected navigation outcome will be different. So despite our best intentions, the validation of the business logic has leaked into the page objects.
We could code the page flow into a page builder class that can detect unexpected navigation flow. This class could detect a failed user login due to a bad password. But this solution quickly increases the complexity of the code. The tidy one liner check now becomes a complicated check for the presence of a login error message in a try catch block. Using a builder for main navigation flows (e.g. login) may have merit, but it becomes overkill to use as the default pattern.
Such a page builder class would not catch all unexpected navigation errors. Unless you've defined custom error pages, the container can also render error pages. These error pages are container specific; Tomcat will generate a different 404 page than Websphere. Browsers can also render error pages. For example, if the browser is unable to connect to a host the browser will render a page indicating the failure. The list of possible unexpected pages is quickly growing.
[gallery columns="2"]
It would be nice if the actual Driver implementation could identify browser generated error pages. It would also be possible to expose the HTTP Response code to identify server side errors. Unfortunately, it does not appear that this functionality will be appearing in Web Driver any time soon.
It might be difficult to detect errors, but when they happen you can at least get the current content of the page. This can be done by logging the contents of the frame with driver.getPageSource() or grabbing a screenshot with getScreenshotAs(). The thought of associating screen shots to a test sound appealing and all of the major drivers (IE, Chrome and Firefox) support the getScreenShotAs method. But it's difficult to a harvest screenshots for inspection after a remote test run. For the most part, taking a current page source dump should be sufficient to indicate current page state.
Conclusions
Page Objects do a good job of encapsulating the underlying page source, but not as well at modeling navigation flow. Web Driver 2.0 and Selenium are continually improving. Hopefully they will continue to do so by adding some of the missing features.
If you are not yet using PageObjects, consider doing so. If you are not yet using Selenium, what are you waiting for?