I touched on this point when I said that tests should have a clear reason to fail. I will reinforce it now. Your test code base will grow significantly. But you probably will not read it until there is a bug or you add another test to the suite.
It is well known that developers spend more time reading than writing code. Therefore, saving reading time will make you more productive. All the things you know about code readability and use in your production code apply to test code, as well. Do not be afraid to invest some time in refactoring it. The next developer will thank you.
I follow two practices when making my tests readable: make sure all the information (especially the inputs and assertions) is clear enough, and use test data builders whenever I build complex data structures.
Let’s illustrate these two ideas with an example. The following listing shows an Invoice
class.
public class Invoice {
private final double value;
private final String country;
private final CustomerType customerType;
public Invoice(double value, String country, CustomerType customerType) {
this.value = value;
this.country = country;
this.customerType = customerType;
}
public double calculate() { ❶
double ratio = 0.1;
// some business rule here to calculate the ratio
// depending on the value, company/person, country ...
return value * ratio;
}
}
❶ The method we will soon test. Imagine business rule here.
Not-very-clear test code for the calculate()
method could look like the next listing.
Listing 10.2 A not-very-clear test for an invoice
@Test
void test1() {
Invoice invoice = new Invoice(new BigDecimal("2500"), "NL",
➥ CustomerType.COMPANY);
double v = invoice.calculate();
assertThat(v).isEqualTo(250);
}
At first glance, it may be hard to understand what all the information in the code means. It may require some extra effort to see what this invoice looks like. Imagine an entity class from a real enterprise system: an Invoice
class may have dozens of attributes. The name of the test and the name of the cryptic variable v
do not clearly explain what they mean. It is also not clear if the choice of “NL” as a country or “COMPANY” as a customer type makes any difference for the test or whether they are random values.
A better version of this test method could be as follows.
Listing 10.3 A more readable version of the test
@Test
void taxesForCompanies() {
Invoice invoice = new InvoiceBuilder()
.asCompany()
.withCountry("NL")
.withAValueOf(2500.0)
.build(); ❶
double calculatedValue = invoice.calculate(); ❷
assertThat(calculatedValue) ❸
.isEqualTo(250.0); // 2500 * 0.1 = 250
}
❶ The Invoice object is now built through a fluent builder.
❷ The variable that holds the result has a better name.
❸ The assertion has a comment to explain where the 250 comes from.
First, the name of the test method—taxesForCompanies
—clearly expresses what behavior the method is exercising. This is a best practice: name your test method after what it tests. Why? Because a good method name may save developers from having to read the method’s body to understand what is being tested. In practice, it is common to skim the test suite, looking for a specific test or learning more about that class. Meaningful test names help. Some developers would argue for an even more detailed method name, such as taxesForCompanyAreTaxRateMultipliedByAmount
. A developer skimming the test suite can understand even the business rule.
Many of the methods we tested while complex, had a single responsibility: for example, substringsBetween
in or leftPad
. We even created single parameterized tests with a generic name. We did not need a set of test methods with nice names, as the name of the method under test said it all. But in enterprise systems, where we have business-like methods such as calculateTaxes
or calculateFinalPrice
, each test method (or partition) covers a different business rule. Those can be expressed in the name of that test method.
Next, using InvoiceBuilder
(the implementation of which I show shortly) clearly expresses what this invoice is about: it is an invoice for a company (as clearly stated by the asCompany()
method), “NL” is the country of that invoice, and the invoice has a value of 2500. The result of the behavior goes to a variable whose name says it all (calculatedValue
). The assertion contains a comment that explains where the 250 comes from.
InvoiceBuilder
is an example of an implementation of a test data builder (as defined by Pryce [2007]). The builder helps us create test scenarios by providing a clear and expressive API. The use of fluent interfaces (such as asCompany().withAValueOf()...
) is also a common implementation choice. In terms of its implementation, InvoiceBuilder
is a Java class. The trick that allows methods to be chained is to return the class in the methods (methods return this
), as shown in the following listing.
Listing 10.4 Invoice test data builder
public class InvoiceBuilder {
private String country = "NL"; ❶
private CustomerType customerType = CustomerType.PERSON;
private double value = 500;
public InvoiceBuilder withCountry(String country) { ❷
this.country = country;
return this;
}
public InvoiceBuilder asCompany() {
this.customerType = CustomerType.COMPANY;
return this;
}
public InvoiceBuilder withAValueOf(double value) {
this.value = value;
return this;
}
public Invoice build() { ❸
return new Invoice(value, country, customerType);
}
}
❶ The builder contains predefined values allowing the user to only set the values they need to customize for the current test.
❷ The builder contains many methods that let the test change a specific value (such as the country).
❸ Once the required Invoice is set up, the builder builds an instance of it.
You should feel free to customize your builders. A common trick is to make the builder build a common version of the class without requiring the call to all the setup methods. You can then, in one line, build a complex invoice, as you see in the next listing.
Listing 10.5 Building an invoice with a single line
var invoice = new InvoiceBuilder().build();
In such a case, the build
method (without any setup) will always build an invoice for a person with a value of 500.0 and “NL” as the country (see the initialized values in InvoiceBuilder
).
Other developers may write shortcut methods that build other common fixtures for the class. In listing 10.6, the anyCompany()
method returns an Invoice that belongs to a company (and the default value for the other fields). The fromTheUS()
method builds an Invoice for someone in the U.S.
Listing 10.6 Other helper methods in the builder
public Invoice anyCompany() {
return new Invoice(value, country, CustomerType.COMPANY);
}
public Invoice fromTheUS() {
return new Invoice(value, "US", customerType);
}
Building complex test data is such a recurrent task that frameworks are available to help, such as Java Faker for the Java world and factory_bot for Ruby. I am sure you can find one for your programming language.
Finally, you may have noticed the comment near the assertion: 2500
*
0.1
=
250
. Some developers would suggest that the need for this comment indicates the code requires improvement. To remove the comment, we can introduce explanatory variables: in listing 10.7, we use the invoiceValue
and tax
variables in the assertion. It is up to you and your team members to agree on the best approach for you.
Listing 10.7 Making the test more readable via explanatory variables
@Test
void taxesForCompanyAreTaxRateMultipliedByAmount() {
double invoiceValue = 2500.0; ❶
double tax = 0.1;
Invoice invoice = new InvoiceBuilder()
.asCompany()
.withCountry("NL")
.withAValueOf(invoiceValue) ❷
.build();
double calculatedValue = invoice.calculate();
assertThat(calculatedValue)
.isEqualTo(invoiceValue * tax); ❸
}
❶ Declares the invoiceValue and tax variables
❷ Uses the variable instead of the hard-coded value
❸ The assertion uses the explanatory variables instead of hard-coded numbers.
To sum up, introducing test data builders, using variable names to explain the meaning of information, having clear assertions, and adding comments where code is not expressive enough will help developers better comprehend the test code.
Leave a Reply