Saturday, May 28, 2016

Page Objects are not enough

Administrative note: Due to code mixing badly with Hebrew - English only. Sorry.

So, as we all know, if one is writing automation in Selenium, one of the first things to hear about is page objects. Page objects are cool, very useful way to separate "what" from "how", or, if we want to stick with  the object oriented terminology - to encapsulate functionality. There's a lot written on page objects and why you should use them, so I will just put here a link or two (or three, just to point to an official looking link).
In most examples that I've seen, The situation is fairly simple: one action test, maybe two. Something such as "login" or "search". At this level, we get a neat piece of code that looks very nice indeed. You will see a neat piece of code that looks as follows:

 @Test
 public void testLogin(){
  LoginPage loginPage=new LoginPage(driver);
  MainPage mainPage = loginPage.login("Username","Password");
  Assert.assertEquals(mainPage.getLoginFromGreeting(), "Username");
 }
How much better could it get?
But, selenium tests are quite expensive, and are used often also to test end-to-end flows and complete use cases from start to finish, and such actions might require going through multiple pages. For instance, When completing a purchase in Amazon, there are up to 8 steps to be done after adding everything you want to your cart (see this link). The steps are:
  1. Click "check out"
  2. Choose "I am a new customer"
  3. Fill registration form
  4. Fill shipping address
  5. Choose shipping type
  6. Enter credit card details
  7. Choose Billing address
  8. Confirm order
So, how would this look in Code? 
@Test
 @DataProvider(name="UserProvider")
 /**
  * Tests the checkout process of a new customer. Assuming the cart is already filled.
  */
 public void testCheckoutNewCustomer(User user){
  new MainPage(driver).clickCheckOut();
  IsRegisteredPage isRegisteredPage = new IsRegisteredPage(driver);
  Assert.assertTrue(isRegisteredPage.isInPage(),"Is in IsRegisteredPage");
  RegistrationPage registrationPage = isRegisteredPage.chooseNewCustomer("testEmail@mytest.com");
  registrationPage.fillUserDetails(user);
  ShippingAddressPage shippingAddressPage = new ShippingAddressPage(driver);
  Assert.assertTrue(shippingAddressPage.isInPage(),"Is in ShippingAddressPage");
  ShippingOptionPage shippingOptionsPage = shippingAddressPage.fillShippingAddress(user);
  PaymentDetailsPage paymentdetailsPage = shippingOptionsPage.standardShipping();
  BillingAddressPage billingAddressPage = paymentdetailsPage.PayWithCreditCard(user);
  ConfirmationPage confirmationPage=billingAddressPage.enterNewBillingAddress(user);
  confirmationPage.confirm();
  Assert.assertTrue(isUserRegistered(user), "User should be registered after purchase");
  Assert.assertEquals(getPurchaseListForUser(user).size(),1, "User should have exactly one purchase");
 }
OK, not as nice as before, but workable - right?  I'm using page objects to hide the actual implementation, and where possible, the page object itself returns the next page after asserting we are at the expected page (I got lazy, and it's a fair design choice to do so), so the test should be pretty solid, right?
As you can guess I have some problems with this implementation.
  1. The test is not as readable as I would like it to be - I don't think it's very bad, but it's still a bit cumbersome. It would be a bit worse if I added some error handling and reporting to it, but for the sake of the example, this is complicated enough.
  2. What if I would like to write another test that uses this purchase as a step? Sure, I assume that the folks at Amazon can simulate any state using some sort of an API, and this use case is less relevant for them - but what if I work on a legacy system that doesn't allow for such testing hooks? Do I want to write all of that again? to just call my test as  I would any other function?
  3. Even if I don't use this exact sequence ever in my tests, I will still have some tests  for purchases by a registered user, or by a signed in user (no identification), or I will want to check the default currency that is suggested to the user during the purchase - so there will be some tests that will have some amount of similarity in their steps. What if tomorrow the purchase process changes so that the credit card information and the billing address page now appear in the same page? Should I just go over all of the tests where I perform a purchase and fix them?
For this, we came up at work with two solutions, both are trying to leverage DRY principle in order to deal with the above problems.  One is simple to execute, and readers with just about any programming experience already have that in mind and wonder what am I blubbering about, while the other is a bit more complex.
In this post, as it is quite long already, I will mention the simple solution only, as it is an easy way to jump-start your automation efforts, despite having some flaws.

So, after all of that fuss - the solution to this problem is to use the same approach we used in creating page objects and encapsulate the desired behavior. If we would have written this as a manual test script, it would probably look a bit like this:
  1. As a non-registered user, complete a purchase. 
  2. Verify that the user has been registered
  3. Verify that the user purchase history contains only the purchase during which the user registered . 
So, our coded test should look quite similar. All we should do is to introduce another layer between the page objects and the tests. You might call it "service","utility", "helper" or whatever suites you, personally I'm accustomed to using "flows". 
The flows layer is the translation of business flows to page-object language. It usually does not have any Selenium code, which belongs inside the page objects. The flows layer is providing the tests with simple to use actions that represents the various user "actions". Most of the time, my preference is that a flow will not fail a test, but rather throw an exception like any other library. However, since the flow is a very useful place to put in some general checks that should always apply (e.g.: "login name should be visible after every login"), sometimes a Flow will fail a soft assert. 
So, now for some code. First - the test:
 @Test
 @DataProvider(name = "UserProvider")
 /**
  * Tests the checkout process of a new customer. Assuming the cart is already filled.
  */
 public void testCheckoutNewCustomer(User user) {
  PurchaseFlows.CheckoutNonRegistered(driver, user, PAYMENT_METHOD.CARD, SHIPPING_OPTIONS.STANDARD);
  Assert.assertTrue(isUserRegistered(user), "User should be registered after purchase");
  Assert.assertEquals(getPurchaseListForUser(user).size(), 1, "User should have exactly one purchase");
 }

Neat and clean, right? That's because we shoved the mess under the carpet, which, in my case, looks like this:
public class PurchaseFlows {
 public enum PAYMENT_METHOD {
  CARD, PAYPAL, AMAZON_STORECARD, CHECKING_ACCOUNT;
 }

 public enum SHIPPING_OPTIONS {
  STANDARD, TWO_DAY, ONE_DAY;
 }

 public static void CheckoutNonRegistered(WebDriver driver, User user,
   PAYMENT_METHOD paymentMethod, SHIPPING_OPTIONS shippingOption) {
  new MainPage(driver).clickCheckOut();
  IsRegisteredPage isRegisteredPage = new IsRegisteredPage(driver);
  Assert.assertTrue(isRegisteredPage.isInPage(), "Is in IsRegisteredPage");
  RegistrationPage registrationPage = isRegisteredPage
    .chooseNewCustomer("testEmail@mytest.com");
  registrationPage.fillUserDetails(user);
  shippingToConfirmation(driver, user, paymentMethod, shippingOption);
 }

 private static void shippingToConfirmation(WebDriver driver, User user,
   PAYMENT_METHOD paymentMethod, SHIPPING_OPTIONS shippingOption) {
  ShippingAddressPage shippingAddressPage = new ShippingAddressPage(driver);
  Assert.assertTrue(shippingAddressPage.isInPage(), "Is in ShippingAddressPage");
  ShippingOptionPage shippingOptionsPage = shippingAddressPage
    .fillShippingAddress(user);
  PaymentDetailsPage paymentdetailsPage = shippingOptionsPage
    .ChooseShippingOption(shippingOption);
  BillingAddressPage billingAddressPage = paymentdetailsPage.fill(paymentMethod,
    user);
  ConfirmationPage confirmationPage = billingAddressPage
    .enterNewBillingAddress(user);
  confirmationPage.confirm();
 }
}


You can also note that since flows classes are utility classes, they enables me to somewhat customize the behavior of the flow - I can now choose the payment method and the shipping options, which I didn't really care about in the test, but will become handy since the private method will probably be shared between registered user purchase and non-regisetered user purchase - this way, if something goes amiss with that part, I will have just one place to fix instead of two, three or who knows how many flows.
Of course, The flow itself can look quite different from the example I show here - having static methods with a lot of parameters is not really convenient to use, so you might consider using something else,  but as long as this separates the business flows from the tests, we're good to go.

Whatever design you choose for your flows, a good flow is something a user would consider as a single action. "Order a Large pizza with 4 types of toppings" is one action, despite the need to open the pizza website, choose a large pizza, select 4 toppings, fill in home address and credit details (which, as we remember, was up to 8 distinct pages by itself  in Amazon website). "Change your password and home address", on the other hand, will probably be considered as two actions. Flows that do two things will probably be broken sooner or later to two flows, as we don't always test the two actions together.

Just one last thing before we're done here - It is fairly common knowledge that having direct WebDriver calls from the tests is a code smell (just search google for the number of references you can find to a saying by Simon Stewart "If you have WebDriver APIs in your test methods, You're Doing It Wrong" - I couldn't find the source, so I assume he either tweeted this once or said it in a talk he gave). And the same applies in this case - unless you are writing very short selenium tests, with single page actions, you do not want to have any direct reference to the page objects in your test methods. Page objects should be encapsulated themselves in order to allow the tests to speak direct business language.

There are several problems that could arise by using flows, I will try to address them in a future post about a different solution to this problem. 

No comments:

Post a Comment