Pruebas unitarias se han convertido en un estándar en el desarrollo. Existen muchas herramientas que pueden emplearse de diversas maneras. Este artículo muestra un par de consejos o, mejor dicho, prácticas recomendadas que han funcionado bien para mí.
En este artículo, aprenderás
- Cómo escribir pruebas unitarias limpias y legibles con JUnit y frameworks de aserciones
- Cómo evitar pruebas falsamente positivas en ciertos casos
- Qué evitar al escribir pruebas unitarias
No abusar de las comprobaciones de NPE
Todos tendemos a evitar el NullPointerException
en el código principal tanto como sea posible, ya que puede conducir a consecuencias desagradables. Creo que nuestra principal preocupación no es evitar NPE en las pruebas. Nuestro objetivo es verificar el comportamiento del componente probado de una manera limpia, legible y confiable.
Pésima práctica
Muchas veces en el pasado, he utilizado la aserción isNotNull
incluso cuando no era necesario, como en el ejemplo a continuación:
@Test
public void getMessage() {
assertThat(service).isNotNull();
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
Esta prueba produce errores como este:
java.lang.AssertionError:
Expecting actual not to be null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
Buena práctica
Aunque la aserción adicional isNotNull
no es realmente perjudicial, debería evitarse por las siguientes razones:
- No añade ningún valor adicional. Es solo más código para leer y mantener.
- El test falla de todos modos cuando
service
esnull
y vemos la verdadera causa raíz del fallo. El test aún cumple con su propósito. - El mensaje de error producido es incluso mejor con la aserción de AssertJ.
Vea la aserción de prueba modificada a continuación.
@Test
public void getMessage() {
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
La prueba modificada produce un error como este:
java.lang.NullPointerException: Cannot invoke "com.github.aha.poc.junit.spring.HelloService.getMessage()" because "this.service" is null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
Nota: El ejemplo se puede encontrar en SimpleSpringTest.
Aserción de Valores y No del Resultado
De vez en cuando, escribimos un test correcto, pero de una “mala” manera. Significa que el test funciona exactamente como se pretende y verifica nuestro componente, pero el fallo no proporciona suficiente información. Por lo tanto, nuestro objetivo es afirmar el valor y no el resultado de la comparación.
Mal Uso
Veamos un par de tales malas pruebas:
// #1
assertThat(argument.contains("o")).isTrue();
// #2
var result = "Welcome to JDK 10";
assertThat(result instanceof String).isTrue();
// #3
assertThat("".isBlank()).isTrue();
// #4
Optional<Method> testMethod = testInfo.getTestMethod();
assertThat(testMethod.isPresent()).isTrue();
Se muestran algunos errores de las pruebas anteriores a continuación.
#1
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
Buena Práctica
La solución es bastante sencilla con AssertJ y su API fluida. Todas las situaciones mencionadas anteriormente pueden reescribirse fácilmente como:
// #1
assertThat(argument).contains("o");
// #2
assertThat(result).isInstanceOf(String.class);
// #3
assertThat("").isBlank();
// #4
assertThat(testMethod).isPresent();
Los mismos errores mencionados anteriormente ahora proporcionan más valor.
#1
Expecting actual:
"Hello"
to contain:
"f"
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting blank but was: "a"
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
Nota: El ejemplo se puede encontrar en SimpleParamTests.
Agrupar Aserciones Relacionadas
La concatenación de aserciones y la indentación del código relacionado ayudan mucho en la claridad y legibilidad de las pruebas.
Malas Prácticas
Al escribir una prueba, podemos terminar con una prueba correcta pero menos legible. Imaginemos una prueba en la que queremos encontrar países y realizar estas verificaciones:
- Cuenta los países encontrados.
- Aserción de la primera entrada con varios valores.
Estas pruebas pueden parecerse a este ejemplo:
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result).hasSize(5);
var country = result.get(0);
assertThat(country.getName()).isEqualTo("Spain");
assertThat(country.getCities().stream().map(City::getName)).contains("Barcelona");
}
Buenas Prácticas
Aunque la prueba anterior es correcta, deberíamos mejorar mucho la legibilidad agrupando las aserciones relacionadas (líneas 9-11). El objetivo aquí es afirmar resultado
una vez y escribir muchas aserciones concatenadas según sea necesario. Vea la versión modificada a continuación.
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result)
.hasSize(5)
.singleElement()
.satisfies(c -> {
assertThat(c.getName()).isEqualTo("Spain");
assertThat(c.getCities().stream().map(City::getName)).contains("Barcelona");
});
}
Nota: El ejemplo se puede encontrar en CountryRepositoryOtherTests.
Prevenir Pruebas Exitosas Falsas Positivas
Cuando se utiliza cualquier método de afirmación con el argumento ThrowingConsumer
, entonces el argumento debe contener assertThat
en el consumidor también. De lo contrario, la prueba pasaría todo el tiempo, incluso cuando la comparación falla, lo que significa una prueba incorrecta. La prueba solo falla cuando una afirmación lanza una excepción RuntimeException
o AssertionError
. Supongo que está claro, pero es fácil olvidarlo y escribir la prueba incorrecta. Me sucede de vez en cuando.
Práctica Inofensiva
Imaginemos que tenemos un conjunto de códigos de país y queremos verificar que cada código cumple con alguna condición. En nuestro caso ficticio, queremos afirmar que cada código de país contiene el carácter “a”. Como puedes ver, es absurdo: tenemos códigos en mayúsculas, pero no estamos aplicando la insensibilidad de caso en la afirmación.
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> countryCode.contains("a"));
}
Sorprendentemente, nuestra prueba pasó con éxito.
Práctica Correcta
Como se mencionó al comienzo de esta sección, nuestra prueba puede corregirse fácilmente con un assertThat
adicional en el consumidor (línea 7). La prueba correcta debería ser así:
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));
}
Ahora la prueba falla como se esperaba con el mensaje de error correcto.
java.lang.AssertionError:
Expecting all elements of:
["CZ", "AT", "CA"]
to satisfy given requirements, but these elements did not:
"CZ"
error:
Expecting actual:
"CZ"
to contain:
"a"
(ignoring case)
at com.github.aha.sat.core.clr.AppleTest.assertValues(AppleTest.java:45)
Cadena de Afirmaciones
El último consejo no es realmente una práctica, sino más bien una recomendación. La API fluida de AssertJ debe ser utilizada para crear pruebas más legibles.
Afirmaciones No Encadenadas
Consideremos la prueba listLogs
, cuyo propósito es probar el registro de un componente. El objetivo aquí es verificar:
- Número afirmado de registros recopilados
- Afirmar la existencia de mensajes de registro
DEBUG
yINFO
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list ).hasSize(2);
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
});
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
Encadenamiento de Aserciones
Con la API fluida mencionada y el encadenamiento, podemos cambiar la prueba de esta manera:
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list )
.hasSize(2)
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
})
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
Nota: el ejemplo se puede encontrar en AppleTest.
Resumen y Código Fuente
El marco AssertJ proporciona mucha ayuda con su API fluida. En este artículo, se presentaron varios consejos e indicaciones para producir pruebas más claras y confiables. Tenga en cuenta que la mayoría de estas recomendaciones son subjetivas. Depende de las preferencias personales y el estilo de código.
El código fuente utilizado se puede encontrar en mis repositorios:
Source:
https://dzone.com/articles/hints-for-unit-testing-with-assertj