In day-to-day automation tasks, Test Engineers have to think of different ways to write their automation tests. At the same time, while creating test automation strategies they have to think about the best way to use the existing code, and also provide new lines of code for new coverage. DRY code comes in handy when designing automation tests.
Let’s say that you’re starting the automation of your project, and you’re working on the web application. When testing web applications, usually there are forms that need to be automated by inserting data into inputs. But, in most cases, those forms are made of optional and required fields (inputs). So, those tests would usually have different flows throughout the application.
(Source)
Presenting the problem
In order to automate these fields, Test Engineers would probably use simple if/else logic to control test flow.
There are two problems with this approach:
- Complexity
- Maintainability
Imagine automating a form with a large number of inputs where, by inserting data to one input, a whole subset of inputs is open and this flow keeps repeating throughout the form.
You would probably agree that there are a lot of combinations to be covered from the automation perspective.
Another problem that might occur for a test written with a lot of conditioning is maintainability. How fast can you make a change in your test?
For example, what happens if an optional field becomes a required one, you would have to track down and change methods in two places at least (in conditionals). But if your methods are scaled in many different tests, that would take a lot of time to track and change, and also, it might lead to an accidental error (or changing the test flow), leading to more time consumption.
From these examples, we can agree that using conditional logic isn’t always the best way to control your test flow. So, is there a better way?
Well, keep reading this blog, and I will show you a better way.
Presenting the solution
Let’s show a simple demo (GitHub). For this purpose, we will create a test with an example from an automation practice website. Our tech stack consists of Protractor, Jasmine & Selenium WebDriver, and for the test structure, we will be using Page Object Model (POM). POM means that our tests and data are separated from web elements and their locators, so that when you have to change an element locator, it’s changed only in one place, and it doesn’t affect tests. Every application screen represents a separate page object. The same philosophy applies to the data file, if data is changed in a data file, that change reflects on all tests where the data is used.
In addition to this structure is the “Tasks” folder, which contains tasks (code partitions that are formed from page objects), and they execute a certain job. The structure shown in the image means that the tests are made of tasks, and the tasks are made of page objects.
The Registration Test suite would look like this:
const data = require('../Data/data.js'), requiredData = data.requiredData, CompleteRegistration = require('../Tasks/CompleteRegistration.js'), NavigateToAuthPage = require('../Tasks/NavigateToAuthPage'); const completeRegistration = new CompleteRegistration(), navigateToAuthPage = new NavigateToAuthPage(); browser.waitForAngularEnabled(false); //For non-Angular apps browser.ignoreSynchronization = true; //For non-Angular apps browser.manage().window().maximize(); //Browser opens in maximized screen describe('001: Registration', () => { let additionalData = { company: "Amazon", address2: "10th Street", email: `[email protected]`, address: "9th street in the East Village" } let allData = Object.assign(requiredData, additionalData); beforeEach( () => { navigateToAuthPage.navigateToAuthPage(requiredData.pageURL); }) it('001: Complete registration form (required data)', async () => { await completeRegistration.completeRegistrationForm(requiredData); }); it('002: Complete registration form (all data)', async () => { await completeRegistration.completeRegistrationForm(allData); }); });
The NavigateToAuthPage task would be:
class NavigateToAuthPage{ async navigateToAuthPage(pageUrl){ console.log('Started navigating to auth page ...') homePage.openPageURL(pageUrl) .waitForHomePage() .clickOnSignIn(); authForm.waitForAuthTitle(); } } module.exports = NavigateToAuthPage;
The Complete Registration task would be:
class CompleteRegistration{ async completeRegistrationForm(data){ console.log('Started creating registration ...') authForm.insertValueInEmail(data.email) .createAccount(); registrationForm.waitForCreateAccount() .insertFirstName(data.firstName) .insertLastName(data.lastName) .insertInIfDefined(data.company, () => { registrationForm.insertCompany(data.company); }) .inserFirstAddress(data.address) .insertInIfDefined(data.address2, () => { registrationForm.insertSecondAddress(data.address2) }) .insertCity(data.city) } } module.exports = CompleteRegistration;
And finally, data file would look like this:
const data = { requiredData: { pageURL: "http://automationpractice.com/index.php", firstName: "John", lastName: "Doe", email: "[email protected]", city: "New York", address: "9th street", } } module.exports = data;
As you can see, in the Registration Test suite we have two ITs, which call the same method (task), but those ITs handle different data (required and all). So, without conditionals, we have two different test flows.
How does this approach work, and how can you control your test flow with data?
First, you have to define a method, which will check if a certain property is defined. It will insert its value into the field, and if it isn’t properly defined, it will skip inserting its value.
Second, in your data, for a certain test case, if you don’t want a value to be inserted, you don’t define its property in the data object.
This method is defined in BasePage, and it looks like this:
insertIfDefined(property, callback){ console.log(`Insert data if ${property} is defined ...`) if (property != null) callback(); return this; }
If the property’s value isn’t null, this method will call a callback function, which will actually insert a value into the field. By doing this, we made sure that we can control the test flow with our data objects.
This method from BasePage class can be implemented like this, you can set it as a parent class to the page objects (e.g. RegistrationForm extends BasePage), upon which you will call chained methods. And every page object in which we can find optional and required fields can inherit this method from its parent class.
Here you can check the code for the Registration form page object:
let EC = protractor.ExpectedConditions; const BasePage = require('./BasePage.js'); class RegistrationForm extends BasePage{ //Getters get createAccountTitle(){ return '.account_creation'; } get firstName(){ return browser.driver.findElement(by.id("firstname")); } get lastName(){ return browser.driver.findElement(by.id("lastname")); } get company(){ return browser.driver.findElement(by.id("company")); } get firstAddress(){ return browser.driver.findElement(by.id("address1")); } get secondAddress(){ return browser.driver.findElement(by.id("address2")); } get city(){ return browser.driver.findElement(by.id("city")); } //Actions waitForCreateAccount(){ console.log("Waiting for registration page to load ... "); browser.wait(EC.presenceOf($(this.createAccountTitle)), 5000); return this; } insertFirstName(firstName){ console.log(`Inserting ${firstName} in firstName field ... `) this.firstName.sendKeys(firstName); return this; } insertLastName(lastName){ console.log(`Inserting ${lastName} in lastName field ... `) this.lastName.sendKeys(lastName); return this; } insertCompany(company){ console.log(`Inserting ${company} in company field ... `) this.company.sendKeys(company); return this; } inserFirstAddress(firstAddress){ console.log(`Inserting ${firstAddress} in firstAddress field ... `) this.firstAddress.sendKeys(firstAddress); return this; } insertSecondAddress(secondAddress){ console.log(`Inserting ${secondAddress} in secondAddress field ... `) this.secondAddress.sendKeys(secondAddress); return this; } insertCity(city){ console.log(`Inserting ${city} in city field ... `) this.city.sendKeys(city); return this; } } module.exports = RegistrationForm;
What about test maintenance? What happens if a certain field becomes required, and vice versa?
Well, in data objects, you would simply change/add the property’s value, and in that way, you can control your test flow, without the need to change the test itself.
This approach is much easier to maintain, would you agree?
So, we can conclude that there are many benefits of this approach:
- Easily maintainable
- Code simplicity
- DRY code
But, are there some downsides to this approach?
Yes, of course, there are.
By using this type of flow controlling, I have realized that you need to have your data well organized and documented, with clear naming nomenclature. So that when you have to make a change, you precisely know where to change it. If you need to adjust data from data.js for a certain test, you can use Object.assign and add new data inside of a test, or adjust existing data by assigning a new value to the existing properties from the data object.
Conclusion
If you have the need to write a new test suite, which contains a lot of different test flows with similar data, it would be hard to maintain that code with conditionals, and it would be better to control your test flow with data. When setting up the data objects, name them in a way that reflects how the flow should be executed, and your tests will become easily maintainable and scalable.
If you want to learn more about how Atlantbh QA teams implement Agile testing methodologies, and how to assure high quality of the application, I highly recommend checking out this link.
See you in the next blog!