How to do test refactoring towards fluent assertion pattern?
What are Clean Tests?
The Clean Code rules apply equally to the production code and the test code. So do code cleanup every time, including when you write tests. You will often notice opportunities for refactoring right after adding a new test or even before writing it. This will be the case when a new test requires parts that are already included in other tests – such as assertions or system configuration.
Such adjustments should take into account the basic principles of Clean Code. They mainly concern maintaining readability and maintaining the ease of introducing further changes. We should also make sure that the code is quick to read and understand.
Refactoring example
Below is a set of several integration tests. They check the price list for visiting a fitness club (gym, sauna, swimming pool). The logic also includes the calculation of loyalty points.
Although the example of this test is quite short, it already contains some code duplications. Code repeats can be found at the beginning and end of each test case.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then assertThat(payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo( "Be Fit Gym" ); assertThat(payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo( "4.0" ); assertThat(payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo( "100" ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then assertThat(payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo( "Be Fit Jacuzzi" ); assertThat(payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo( "10.0" ); assertThat(payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo( "300" ); } |
Refactoring in small steps
Formatowanie
Before I do my first transformation, note the value of code formatting. The above code has already been formatted. Before that, it looked like the code below. You probably see the difference when the code is clearer?
1 2 3 4 5 6 7 | @Test public void twoHours_PayForEach() { ... assertThat(payment).valueByXPath( "/table/tr[1]/td[1]" ).isEqualTo( "Gym" ); assertThat(payment).valueByXPath( "/table/tr[1]/td[2]" ).isEqualTo( "10.0" ); assertThat(payment).valueByXPath( "/table/tr[1]/td[3]" ).isEqualTo( "300" ); } |
Make assertions dependent on local variables
In well-formatted code, code repeats are more visible. This is how I prepare the code to extract methods that contain repetitions of logic. Before I perform the method extraction, I will make the repeating code dependent on local variables by extracting them.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Test public void twoHours_payEntryFee() { // Given Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then String facilityName = "Be Fit Gym" ; String facilityPrice = "4.0" ; String facilityPoints = "100" ; assertThat(payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo(facilityName); assertThat(payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo(facilityPrice); assertThat(payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo(facilityPoints); } |
Extract the assertions method
Now it’s time to extract the method. This is an automatic code refactoring in most Java development environments.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 | private void assertFacility(String payment, String facilityName, String facilityPrice, String facilityPoints) { assertThat(payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo(facilityName); assertThat(payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo(facilityPrice); assertThat(payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo(facilityPoints); } |
The extracted local variables are no longer needed, so we can inline them. Below is the result of this test refactoring.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then assertFacility(payment, "Be Fit Gym" , 4.0 , 100 ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then assertFacility(payment, "Jacuzzi" , 10.0 , 150 ); } |
Pay attention to the parameters of the methods
Note that the tests have become shorter. The problem, however, is now the number of parameters that additionally belong to two groups. The first group is the input data (the first parameter) and the second group are the values of each assertion (the next three parameters). Additionally, if the parameters next to each other are of the same type, it is easy to get confused in their order.
Create a new assertion class
Next, I will use the above two groups of parameters as the direction for subsequent changes. I put the method in a new class and define one of the groups as a constructor parameter. Then the current method will only contain parameters from the second group and will gain access to the first group through the class fields.
Dokonaj ektrakcji klasy poprzez ekstrakcję delegata
To create a new class, I launch “extract delegate” code refactoring, which is another automated conversion in IntelliJ IDE for Java language.
Here is the result of code transformation.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | private final FacilityAssertion facilityAssertion = new FacilityAssertion(); @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then facilityAssertion.assertFacility(payment, "Be Fit Gym" , 4.0 , 100 ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then facilityAssertion.assertFacility(payment, "Jacuzzi" , 10.0 , 150 ); } |
Inline field
The extra field in the class was not my goal. So I am absorbing this field. Then the new assertion object will be recreated from scratch wherever the field was used by logic.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then new FacilityAssetion().assertFacility(payment, "Be Fit Gym" , 4.0 , 100 ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then new FacilityAssetion().assertFacility(payment, "Jacuzzi" , 10.0 , 150 ); } |
Then I re-extract the “assertFacility” method. Thanks to this, calling the assertion constructor will be in one place only. Below the refactoring result.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | private void assertFacility(String payment, String facilityName, String facilityPrice, String facilityPoints) { new FacilityAssertion() .assertFacility(payment, facilityName, facilityPrice, facilityPoints); } @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then assertFacility(payment, "Be Fit Gym" , 4.0 , 100 ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then assertFacility(payment, "Jacuzzi" , 10.0 , 150 ); } |
Move the parameter from the method to the constructor
The constructor (FacilityAssertion) is currently only called from one place. So I add a new parameter in constructor, then a field in this class. When the method uses the “payment” field instead of the “payment” parameter – I can delete the unnecessary parameter.
Replace the constructor with a static method call
Next, in the FacilityAssertion class, I run the automatic code transformation “Replace constructor call with static method”.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | public class FacilityAssertion { private String payment; private FacilityAssertion(String payment) { this .payment = payment; } public static FacilityAssertion assertThat(String payment) { return new FacilityAssertion(payment); } void hasAttributes(String facilityName, String facilityPrice, String facilityPoints) { XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo(facilityName); XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo(facilityPrice); XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo(facilityPoints); } } |
Replace method with a method chain
Time to build a method chain. So I do the last extraction of a few new methods that will contain “return this” at their ends. This will allow me to make code refactoring of these methods into a call chain.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | public class FacilityAssertion { private String payment; private FacilityAssertion(String payment) { this .payment = payment; } public static FacilityAssertion assertThat(String payment) { return new FacilityAssertion(payment); } FacilityAssertion hasAttributes(String facilityName, String facilityPrice, String facilityPoints) { return hasName(facilityName) .hasPrice(facilityPrice) .hasPoints(facilityPoints); } FacilityAssertion hasPoints(String facilityPoints) { XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[3]" ) .isEqualTo(facilityPoints); return this ; } FacilityAssertion hasPrice(String facilityPrice) { XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[2]" ) .isEqualTo(facilityPrice); return this ; } FacilityAssertion hasName(String facilityName) { XmlAssert.assertThat( this .payment) .valueByXPath( "/table/tr[1]/td[1]" ) .isEqualTo(facilityName); return this ; } } |
Inline initial assertion method
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | @Test public void twoHours_isOnly_payEntryFee() { Facility beFitGym = new Facility( "Be Fit Gym" , Facility.GYM); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // when client.addVisit(visit); String payment = client.getReceipt(); // Then assertThat(payment) .hasName( "Be Fit Gym" ) .hasPrice( "4.0" ) .hasPoints( "100" ); } @Test public void twoHours_PayForEach() { // Given Facility beFitGym = new Facility( "Jacuzzi" , Facility.STEAM_BATH); Visit visit = new Visit(beFitGym, 2 ); Client client = new Client( "Mike" ); // When client.addVisit(visit); String payment = client.getReceipt(); // Then assertThat(payment) .hasName( "Jacuzzi" ) .hasPrice( "10.0" ) .hasPoints( "150" ); } |
Use the builder or factory pattern analogously for the test setup
You’ve surely noticed that now the test configurations differ only in the type of facility and the visit duration. The returned facility name is always the same, so you can check it separately and only once.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | @Test public void twoHours_isOnly_payEntryFee() { // Given String payment = newPaymentFor(Facility.GYM, 2 ); // Then assertThat(payment) .hasPrice( "4.0" ) .hasPoints( "100" ); } @Test public void twoHours_PayForEach() { // Given String payment = newPaymentFor(Facility.STEAM_BATH, 2 ); // Then assertThat(payment) .hasPrice( "10.0" ) .hasPoints( "150" ); } |
As you can see, we refactored code above into clean tests. They have no code duplication and are easy to understand. Writing another test is also simple.
Libraries promoting the fluent builder pattern
Fluent assertion pattern is supported by testing libraries. One of them is asserjJ that works very well with JUnit. It follows fluent builder pattern and allow to create one assertion at a time. It facilitates writing one detailed message in case of test failure or returning a new nested assertion instance that checks more.
Take care of tests readability
Uncle Bob once said (or wrote), “treat your tests like a first-class citizen.” So take care of your tests by constantly refactoring them! Clean Code is also Clean Tests!
Remember that the concept of the refactoring pyramid and the SOLID principles are equally applicable in cleaning tests through refactoring.
Published on Java Code Geeks with permission by Wlodek Krakowski, partner at our JCG program. See the original article here: How to do test refactoring towards fluent assertion pattern? Opinions expressed by Java Code Geeks contributors are their own. |