Patrón Builder para mejorar semántica en los test y quitar ruido
Por
Rubén Zamora
El patrón Builder es de lo más conocidos para instanciar objetos de manera más fácil, y yo, en mi caso, es al patrón que más recurro a la hora de hacer test para mantener una legibilidad mayor sin ensuciar mucho el código.
Para entendernos, tomaré un código de una aplicación que tengo a mano para explicarlo con ejemplos.
Así, comenzaría un simple test con un objeto que tiene un método de factoría, porque los campos tienen validaciones, como que no pueden estar vacíos o nulos.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class ClientDetailsShould {
@Test
public void throw_exception_when_did_not_find_a_field_in_clients_details() {
Throwable exception = assertThrows(FieldInClientDetailsNotFound.class, () -> {
ClientDetails.with(
null,
"García García",
"",
newAddress("C/ Falsa 123", "01240", "Spain", "Canary Island")
);
});
assertEquals("Didn't find the client details field name = null, documentNumber = ", exception.getMessage());
}
}
|
Os dejo por aquí el la clase ClientDetails por si queréis echarle un ojo 👀:
1
2
3
4
5
6
7
8
9
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
|
public class ClientDetails {
public final String name;
public final String lastName;
public final String documentNumber;
public final Address address;
private ClientDetails(String name, String lastName, String documentNumber, Address address) {
this.name = name;
this.lastName = lastName;
this.documentNumber = documentNumber;
this.address = address;
}
public static ClientDetails with(String name, String lastName, String documentNumber, Address address) {
validateFields(name, lastName, documentNumber, address);
return new ClientDetails(name, lastName, documentNumber, address);
}
public String address() { return address.address; }
public String postalCode() { return address.postalCode; }
public String country() { return address.country; }
public String region() { return address.region; }
private static void validateFields(String name, String lastName, String documentNumber, Address address) {
List<String> exceptions = new ArrayList<>();
if (isEmptyOrNull(name))exceptions.add("name = " + name);
if (isEmptyOrNull(lastName))exceptions.add("lastName = " + lastName);
if (isEmptyOrNull(documentNumber))exceptions.add("documentNumber = " + documentNumber);
if (isEmptyOrNull(address.toString()))exceptions.add("address = " + address);
if (has(exceptions)){
throw FieldInClientDetailsNotFound.with(exceptions);
}
}
private static boolean has(List<String> exceptions) { return exceptions.size() > 0; }
private static boolean isEmptyOrNull(String parameter) {
return parameter == null || parameter.isEmpty();
}
}
|
Aquí, ya sentimos que si no entramos al objeto a ver cómo se construye hay campos que no entendemos del todo bien, es decir, gastamos energía mental en intentar entenderlo, aunque en el assertEquals para verificar el mensaje ya nos puede dar una idea :thought_balloon:. Y para este caso son pocos parámetros los que necesito para construirla, pero imaginaos que tengo que meterle 20, ya se nos atraganta un poco.
Como he realizado el ejercicio de refactorizarlo para intentar dejarlo más claro, os lo comparto para sacar más idea común.
Al turrón !! 🏃♂️
Pasamos a crear una clase Builder de ClientDetails que estará en el directorio de los :file_folder:test, porque en productivo no voy a utilizar ningún builder para instanciar a ClientDetails.
Creamos a ClientDetailsBuilder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
public class ClientDetailsBuilder {
private String name = "Martina";
private String lastName = "García García";
private String documentNumber = "00000000T";
private Address address = address().build();
public ClientDetailsBuilder withName(String name) {
this.name = name;
return this;
}
public ClientDetailsBuilder withLastName(String lastName) {
this.lastName = lastName;
return this;
}
public ClientDetailsBuilder withDocumentNumber(String documentNumber) {
this.documentNumber = documentNumber;
return this;
}
public ClientDetailsBuilder withAddress(Address address) {
this.address = address;
return this;
}
public ClientDetails build() {
return ClientDetails.with(name,
lastName,
documentNumber,
address);
}
}
|
Y así nos quedaría ahora el test:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class ClientDetailsShould {
@Test
public void throw_exception_when_did_not_find_a_field_in_clients_details () {
Throwable exception = assertThrows(FieldInClientDetailsNotFound.class, () -> {
new ClientDetailsBuilder().withName(null)
.withDocumentNumber("")
.build();
});
assertEquals("Didn't find the client details field name = null, documentNumber = ", exception.getMessage());
}
}
|
Pero hay un último paso que haría, porque la coletilla de Builder me chirría un poco 😵💫, así que lo que haces es crearle un método estático para poder conseguir más semántica. Es decir, pasamos de esto:
1
2
3
4
|
new ClientDetailsBuilder()
.withName(null)
.withDocumentNumber("")
.build();
|
A esto (wait que todavía quedan pasos 🤓):
1
2
3
|
ClientDetailsBuilder.clientDetails().withName(null)
.withDocumentNumber("")
.build();
|
Tendremos que decirle ahora al IDE que nos importe el on-demand static (Alt + Enter sobre el objeto):
Para que finalmente nos quede el test así:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import static com.company.app.builders.ClientDetailsBuilder.clientDetails;
class ClientDetailsShould {
@Test
public void throw_exception_when_did_not_find_a_field_in_clients_details () {
Throwable exception = assertThrows(FieldInClientDetailsNotFound.class, () -> {
clientDetails()
.withName(null)
.withDocumentNumber("")
.build();
});
assertEquals("Didn't find the client details field name = null, documentNumber = ", exception.getMessage());
}
}
|
🙈 Él antes para comparar:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class ClientDetailsShould {
@Test
public void throw_exception_when_did_not_find_a_field_in_clients_details() {
Throwable exception = assertThrows(FieldInClientDetailsNotFound.class, () -> {
ClientDetails.with(
null,
"García García",
"",
newAddress("C/ Falsa 123", "01240", "Spain", "Canary Island")
);
});
assertEquals("Didn't find the client details field name = null, documentNumber = ", exception.getMessage());
}
}
|
Y la clase ClientDetailsBuilder final os la dejo por aquí:
1
2
3
4
5
6
7
8
9
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
|
public class ClientDetailsBuilder {
private String name = "Martina";
private String lastName = "García García";
private String documentNumber = "00000000T";
private Address address = address().build();
public static ClientDetailsBuilder clientDetails(){
return new ClientDetailsBuilder();
}
public ClientDetailsBuilder withName(String name) {
this.name = name;
return this;
}
public ClientDetailsBuilder withLastName(String lastName) {
this.lastName = lastName;
return this;
}
public ClientDetailsBuilder withDocumentNumber(String documentNumber) {
this.documentNumber = documentNumber;
return this;
}
public ClientDetailsBuilder withAddress(Address address) {
this.address = address;
return this;
}
public ClientDetails build() {
return ClientDetails.with(
name,
lastName,
documentNumber,
address
);
}
}
|
Conclusión
Creo que con esto hemos logrado quitar mucho ruido a los test y logramos mostrar realmente los únicos parámetros que han cambiado para provocar el fallo y se autoexplica que no acepta nulos o campos vacíos