This post belongs to the How to create Lean Test Automation Architecture for Web using Java series. If you didn’t see the previous posts, please check them out!
What problem does it solve?
One of the biggest pain points during test automation, whatever the layer you are testing, is to manage the test data. I would say it’s quite easy to manage in the unit and integration levels, where we can control the application state, but for functional and e2e tests it’s quite hard.
Would be awesome if we could stop manually change data we have in the code file or even from an external source like a CSV or JSON file, right?
We would like to solve the code maintenance and the test failures due to the data changes and create an easy and extensible way to generate test data.
What is the Test Data Factory?
We don’t have an agreement about test terminologies (yes, I know that sucks) and we don’t have a definition for the Test Data Factory approach, so here’s my definition.
During some research* (read this as “I was using Google”) I found interesting posts related to the same concept: the ObjectMother pattern
the research:
- Combining Object Mother and Fluent Builder for the Ultimate Test Data Factory
- Test Data Builders and Object Mother: another look
- Writing Clean Tests – New Considered Harmful
- Test Data Builders: an alternative to the Object Mother pattern
The example
Think that you have an application you must fill in a form where get a loan, where it has these two requirements:
- a not null name
- a valid e-mail
- an amount should be greater than $ 1.000 and less than $ 40.000
- the installments should be greater than 2 and less than 10
If you already know some testing techniques you can see yourself applying the Boundary Value Analysis (BVA) testing technique for the amount and installments inputs.
We must figure out a way to test all those possible scenarios filling the form with, of course, some data:
- all valid information
- name as null
- not a valid e-mail
- an amount less than $ 1.000
- an amount greater than $ 40.000
- installments less than 2
- installments greater than 18
Test example with hard-coded data
Let’s assume you’re using Selenium WebDriver to fill in this information on a web page. A hard-coded data example would be:
LoanPage loanPage = new LoanPage();
loanPage.fillName("Elias");
loanPage.fillEmail("elias@eliasnogueira.com");
loanPage.fillAmount(new BigDecimal(10.000));
loanPage.fillInstallments(8);
loanPage.clickOnSubmit();
The example above is using Java and the Page Object pattern to fill in a form on a web page. Notice that from lines 3 to 6 we are using fixed data to fill the form.
Using a fixed data in the test is considered a bad practice and should be avoided in order to have less maintenance in the automated test code.
How to implement it?
There’re a few steps we must follow:
- Create an object to model the data you need
- Create a builder class
- Create a factory class to consume the data
- Modify the class to generate dynamic data
- Use the factory class in the test
Create an object to model the data you need
In our example, we are filling a loan form based on name, e-mail, amount, and installments. We need to create an object (class) to record this information.
In a plain Java class we can model our example like this:
public class Loan {
// private attributes
private String name;
private String email;
private BigDecimal amount;
private int installments;
// constructor
public LoanData(String name, String email, BigDecimal amount, int installments) {
this.name = name;
this.email = email;
this.amount = amount;
this.installments = installments;
}
// getters and setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// others getters and setters ignored (but they're necessary)
@Override
public String toString() {
return "Loan{ \"name='" + name + '\'' +
", email='" + email + '\'' +
", amount=" + amount +
", installments=" + installments + '}';
}
}
When we create a model class using Java we need three things: the private attributes, the constructor, and the getters and setters.
We need to create the same attributes we’re going to use to fill in the data. You can see this in lines 4 to 7.
The constructor is necessary to create the object and easily inform the data, but we are going to use it to generate our builder class. You can see this on lines 10 to 14.
The getters and setters can be used to get or set a single data in the object. You can see this on lines 19 to 25.
Lines 28 to 33 show the toString
method that will be necessary to log the data we will use in the tests.
You might not need the getters and setters if we plan to use the builder object so you can remove it and set the attributes as final, but you still need the constructor and the toString method.
The model class would be used as the example below, but we will make it more readable and fluent using the builder pattern.
Loan loan = new Loan("elias", "elias@eliasnogueira.com", new BigDecimal(10.000), 8);
Create a builder class
You could use the getters and setters in the Loan
class (if you didn’t remove it) to create the class with data, but we have a better and fluent way to do it: the builder pattern.
I would recommend you to read the link below but, in general, and for this example, a builder consists of the same attributes we have in the Loan
class, methods that receive an attribute as a parameter and return the builder class, and a build method that will create the concrete class.
Let’s learn the internal structure.
public class LoanBuilder {
private String name;
private String email;
private BigDecimal amount;
private int installments;
public LoanBuilder name(String name) {
this.name = name;
return this;
}
public LoanBuilder email(String email) {
this.email = email;
return this;
}
public LoanBuilder amount(BigDecimal amount) {
this.amount = amount;
return this;
}
public LoanBuilder installments(int installments) {
this.installments = installments;
return this;
}
public Loan build() {
return new Loan(name, email, amount, installments);
}
}
Lines 3 to 6 have the same attributes as the Loan
class. We need it to create the class with the parameters filled in using its constructor.
The lines from 8 to 26 are the build methods. The method must return the builder class, have a parameter, associate the parameter with an attribute, and return itself (this
). Usually, the method name is the same as the data you fill inform.
Lines 28 to 30 show the build method. This method is responsible to create the main object, which in our case is Loan. It uses all the attribute values used through the methods to create it where it will return a new Loan
object.
Within it you can create the class with data in a fluent way, like this:
public class MyTest {
public static void main(String[] args) {
// create a new Loan object using the LoanBuilder
Loan loan = new LoanBuilder().
name("elias").
email("elias@eliasnogueira.com").
amount(new BigDecimal(10.000)).
installments(8).
build();
// show the loan data
System.out.println("loan = " + loan);
}
}
Note that we can use the LoanBuilder
to chain the methods, as it returns itself, filling in the necessary data and, always, constructing it using the build
method.
Create a factory class to consume the data
The factory data class is slightly different from the Factory pattern (actually it’s simpler) where we have the following aspects to implement:
- a private constructor to avoid the class instantiation
- a static method that will generate the data
- as it generates de data it must return it
- the method must return the data model
In the example below, we have different factory methods for creating a loan (createLoan
), creating a loan with an invalid e-mail (createLoanWithNotValidEmail
), and others.
Note that we are using the LoanBuilder
to easily add the correct data to the attributes, returning the object Loan with all the necessary data.
public class LoanDataFactory {
private LoanDataFactory() {}
public static Loan createLoan() {
return new LoanBuilder().name("Elias").email("elias@eliasnogueira.com")
.amount(new BigDecimal("10.000")).installments(8).build();
}
public static Loan createLoanWithoutName() {
return new LoanBuilder().email("elias@eliasnogueira.com")
.amount(new BigDecimal("10.000")).installments(8).build();
}
public static Loan createLoanWithNotValidEmail() {
return new LoanBuilder().name("Elias").email("not-valid-email")
.amount(new BigDecimal("10.000")).installments(8).build();
}
public static Loan createLoanWithAmountLessThan() {
return new LoanBuilder().name("Elias").email("elias@eliasnogueira.com")
.amount(new BigDecimal("900.00")).installments(8).build();
}
// other data factories ignored
}
As you can see the data still hard-coded. It’s ok but we can improve it a little bit more by adding a dynamic generation.
Modify the class to generate dynamic data
We can dynamically generate data using different ways. The three main ones are:
- using a library
- using an API endpoint
- using a database
I will focus on the first one: using a library.
For that I will introduce you JavaFaker, a is a library that can generate fake random data every time it’s called. For example: if you generate a name all the names generated will be different.
public class LoanDataFactory {
private static Faker faker;
private static final int MIN_VALID_INSTALLMENTS = 2;
private static final int MAX_VALID_INSTALLMENTS = 18;
private static final int MAX_NUMBER_OF_DECIMALS = 2;
private static final Long MIN_VALID_AMOUNT = Long.valueOf("1000");
private static final Long MAX_VALID_AMOUNT = Long.valueOf("40000");
private LoanDataFactory() {
faker = new Faker();
}
public static Loan createLoan() {
return new LoanBuilder().
name(faker.name().firstName()).
email(faker.internet().emailAddress())
.amount(BigDecimal.valueOf(
faker.number().randomDouble(
MAX_NUMBER_OF_DECIMALS, MIN_VALID_AMOUNT, MAX_VALID_AMOUNT)))
.installments(faker.number().numberBetween(MIN_VALID_INSTALLMENTS, MAX_VALID_INSTALLMENTS)).build();
}
// other methods ignored
}
As you can see in the code above the hard-coded data was replaced by the usage of JavaFaker. You will receive different data all the time you call the factory method, but it’s still valid data.
The constants created from line 4 to 8 was added to add more clarity and reduce the maintenance in changing the values in the future.
You can see from line 16 to 21 the usage of Java Faker to generate the correct value for each field. For example, the email is added to the Loan object using the faker.internet().emailAddress()
where it generates a valid e-mail.
Use the factory class in the test
I explained in the Test Example with hard-coded data about how this class looks like. Let’s now using the Test Data Factory approach we learned.
@Test
void submitValidLoan() {
Loan validLoanData = LoanDataFactory.createLoan();
LoanPage loanPage = new LoanPage();
loanPage.fillName(validLoanData.getName());
loanPage.fillEmail(validLoanData.getEmail());
loanPage.fillAmount(validLoanData.getAmount());
loanPage.fillInstallments(validLoanData.getInstallments());
loanPage.clickOnSubmit();
}
Line 4 shows the usage of the data factory method createLoan()
where the object returned is set to the validLoanData
attribute.
Lines 7 to 10 show the usage of the Loan object (validLoanData
) to fill in each data using the getters. The data will be different every time we run the test.
Real examples
Would you like to see real examples for two different test targets: web and API?
Web
In the selenium-java-lean-test-architecture project, you will find the BookingDataFactory class that generates data for booking a room.
Notice that I have a restriction: in the frontend, I have fixed data for the countries and daily budget. We can still randomize it and make the factory class return random data for a limited set of data.
API
In the project restassured-complete-basic-example you will find different data generation approaches in the data package. The SimulationDataFactory class has a complete set of factory methods for all the possible scenarios.
In some cases, you can even call some existing endpoints to provide data you need to use in your tests. It’s the case for the method getAllSimulationsFromApi() where I’m getting the existing simulation, using it in the oneExistingSimulation() factory method to get a random existing simulation and on allExistingSimulations() to return all the existing simulations.
Top comments (0)