SDET: SOLID principle examples in Test Automation solutions

Kostiantyn Teltov
12 min readApr 11, 2023

Greetings,

The best QA/SDET community,

In one of my previous articles, we talked about test design patterns that help you build structural test automation solutions (Design Patterns for QA Automation: Build effective test solutions). After some time, I decided that it would be fair to remember the SOLID principles as well. It is not always obvious how to use them and you may not need them in all cases. So there are reasons to talk about them.

S.O.L.I.D

What SOLID stands for?

SOLID is a basic acronym for Design Principles first introduced by American software engineer and instructor Robert C. Martin in 2000.

Solid stands for:

  • S = Single Responsibility Principle
  • O = Open/Closed Principle
  • L = Liskov Substitution Principle
  • I = Interface Segregation Principle
  • D = Dependency Inversion Principle

You will ask me, “What are the differences between design principles and design patterns?”

Design Principles vs Design Patterns

Design Patterns are more solution guides to resolve already known problems. They are very specific and you can choose what design pattern to use in some concrete cases.

SOLID principles (Design Principles) are more abstract principles we should try to follow when we build our solutions (Including Test Automation Solutions).

That’s why design principles (SOLID) are more difficult to understand. It does not contain concrete steps. Anyway, in this article, we will consider real examples that can be used in Test Automation Projects. It may give you some basic understanding of how it can be used.

Let’s jump to each principle with examples

Single Responsibility Principle (SRP)

Probably the easiest principle for understanding. Each software module should have one and only one reason to change.

This principle is about avoiding creating a zoo in a class, method, or folder. Instead, make each entity responsible only for a specific logic.

Some of the examples how you can use it:

PageObject model

Suppose you create a separate class for each page. Like HomePage, LoginPage, OrdersPage, and so on. You do not put all the page locators in one class called “PageObject”, but split the logic for each of the classes responsible for its own page.

In some cases, a page can be very large. So you can split this page into smaller PageFragments. Like header, table, footer, and so on.

Webdriver classes

We can have a separate class for each of the WebDriver implementations like, ChromeDriver, FirefoxDriver, IEDriver and etc.

Utils/Helpers

You should not create a helper class that contains all the existing methods. What you need to do is split these classes logically.

For example, you have a folder with data generators. What you can do is split these generator classes. StringGenerator, IntGenerator (NumberGenerator), DateTimeGenerator and so on.

Or you can have different classes for FileHelper, DatabaseHelper and etc.

One method responsible only for one action

When the method calculates a value, it should only perform the calculation of that value. It should not also read that value from a file or database or some other place. A different action can be performed in another class/method.

So now you know that when you want to create a class or method, you should think about the true goal of the entity and not overwhelm it with logic that has nothing to do with it.

Open Closed Principle (OCP)

The Open-Closed Principle (OCP) is a design principle in object-oriented programming that states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality to a system without having to modify existing code.

The main goal is you should not change already existing parts of the test solution, but try to extend it.

Let’s consider some of the examples:

API Test models

Today we have implemented the REST API Endpoint of version 1(v1).

Some model was created for this version.

Six months after we decided to add a new version of the endpoint with some additional logic and add some new fields to our model.

Instead of updating the already existing model, we can create a new model based on the already existing model. It allows us to use already existing model fields/properties and add new properties.

Example with WebElements implementation

Let’s imagine you want to build some reusable UI Components like Button, Text, Check-box and etc.

What you can do is first create a basic component with very basic methods. Like, return if an element is displayed. You may add some more methods. This is just an example.

Then you can create a very simple Readonly-text component(Label component). It may be inherited from basic component and implement additional methods. Like reading a text.

The next component can be Input Field (Which can be used for Username, Password, Comment and etc.). You can inherit it from the Readonly-text component because it already contains the methods you can use in the current component.

So, that’s how step by step you can extend a basic component instead of modification of existing ones.

Example with Base test class implementation

Very similar to previous one example. Let’s imagine you have a base class that used by most of the test classes.

In the future you may want to add some specific logic related only to some specific test classes. For our example I will add very simple class adds Allure Report attribute.

In short words OCP principle can be achieved through techniques such as inheritance, composition, and dependency injection.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is a fundamental principle of object-oriented design that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program

Example with PageObject

Suppose we have a base class “PageObject” that represents a page in our web application. The “PageObject” class has a “Load” method that loads the page in the browser and returns an instance of itself.

Now suppose we have a subclass “LoginPage” that represents the login page of our application. The “LoginPage” class has a “Login” method that logs in to the application and returns an instance of the home page.

According to the Liskov Substitution Principle, we should be able to use an instance of “LoginPage” wherever an instance of “PageObject” is expected, without affecting the correctness of the program. This means that we should be able to call the “Load” method on an instance of “LoginPage” and get an instance of “LoginPage” back, just like we would with an instance of “PageObject”.

In this example, we create an instance of “LoginPage” and call the “Load” method on it. The “Load” method returns an instance of “PageObject”, but we can safely cast it back to “LoginPage” using the as operator, because we know that it is an instance of “LoginPage”.

By applying the Liskov Substitution Principle in our test automation solution, we can ensure that our tests are easy to maintain, extend, and reuse, because we can use polymorphism to write tests that are more generic and adaptable to changes in our application.

Example with Test ApiClient

Suppose we have a base class ApiClient that represents an HTTP client for our API. The ApiClient class has a SendAsync method that sends an HTTP request to the API and returns an HTTP response.

Now suppose we have a subclass UsersApiClient that represents an HTTP client for the users endpoint of our API. The UsersApiClient class has a GetUser method that sends a GET request to the users endpoint and returns user data.

According to the Liskov Substitution Principle, we should be able to use an instance of UsersApiClient wherever an instance of ApiClient is expected, without affecting the correctness of the program. This means that we should be able to call the SendAsync method on an instance of UsersApiClient and get an HTTP response back, just like we would with an instance of ApiClient.

In this example, we create an instance of UsersApiClient and call the SendAsync method on it. The SendAsync method returns an instance of Task<HttpResponseMessage>, which is what we would expect from an instance of ApiClient.

By applying the Liskov Substitution Principle in our API tests, we can ensure that our tests are easy to maintain, extend, and reuse, because we can use polymorphism to write tests that are more generic and adaptable to changes in our API.

As you can see, LSP looks very useful, you can definitely adapt it in your automation solutions.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is a principle in object-oriented programming that states that clients should not be forced to depend on interfaces they do not use. In the context of test automation solutions, ISP can be applied by breaking down larger, monolithic interfaces into smaller, more specific ones that are tailored to the needs of individual clients.

Example with WebElement

Suppose we have an interface IWebElement that represents a web element on a page. The IWebElement interface has methods like Click, SendKeys, and GetText.

Now suppose we have a class Button that represents a button element on a page. The Button class implements the IWebElement interface, but it only needs to implement the Click method, because buttons cannot receive text input or return text.

According to the Interface Segregation Principle, clients should not be forced to depend on methods they do not use. In the above example, Button clients only need to call the Click method, and they do not need to depend on the SendKeys or GetText methods, which are not applicable to buttons.

Now suppose we have another class TextField that represents a text input element on a page. The TextField class also implements the IWebElement interface, but it only needs to implement the SendKeys and GetText methods, because text fields cannot be clicked.

In this example, TextField clients only need to call the SendKeys and GetText methods, and they do not need to depend on the Click method, which is not applicable to text fields.

Example with WebDriver interfaces

Suppose we have an interface IWebDriver that represents a web driver. The IWebDriver interface has methods like FindElement, Navigate, and Quit.

Now suppose we have a class ChromeDriver that implements the IWebDriver interface for the Chrome browser. The ChromeDriver class implements all the methods of the IWebDriver interface, but it also has additional methods that are specific to Chrome, like SetChromeOptions and GetPageSource.

According to the Interface Segregation Principle, clients should not be forced to depend on methods they do not use. In the above example, ChromeDriver clients may only need to use the methods from the IWebDriver interface, and they may not need to use the additional methods that are specific to Chrome.

To address this, we can create a separate interface for the methods that are specific to Chrome, like IChromeDriver. The ChromeDriver class can then implement both the IWebDriver and IChromeDriver interfaces, and clients can choose which interface to use, depending on their needs.

In short, By applying the Interface Segregation Principle in our test automation solutions, we can make our code more modular, extensible, and maintainable, because we can create interfaces that are tailored to the needs of different types of web elements, and we can avoid unnecessary dependencies on methods that are not applicable to certain types of web elements.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is a principle in object-oriented programming that states that high-level modules should not depend on low-level modules, but instead should depend on abstractions. In the context of test automation solutions, DIP can be applied by using interfaces or abstract classes to define dependencies between modules, rather than concrete implementations.

PageObject example

Let’s consider an example where we have a LoginPage class that has a dependency on a WebDriver. The LoginPage class uses the WebDriver to interact with the login page of a web application and perform actions like entering username and password, and clicking on the login button.

Without applying the DIP, we might create a LoginPage object and instantiate a WebDriver object inside the LoginPage constructor, like this:

However, this creates a tight coupling between the LoginPage class and the ChromeDriver class, which makes it difficult to switch to a different type of driver in the future, or to mock the WebDriver for testing purposes.

To apply the DIP, we can use dependency injection to inject the WebDriver into the LoginPage class, like this:

Now, when we create a LoginPage object, we can inject a WebDriver object of any type, including a mock WebDriver for testing purposes:

API Client example

Suppose we have an ApiClient class that is responsible for sending requests to an API and receiving responses. The ApiClient class might have a dependency on a specific HTTP client library, such as HttpClient, to make HTTP requests.

Without applying the DIP, we might create an ApiClient object and instantiate an HttpClient object inside the ApiClient constructor, like this:

However, this creates a tight coupling between the ApiClient class and the HttpClient class, which makes it difficult to switch to a different type of HTTP client library in the future, or to mock the HttpClient for testing purposes.

To apply the DIP, we can use dependency injection to inject the HttpClient into the ApiClient class, like this:

Now, when we create an ApiClient object, we can inject an HttpClient object of any type, including a mock HttpClient for testing purposes:

Database Helper example

Suppose we have a test suite that includes multiple test cases, and each test case needs to interact with a database.

To apply the DIP, we can use dependency injection to inject a database connection object into each test case, like this:

Now you can inject Database in the test directly or in some helper/base class:

By applying the DIP, we can make our test automation code more modular and flexible, because we can easily swap out dependencies for different implementations, or mock dependencies for testing purposes, without having to modify the classes that use those dependencies.

To summarize

Huh. Finally. This is probably one of the most difficult articles I have ever written. Perhaps the main reason is that design principles are abstract. The most important thing to understand is that you should not follow the principles because of the principles. Maybe in some cases you need to modify a class/module, but not extend it. Maybe you need to combine some logic inside a method instead of using the single responsibility principle. Either way, it is always nice to see more than one way. And the SOLID principles will help you find those paths. Good luck, Pathfinder.

“One small step for man, one giant leap for mankind.” (Neil Armstrong)

--

--

Kostiantyn Teltov

From Ukraine with NLAW. QA Tech Lead/SDET/QA Architect (C#, JS/TS, Java). Like to build testing processes and help people learn. Dream about making indie games