Testing Websites with Selenium

A Guide to Writing Easy To Read and Maintain Parallel Tests

Selenium facilitates running automated E2E tests as if you were a user accessing the website using the any browser with a supported WebDriver. This enables you to test things not normally available to unit and integration tests such as cross browser support. And using a well thought out strategy, can make it easy to maintain tests even for a complex difficult to test legacy code base. I'll be writing my examples in C# and some of it will be C# specific, but the concepts can apply to multiple languages.

Selenium Testing Architecture

WebDriver Management

The WebDriver is the object that actually sends commands to the browser. You have to have the webdriver that matches your browser (type and version). In C# this can be installed using the NuGet package for FirefoxDriver (controls Firefox) of ChromeDriver (controls Chrome) but other languages may require you to manually download a webdriver. npm hosts a webdriver-manager tool that can be used to keep your driver executables up to date.

I prefer to keep a single global webdriver for the entire test. I've been told that this seems like a code smell, but generally you won't need to control more than one browser at once, and each webdriver instance requires an executable to be launched, so creating new ones isn't cheap. This is meant to make it easier to write and maintain tests.

public static class Browser
{
       private static ThreadLocal<IWebDriver> webDriver = new ThreadLocal<IWebDriver>();
       internal static IWebDriver WebDriver 
       {
              get => webDriver.Value ?? Initialize();
              set => webDriver.Value = value;
       }

       private static IWebDriver Initialize(string TestSiteURL, string PathToTestInputVideo = "", bool Headless = true)
       {
              var driverService = ChromeDriverService.CreateDefaultService();
              var options = new ChromeOptions();
              options.AddArgument("--start-maximized");
              options.AddArgument("--use-fake-device-for-media-stream");
              options.AddArgument("--use-fake-ui-for-media-stream");
              if(!string.IsNullOrWhiteSpace(PathToTestInputVideo)) options.AddArgument($"--use-file-for-fake-video-capture {PathToTestInputVideo}");
            if (Headless)
            {
                options.AddArgument("--headless");
                driverService.HideCommandPromptWindow = true;
            }

            options.AddArgument("--incognito");
            options.AddArgument(TestSiteURL);
            var driver = new EventFiringWebDriver(new ChromeDriver(driverService, options, TimeSpan.FromMinutes(5)));

       }

//... Static Wrappers for methods like FindBy
}

What does ThreadLocal do? If written as a static wrapper for you webdriver, it enables centralizing all of your setup logic such that any test can access it and always gets the Webdriver that is owned by the current thread thanks to ThreadLocal. This means no passing around instances of a webdriver, you current test will always have access to it's own browser. And thanks to the property, when you go to get your webdriver, it will create a new one if the current one is null.

Why didn't we just use new ChromeDriver()? Most of that is to get Chrome to run completely silently in the background. And the media stream stuff allows us to use a video as fake webcam input in case it's needed for a test. Peter Beverloo put together a page I reference quite often when I need to dig into more Chrome Switches. Firefox likely has similar documentation, but the only links I found were invalid and outdated.

Page Object Model

A design commonly used with Selenium is to treat each page as it's own class. These page objects make it clear what elements and code are usable where. And clarity is key in maintainable tests. Name your elements based on how you would describe them (ex. The text on a button or the location of a textbox) not what their ID/xpath is. This keeps your tests maintainable, readable and DRY.

public class LoginPage
{
       public static IWebElement UserName => Driver.FindElement(By.Id("username"));

       public static IWebElement Password => Driver.FindElement(By.Id("pwid234wn"));

}

Note that above I used ReadOnly properties. You could just as well Use GetUsername or GetPassword, but you do not want to store these element references for any length of time. As you browser goes about its business these references will become stale and will not work (depending on how your website is built this may vary).

Component Object Model

For components of a SPA such as a React or Angular website, the Page Object Model doesn't make immediate sense. In comes the Component Object Model. Each reused component of significant size has it's own class. If the component is a button, don't worry about it unless it has very peculiar behavior. Name each component in a way that makes it readily identifiable by behavior/purpose and any visual identifiers that make sense. The Component Object Model is about larger repeatable pieces that show up on various "Screens" of the SPA.

It can look pretty similar as well:

public class LoginDiv
{
       public static IWebElement UserName => Driver.FindElement(By.Id("username"));

       public static IWebElement Password => Driver.FindElement(By.Id("pwid234wn"));

}

This is also handy for websites build using the Page Object Model. If the Navigation Bar or Footer of your website is on every page, using the Component Object Model to Describe these is better than repeating the code on every page.

Action Flows

Writing tests as a series of static calls to Pages and Components is repetitive and provides no guidance to the test writer as to what they can do next. By designing a framework of fluent Action Flows for your site, you can facilitate readable tests that are written in a way that is simple enough that non-developers should be able to read, understand and modify the tests. This means if a method/element is missing from a Page Object, or an unexpected redirect occurs, you know instead of wondering why it cannot find an object or where to look for it's definition.

Checkout the fluent tests below:

public void Test()
{
       User.Login("SomePassword1");
       User.OpenNotifications()
              .Click("Test Notification");
}

public void Test()
{
       User.Login(username: "UserPerson123", password: "SomePassword1");
       User.BrowseCategory("Shoes")
              .FilterColor("Blue")
              .FilterSize(9)
              .FilterPriceMin(4.50)
              .FilterPriceMax(450)
              .ApplyFilters();
       User.AddToCart(itemnumber: "12345");
}

Not knowing what code was used to implement these, it's fairly obvious what the user is doing in both tests. There was a reason our classes from earlier weren't static. In addition to the static ReadOnly properties containing the element definitions, we are going to add some instance methods that use these definitions, but always return an object for the page/component that the user is looking at when they get done.

Our User class acts as a static entry point to the various Action Flows we might be entering:

public static class User
{
      public static HomePage Login(string username, string password)
      {
           NavBar.LogIn.Click();
           LoginPage.UserName.SendKeys(username);
           LoginPage.Password.SendKeys(password);
           return new HomePage();
      }

     public static BrowseCategory(string categoryName)
     {
          NavBar.Categories.Click(categoryName);
          return new ProductSearchPage();
     }

     public static NotificationsPage OpenNotifications()
     {
          NavBar.NotificationsButton.Click();
          return new NotificationsPage();
     }
}

public class ProductSearchPage
{
     public static SelectElement FilterColor => new SelectElement(Driver.FindElement(By.Id("colorFilter")));

     public FilterColor(string color)
     {
          FilterColor.SelectByText(Color);
          return this;
     }
}

If any methods or elements are not likely to be useful outside of action flows, mark them internal. This will allow them to be used in the test library, but not in the actual test project. More importantly it means that it won't clutter their auto-complete.

Utility Methods

Anything that does not specifically interact with the website should go into a separate set of Static Utility Classes. These classes have no state and only host methods that are helpful for doing specific formula for constructing values to compare with website data. This could be calculating tax, getting the order total, parsing a file etc. These utility methods should generally be Pure Functions. Calling webservices or accessing files is fine as long as the method it's obvious and the data accessed will always be the same (or at least reasonably equivalent) for the test it is called in.

Running Tests in Parallel

The key thing in this design is to never store anything at a static level. All the fluent methods will be thread safe because we are using static methods that invoke a ThreadLocal WebDriver. Now the only worries as far as parallel activity goes is the website being tested. If you start changing product prices in one test while trying to assert that they are correct in another, it will conflict eventually and produce false positive rest results.

Gotchas and Pitfalls

Conditional Flows

Methods within a single page that depend on conditionals can be time consuming and messy and remove control front the test. If you have to have them, do not clutter up the page or component models. Ideally you have explicit test cases where the path through the website is known and you expect each test to know where it will go and how it will behave.

For example, you have a store that offers free shipping when the subtotal is over $75. Any tests for totals should not use conditional logic to automatically exclude expected shipping cost. Yes it is convenient, but the first time you change that free shipping requirement or items that are never eligible for free shipping, you will have to go back and change those tests.

Utility methods that have are aware of these conditions can keep the conditions out of your tests and out of the page/components. It keeps your code DRY and makes updating tests easier when specifics of a condition change.

public class ViewCartPage
{
     public static IEnumerable<IWebElement> ProductsElement => 
          Driver.FindElement(By.Id("orderdIds"))
               .FindElements(By.ClassName("cartLineItem"));

     public static IEnumerable<IWebElement> SubtotalElement=> 
          Driver.FindElement(By.Id("subtotal"));

     public static decimal Subtotal=> 
          Decimal.Parse(SubtotalElement.GetText());

     public static GetProductPrice()
     {
          // Some logic to extract the price from each product's element
     }
}

public static class ShippingCost
{
     public static decimal CalculateTotalShipping()
     {
          // Some logic to check the products against preset data to see
          // which are eligible for shipping and what the total should be
          // and to check against the subtotal.
     }
}

External Dependencies

If your tests depend on an external database, webservice, etc you may be tempted to mock it. If this external resource is not under your control or is not part of your test environment, you should probably do it. Otherwise, consider what is not being tested by skipping it.

If your site generally uses an ORM to target SQL Server, but you add a class with Oracle specific SQL and use a mock, you may miss the fact that a production database can't use that query. Or if you are calling a webservice that has recent changes, you may miss a breaking change due to mocks. When possible, setup your own backend systems that can have controlled state that are up to date with what will be used in production scenarios. When not reasonable (such as when targeting multiple Databases through ORM), mock it. The more time and complexity cost that get's added, the more coverage you could have added to your tests.

Test State

You will be likely want to store state about a test somewhere at some point. Preferrably this should be stored in a local variable inside the test method itself. Storing it on the Test object could cause it to affect other tests (depending on how your test runner works) and storing it in the framework as a static variable can massively increase the complexity (since you would need to use ThreadStatic or ThreadLocal in C#).

Did you find this article valuable?

Support Curtis Carter by becoming a sponsor. Any amount is appreciated!