You probably noticed that the amount of code required to get our first system test working was much greater than. In this section, I introduce some patterns and best practices that will help you write maintainable web tests. These patterns come from my own experience after writing many such tests. Together with Guerra and Gerosa, I proposed some of these patterns at the PLoP conference in 2014.
Provide a way to set the system to the state that the web test requires
To ensure that the Find Owners journey worked properly, we needed some owners in the database. We added them by repeatedly navigating to the Add Owner page, filling in the form, and saving it. This strategy works fine in simple cases. However, imagine a more complicated scenario where your test requires 10 different entities in the database. Visiting 10 different web pages in a specific order is too much work (and also slow, since the test would take a considerable amount of time to visit all the pages).
In such cases, I suggest creating all the required data before running the test. But how do you do that if the web application runs standalone and has its own database? You can provide web services (say, REST web services) that are easily accessible by the test. This way, whenever you need some data in the application, you can get it through simple requests. Imagine that instead of visiting the pages, we call the API. From the test side, we implement classes that abstract away all the complexity of calling a remote web service. The following listing shows how the previous test would look if it consumed a web service.
Listing 9.32 Our test if we had a web service to add owners
@Test
void findOwnersBasedOnTheirLastNames() {
AddOwnerInfo owner1 = new AddOwnerInfo(
➥ "John", "Doe", "some address", "some city", "11111");
AddOwnerInfo owner2 = new AddOwnerInfo(
➥ "Jane", "Doe", "some address", "some city", "11111");
AddOwnerInfo owner3 = new AddOwnerInfo(
➥ "Sally", "Smith", "some address", "some city", "11111");
OwnersAPI api = new OwnersAPI(); ❶
api.add(owner1);
api.add(owner2);
api.add(owner3);
page.visit();
ListOfOwnersPage listPage = page.findOwners("Doe");
List<OwnerInfo> all = listPage.all();
assertThat(all).hasSize(2).
containsExactlyInAnyOrder(owner1.toOwnerInfo(), owner2.toOwnerInfo());
}
❶ Calls the API. We no longer need to visit the Add Owner page. The OwnersAPI class hides the complexity of calling a web service.
Creating simple REST web services is easy today, given the full support of the web frameworks. In Spring MVC (or Ruby, or Django, or Asp.Net Core), you can write one in a couple of lines. The same thing happens from the client side. Calling a REST web service is simple, and you don’t have to write much code.
You may be thinking of security issues. What if you do not want the web services in production? If they are only for testing purposes, your software should hide the API when in production and allow the API only in the testing environment.
Moreover, do not be afraid to write different functionalities for these APIs, if doing so makes the testing process easier. If your web page needs a combination of Product
s, Invoice
s, Basket
s, and Item
s, perhaps you can devise a web service solely to help the test build up complex data.
Make sure each test always runs in a clean environment
Similar to what we did earlier when testing SQL queries, we must make sure our tests always run in a clean version of the web application. Otherwise, the test may fail for reasons other than a bug. This means databases (and any other dependencies) must only contain the bare minimum amount of data for the test to start.
We can reset the web application the same way we provide data to it: via web services. The application could provide an easy backdoor that resets it. It goes without saying that such a web service should never be deployed in production.
Resetting the web application often means resetting the database. You can implement that in many different ways, such as truncating all the tables or dropping and re-creating them.
WARNING Be very careful. The reset backdoor is nice for tests, but if it is deployed into production, chaos may result. If you use this solution, make sure it is only available in the test environment!
Give meaningful names to your HTML elements
Locating elements is a vital part of a web test, and we do that by, for example, searching for their name, class, tag, or XPath. In one of our examples, we first searched for the form the element was in and then found the element by its tag. But user interfaces change frequently during the life of a website. That is why web test suites are often highly unstable. We do not want a change in the presentation of a web page (such as moving a button from the left menu to the right menu) to break the test.
Therefore, I suggest assigning proper (unique) names and IDs to elements that will play a role in the test. Even if the element does not need an ID, giving it one will simplify the test and make sure the test will not break if the presentation of the element changes.
If for some reason an element has a very unstable ID (perhaps it is dynamically generated), we need to create any specific property for the testing. HTML5 allows us to create extra attributes on HTML tags, like the following example.
Listing 9.33 HTML element with a property that makes it easy to find
<input type="text"
id="customer_\${i}"
name="customer"
data-selenium="customer-name" /> ❶
❶ It is easy to find the HTML element that has a data-selenium attribute with customer-name as its value.
If you think this extra property may be a problem in the production environment, remove it during deployment. There are many tools that manipulate HTML pages before deploying them (minification is an example).
NOTE Before applying this pattern to the project, you may want to talk to your team’s frontend lead.
Visit every step of a journey only when that journey is under test
Unlike unit testing, building up scenarios on a system test can be complicated. We saw that some journeys may require the test to navigate through many different pages before getting to the page it wants to test.
Imagine a specific page A that requires the test to visit pages B, C, D, E, and F before it can finally get to A and test it. A test for that page is shown here.
Listing 9.34 A very long test that calls many POs
@Test
void longest() {
BPage b = new BPage(); ❶
b.action1(..);
b.action2(..);
CPage c = new CPage(); ❷
c.action1(..);
DPage d = new DPage(); ❸
d.action1(..);
d.action2(..);
EPage e = new EPage();
e.action1(..);
FPage e = new FPage();
f.action1(..);
// finally!!
APage a = new APage();
a.action1();
assertThat(a.confirmationAppears()).isTrue();
}
Note how long and complex the test is. We discussed a similar problem, and our solution was to provide a web service that enabled us to skip many of the page visits. But if visiting all these pages is part of the journey under test, the test should visit each one. If one or two of these steps are not part of the journey, you can use the web services.
Assertions should use data that comes from the POs
In the Find Owners test, our assertions focused on checking whether all the owners were on the list. In the code, the FindOwnersPage
PO provided an all()
method that returned the owners. The test code was only responsible for the assertion. This is a good practice. Whenever your tests require information from the page for the assertion, the PO provides this information. Your JUnit test should not locate HTML elements by itself. However, the assertions stay in the JUnit test code.
Pass important configurations to the test suite
The example test suite has some hard-coded details, such as the local URL of the application (right now, it is localhost:8080) and the browser to run the tests (currently Safari). However, you may need to change these configurations dynamically. For example, your continuous integration may need to run the web app on a different port, or you may want to run your test suite on Chrome.
There are many different ways to pass configuration to Java tests, but I usually opt for the simplest approach: everything that is a configuration is provided by a method in my PageObject
base class. For example, a String
baseUrl()
method returns the base URL of the application, and a WebDriver
browser()
method returns the concrete instance of WebDriver
. These methods then read from a configuration file or an environment variable, as those are easy to pass via build scripts.
Run your tests in multiple browsers
You should run your tests in multiple browsers to be sure everything works everywhere. But I don’t do this on my machine, because it takes too much time. Instead, my continuous integration (CI) tool has a multiple-stage process that runs the web test suite multiple times, each time passing a different browser. If configuring such a CI is an issue, consider using a service such as SauceLabs which automates this process for you.
Leave a Reply