Practical Implementation Of SOLID Principles In Test Automation
We covered some clean code pieces of advice in clean code article. However the list of clean code advice is not possible to summarize easily. Therefore, this can be considered a practical sequel to the previous article. These principles are something that is used in programming. SOLID principles in test automation should be equally applied. SOLID is as an acronym and each letter represents one principle: S – Single Responsibility Principle, O – Open Closed Principle, L – Liskov Substitution Principle, I – Interface Segregation Principle, D – Dependency Injection Principle. We will go through each of them with an example specific for test automation.
Single Responsibility Principle (SRP)
The essence of this principle lies in the fact that a module in programming should have just one purpose. Alternative definitions say that module should have just one reason to change. For all intents and purposes we will consider a module to be a class, method, test, etc. In case of test automation, when we use Page Object Model (POM) we follow SRP. In POM we create classes containing elements which belong to one page only. If we would mix elements from different pages, that would mean we are breaking the SRP.
However, if you are like me and like to create specific classes for each of the controls which should contain all the methods that could be used with such element, then you have to be more careful. Let’s look at an example in Playwright with Typescript:
This is a reusable class which can be used for any button in the application. This class extends BaseControl class which contains functions for finding the element. The element locator is being passed from page object class where I create an instance of button class and pass the locator as an argument.
The button class contains three methods which are related to button elements. If I would add here methods for any other element, like dropdowns I would be breaking SRP.
SRP can be also applied to tests. The premise that a test should test only one thing is exactly what SRP is all about. If you have multiple asserts in your tests, you are breaking SRP.
Open Closed Principle
Open Closed Principle (OCP) is one of the most important SOLID principles in test automation. It states that a module can be opened for extensions but closed for modifications. In practical terms we can say that a working code class can be extended (inherited) by other classes but we shouldn’t modify its contents. Modifications lead to breaking changes. With extensions we add new code to the code base, but we do not touch the existing code.
In our above example of button class there are things I am not allowed to do. If for example I find a new button in the system under test which does not work with this click
function I could amend the function to make it work. This would be the wrong choice because I would break OCP. In this case, proper thing to do would be to abstract the button functions into an interface and implement the logic for each of the functions in the classes implementing this interface.
export interface ButtonInterface {
hover(): Promise<void>;
click(): Promise<void>;
isHovered(): Promise<boolean> | undefined;
}
export class Button implements ButtonInterface {
hover(): Promise<void> {
//function implementation
}
click(): Promise<void> {
//function implementation
}
isHovered(): Promise<boolean> | undefined {
//function implementation
}
}
export class NewButton implements ButtonInterface {
hover(): Promise<void> {
//function implementation
}
click(): Promise<void> {
//function implementation
}
isHovered(): Promise<boolean> | undefined {
//function implementation
}
}
An alternative would be to create a new class which would extend the Button class and override the click function in this new class. Then we would create an instance of this new class in page object classes when we need to represent a button of such kind.
There is a matter of adding new functions in this class. Is such action considered a breaking of OCP? It depends on many factors. Does new code interfere with the existing tests? Does it require to change something in the base class because of this new function? If the answer is yes, then OCP would be broken.
Liskov Substition Principle
This principle is kind of addition to OCP. It says that we can replace base classes with derived classes in our tests and keep the existing functionality. In our above example of Button class, we should be able to replace the instance of BaseControl class with Button class without breaking the functionality of tests.
export SomeClass {
constructor(controlProperties: IControlProperties) {
this.controlProperties = controlProperties;
}
let control = new BaseControl();
let element = control.findControl(this.controlProperties);
}
//this should work the same
export SomeClass {
constructor(controlProperties: IControlProperties) {
this.controlProperties = controlProperties;
}
//we replace the BaseControl with Button
let control = new Button();
let element = control.findControl(this.controlProperties);
}
Interface Segregation Principle
This principle helps us avoid excessive implementation of interfaces we don’t need to use. Having large interfaces with number of functions which are not always needed in every implementation is not something we want. We usually want to have smaller interfaces with specific functions. In the below (bad) example we have a single control interface which covers several types of controls we can have in our application: buttons, input fields and list of values. Therefore, we have typeText
, click
and getText
methods in this interface. The classes implementing this interface need to implement every method although the class for buttons does not need typeText
and getText
methods. The buttons cannot have such functionalities.
export interface ControlInterface {
typeText(): Promise<void>;
click(): Promise<void>;
getText(): Promise<string>;
}
export class Button implements ControlInterface {
typeText(): Promise<void> {
//not needed
}
click(): Promise<void> {
//function implementation
}
getText(): Promise<string> {
//not needed
}
}
export class TextField implements ControlInterface {
typeText(): Promise<void> {
//function implementation
}
click(): Promise<void> {
//not needed
}
getText(): Promise<string> {
//function implementation
}
}
The solution would be to separate this interface and in each specific interface add just the methods it needs. Then each of the control classes like Button and TextField can implement just the interface they need. In case of dropdown controls which could have all three methods click
, typeText
and getText
we can implement both interfaces.
Dependency Inversion Principle
Dependency inversion principle (DIP) is best used with dependency injection. DIP claims that software modules should not depend on concrete implementations like classes but instead they should depend on abstractions. In this way we remove tight coupling between parts of our code making the pieces of code easily replaceable and less error prone. The most famous example of this SOLID principle in test automation is the usage of WebDriver interface in Selenium. WebDriver is an interface implemented by ChromeDriver, FirefoxDriver and EdgeDriver classes. Each of these classes have implementations related just to the browser in question.
public class HomePage {
private WebDriver driver;
public HomePage(WebDriver driver) {
this.driver = driver;
}
public string getTitleText() {
return driver.findElement(By.id("title")).getText();
}
public void closeButtonClick() {
driver.findElement(By.id("button")).click();
}
}
@Test
public class HomePageTest {
WebDriver driver = new ChromeDriver();
HomePage homepage = new HomePage(driver);
Assert.assertEquals(homepage.getTitleText(), "Some page title", "Wrong title");
}
As you can see in the code above, the implementation of ChromeDriver is injected in HomePage constructor. This makes the class dependent on abstraction instead of a specific driver implementation.
Conclusion
SOLID principles in test automation are just as equally important as in application programming. Tests also must follow the best practice of coding. With good understanding SOLID principles we avoid making mistakes which can cost us later. It is understandable that some situations can be unforeseen and we cannot avoid some architectural mistakes and delays. However, with SOLID we can minimize the impact of unknown on our tests. These principles are not easy to understand and implement, but practice makes perfect. After being exposed to a number of situations where these principles are being implemented we can achieve higher understanding of the concepts for the majority of situations.