When I am referring to automated tests architecture, there are two things I am thinking of: the architecture of the automation framework (or code project) you are writing tests in, and the architecture of the tests themselves.
The first item might refer to: how you are managing switching test environments in test runs; how you are handling internationalization in tests or where you are storing certain resource files. The second item might refer to: where do you keep certain bits of code or how do you structure your tests.
In this post, I will discuss the architecture of the tests, and I will show you my technique for writing them, while keeping in mind reusability and separation of concerns 💡
Creating an Architecture for your Automated Tests
Tests analysis
Often when you are writing automated tests you just jump right into it. You have some requirements based on which you extract the scenarios you want to automate. And once you have those, you just write code code code 👩💻 But you might be missing an important step here- analysis. Let me give you my tips on how to perform this analysis, in order to identify reusable components for your tests.
First of all, usually when I get a large feature to test, or when I have a large number of related scenarios that need to be automated, I need to spend some time reviewing them. I need to understand whether there are common parts in all these scenarios. I want to identify what can be reused across the tests, or put it a different way, what pieces of code can be written only once but used in many tests.
For that, I always love working with a whiteboard. Writing the scenarios on the whiteboard allows me to see which parts are common to multiple tests. But in some cases writing the entire test on the whiteboard might fill it up quickly.
Therefore, I like to think about the test as a succession of blocks. Each block represents an action that might be either very simple (translated into one line of code) or more complex (many lines of code). Once I identify all the blocks in the tests, I would write them on the whiteboard. Let’s see how:
For example, let’s say that we have the following scenarios for UI testing of the shopping cart of an eCommerce website:
1. Log in to the website; search for product A from the top-level search bar, and once it is displayed in the results list, click on it (this action will navigate to its’ dedicated page); from this page add one product to the shopping cart; navigate to the shopping cart and check that only one product A is in the cart.
2. Log in to the website; search for product B (as in scenario 1); from its’ page add 5 products to the shopping cart; from the same page add one more product; navigate to the shopping cart and check that 6 products B are in the cart.
3. Open the website without logging in; search for product C (as in scenario 1); from its’ page add 3 products to the shopping cart; from the same page remove all products C; navigate to the shopping cart and check that 0 products C are present.
4. Log in to the website; search for product A (as in scenario 1); from its’ page add one product to the shopping cart; search for product B (as in scenario 1); from its’ page add 5 products to the shopping cart; navigate to the shopping cart and check that the contents are the ones you added in the previous steps.
Break down the scenarios
Considering these scenarios, let’s individually look at each of them and break them down into blocks:
1. The first block of this scenario is rather obvious. It is the login one. In this case, I did not detail what all the steps of the login process are, but you can imagine it.
The second block would be related to the search for the product. Typing in the top-level search bar, waiting for the result, clicking on it, checking that this action causes a navigation to the dedicated page of the product you searched for 🔍
The third block represents adding the desired quantity of product A to the cart. Typing in the quantity input field, clicking the ‘Add’ button, waiting for a ‘Successfully added’ message.
The fourth block would represent the following actions: navigating to the shopping cart (by clicking on the top-level shopping cart icon), waiting for the page to load properly, and then checking that the shopping cart only contains product A, in the quantity 1.
I would visually represent this on the whiteboard as a succession of blocks (actual rectangles):
2. The first block is the same login block as for scenario 1.
The second block is the same as for scenario 1. Of course, in this case, you are searching for a different product, but conceptually, the action is the same.
The third block is also the same as the third block of scenario 1. You are adding a quantity of the product whose page you are on, even though the quantity is not the same as for scenario 1.
The fourth block of this scenario is the same as the third block of scenario 1. This time, the quantity just differs from the previous block, but it’s the same product, so there is no need to navigate to a different page.
The fifth block is the same as the last block in scenario 1. Checking that the quantity of the product is the expected one, on the shopping cart page.
Visually, scenario 2 would be represented as the following succession of blocks:
3. For scenario 3, the first block is different from the previous scenarios: there is no login. Instead, only navigation to the site’s homepage.
Blocks two (for searching for a product) and three (for adding a given quantity of the product to the cart) are the same as in scenario 1.
Block four is brand new. It assumes you are removing the product from the cart by clicking the ‘garbage can’ icon on the product’s page and waiting for a confirmation message.
The last block in this scenario is the same ‘search for quantity in the shopping cart’ block as in scenario 1. Even if the quantity here is 0. The implementation of the block will take care of checking for quantities. How this is done is not of interest at this point when we are identifying the blocks.
This is what scenario 3 looks like as blocks:
4. For scenario 4, since it is very similar to the previous ones, here is the diagram of blocks:
Now let’s look at all these scenarios together:
What we can observe is that we have blocks that are used across several tests. For instance, the block ‘Search for product’ is used in all the tests. Same for the ‘Check quantity in cart’ 🛒 Some of these blocks are only used once in our test cases, like the ‘Delete product’ one.
Blocks to code
Where shall we place the code?
Now that it is clear that we have some blocks that are reusable, we can go ahead and translate this picture into code. From my perspective, since I am working with Java, for the reusable blocks I will create dedicated methods. I will call these methods from the tests with certain parameters.
When it comes to the blocks which are used in only one test, I need to analyze whether they will be ever used in the future again. Most likely yes.
Of course, in some cases, these methods might already be available in your code, but for the purpose of exemplifying the technique, let’s say they don’t exist yet. This means it is a good idea to translate each block into a method. This way it will also look more consistent in the test, as you will see when I show what the test would look like. And, everything will be reusable 🔁
Good, so we established that we want methods. But where will we store them? Let’s look at the ‘login’ one. How many tests in our project will require to perform this task? Will it only be tests that are related to the shopping cart? Of course not.
Therefore it is a good idea to place the ‘login’ method in a place available to all the tests in the framework. In some cases, you have a dedicated Page Object class for commonly used items. Or you might have a base class that all your tests extend. Any of these two might be a good location for storing the ‘login’ method. The same is valid for the block where you ‘open the homepage’.
The method for ‘checking quantity in cart’ sounds like it has to do specifically with the shopping cart. Therefore, it should be placed in a class that deals exclusively (or almost exclusively) with the shopping cart. Perhaps the Page Object class where you define all the shopping cart-related WebElements.
All the remaining methods have something to do with the product. Therefore, you might have a dedicated Page Object class for working with product WebElements. This will work well as the home of the product-related methods.
Method signatures
If we look at the login method, which we might call simply ‘login’, the method signature might be very simple: two String parameters that represent the username and password. In some cases, you might represent the user who logs in and makes purchases into a Java object.
The properties of the Java object will reflect user information, like first name, last name, username, password, etc. In this case, you could simply pass the Java object as a parameter to the login method, and extract the username and password inside the ‘login’ method, by using the corresponding getter methods. In this case, the signature of the ‘login’ method will simply have an object as a parameter.
The ‘search for product’ method should be straightforward. We should simply provide the name of the product we are searching for, as a String:
public void searchProduct(String productName)
The ‘add to cart’ method should assume that we are on the product page, and it should only perform the action of setting the desired quantity and saving 💾 This would be its signature, taking only the quantity as an int parameter:
public void addProduct(int quantity)
Now if we look at the blocks again, we see that in some cases we are searching for a product and we are only adding a certain quantity at one time. But in other cases, we might add several quantities of the same product, without performing extra searches for the product (except for the initial one).
This means we could create a ‘search and add’ block/method. The parameters for this method would be, of course, the name of the product, but then the quantities. Since we don’t know exactly how many times we will add new quantities of this product, we should use varargs.
These are used to specify that you want one or more values passed to the method as parameters. The signature for the newly created method would look like this:
public void searchAndAddProduct(String productName, int... quantities)
Inside this method, we would call the ‘search’ method once, to help us navigate to the products’ page. Then, for each quantity specified, we will call the ‘add’ method.
Now going to the ‘delete’ method: here, let’s just delete the entire quantity of the product. This way, we only need one parameter for this method: a String representing the product name as below:
public void deleteProduct(String productName)
For the ‘openHomePage’ method, it is very simple. No parameter is needed since the details of the homepage will be processed directly inside the method.
And now, possibly the most important method. the one for checking the quantity in the cart. Because we are able to add several quantities of the same product, but also several distinct products, it would be best if we had a single method that checks the contents of the entire cart.
This method would take as a parameter a Map with a key String and an Integer value. The key corresponds to the product name. The value corresponds to the total quantity we expect to have in the cart, for the given product having the name identical to the key. This method signature would look like this:
public void checkProductIsInCart(Map<String, Integer> productAndQuantity)
The tests
Because we have created these methods, it will be easy to create our tests. They will have way less code than if we had chosen a different approach. They are also easier to read, understand, and maintain, which is a key element of a good automated tests architecture ✅ If there is a change in the website regarding how a product is added to the cart, for example, that change only needs to be made in the corresponding method.
Let’s assume all your tests are extending a base class. That is where you will initialize all the classes you need in the tests (the classes where you created the above methods). Now let’s look at the scenarios:
1. Log in to the website; search for product A from the top-level search bar, and once it is displayed in the results list, click on it (this action will navigate to its’ dedicated page); from this page add one product to the shopping cart; navigate to the shopping cart and check that only one product A is in the cart.
The corresponding automated test for this scenario is:
@Test void scenario1() { Map<String,Integer> expectedProducts = new HashMap<>(); expectedProducts.put("A", 1); commonsPage.login(user); productPage.searchAndAddProduct("A", 1); shoppingCartPage.checkProductIsInCart(expectedProducts); }
2. Log in to the website; search for product B (as in scenario 1); from its’ page add 5 products to the shopping cart; from the same page add one more product; navigate to the shopping cart and check that 6 products B are in the cart.
This test is similar to the one above, however in this case, for the same product, we first need to add a quantity of 5, then a quantity of 1. This means we will simply call the searchAndAddProduct method, where we will first pass 5 as the value of the quantity, then 1. The Map of expected quantities for the shopping cart contains only one type of product with the quantity 6:
@Test void scenario2() { Map<String,Integer> expectedProducts = new HashMap<>(); expectedProducts.put("B", 6); commonsPage.login(user); productPage.searchAndAddProduct("B", 5, 1); shoppingCartPage.checkProductIsInCart(expectedProducts); }
3. Open the website without logging in; search for product C (as in scenario 1); from its’ page add 3 products to the shopping cart; from the same page remove all products C; navigate to the shopping cart and check that 0 products C are present.
The corresponding test for this scenario is:
@Test void scenario3() { Map<String,Integer> expectedProducts = new HashMap<>(); expectedProducts.put("C", 0); commonsPage.openHomePage(); productPage.searchAndAddProduct("C", 3); productPage.deleteProduct("C"); shoppingCartPage.checkProductIsInCart(expectedProducts); }
4. Log in to the website; search for product A (as in scenario 1); from its’ page add one product to the shopping cart; search for product B (as in scenario 1); from its’ page add 5 products to the shopping cart; navigate to the shopping cart and check that the contents are the ones you added in the previous steps.
For this scenario, first of all, the Map will have 2 entries: one for each product we want to add. Secondly, in this scenario we will call the ‘searchAndAddProduct’ method twice: once for each product, with the corresponding required quantity. And there will be only one call to the checkProductIsInCart method:
@Test void scenario4() { Map<String,Integer> expectedProducts = new HashMap<>(); expectedProducts.put("A", 1); expectedProducts.put("B", 5); commonsPage.login(user); productPage.searchAndAddProduct("A", 1); productPage.searchAndAddProduct("B", 5); shoppingCartPage.checkProductIsInCart(expectedProducts); }
Conclusion
As we’ve seen in these simple examples, it is easy to create short, easy-to-maintain tests. We just need to properly identify any reusable components, and then adapt them to the scenarios we need to implement.
It all starts with the requirements, from which we derive the test scenarios. We then visualize the flow, possibly using a whiteboard (or even paper if we don’t have a whiteboard), and identify all the blocks our tests are made up of.
When implementing the corresponding methods to each scenario, we just need to analyze what is the best signature for that method, so that we can cater to as many test scenarios as possible. The result: block by block, we will create our simply beautiful tests 🦋
I hope you found my technique for writing a good architecture helpful. Share with me your thoughts & if you have other useful tips 🙏