TEST UNITARIOS CON JAVA Y MAVEN
1.MARCO TEÓRICO
Las Pruebas de Caja Negra, constituyen una técnica de pruebas de software en para comprobar y verificar la funcionalidad de una aplicación sin tener en cuenta la implementación o estructura interna de código, así como los escenarios de ejecución (donde se va a ejecutar).
En estas pruebas, no hace falta conocer la estructura interna del programa ni su funcionamiento. Su busca la obtención de casos de prueba que demuestren que las salidas que devuelve la aplicación son las esperadas en función de las entradas que se proporcionen.
A este tipo de pruebas también se les llama prueba de comportamiento.
Con ellas intentamos encontrar errores de las siguientes categorías:
- Funcionalidades incorrectas o ausentes.
- Errores de interfaz.
- Errores en estructuras de datos o en accesos a bases de datos externas.
- Errores de rendimiento.
- Errores de inicialización y finalización.
TEST UNITARIOS
Las pruebas o tests unitarios buscan verificar el comportamiento de una unidad específica, como una función o un método de clase, de forma aislada de otras partes del sistema. El objetivo es validar que cada unidad del software funciona según lo diseñado. Esto se hace proporcionando entradas conocidas a la unidad y comprobando que se reciben las salidas esperadas.
Algunas de las principales ventajas de las pruebas unitarias son
- Detectar errores en una fase temprana del ciclo de desarrollo, lo que reduce el coste de su corrección.
- Garantizar que el código funciona según lo previsto a medida que se realizan cambios.
- Proporcionar documentación viva sobre el funcionamiento del código.
- Ayudar a escribir código más modular y fácil de mantener.
- Mejorar la calidad del código y reducir la deuda técnica a lo largo del tiempo.
Algunas de las características que debemos tener en cuenta para elaborar unas pruebas unitarias de calidad, sería acosejable seguir estas buenas prácticas:
- Tratar de conseguir pruebas legibles y fáciles de mantener. Las pruebas poco claras son casi tan malas como la ausencia de pruebas.
- Las pruebas deben ser pequeñas y específicas. Cada prueba debe verificar un comportamiento.
- Las pruebas deben estar aisladas. Evite las dependencias entre pruebas.
- Las pruebas deben ser deterministas. Ejecutar una prueba con los mismos datos debe devolver siempre el mismo resultado.
- Debemos usar nombres claros y descriptivos para los métodos de prueba. Los nombres de las pruebas deben describir el escenario que se está probando.
- Es aconsejable separar las pruebas del código de producción para mantener la modularidad del código.
- Deberíamos ejecutar las pruebas con frecuencia, idealmente cada vez que hagamos cambios en el código.
Para hacer nuestros test unitarios, podemos usar las librerías integradas que vienen por defecto en los IDE o bien importar nuestras propias librerías para realizar los test.
1. REQUISITOS PREVIOS: PROYECTOS MAVEN
Maven es una herramienta de gestión y construcción de proyectos para Java. Su propósito principal es facilitar la gestión de dependencias, la compilación, la ejecución de pruebas y la creación de paquetes de manera eficiente y estandarizada.
1.1 Características principales
- Gestión de dependencias: Permite incluir fácilmente bibliotecas externas sin necesidad de descargarlas manualmente.
- Estructura estandarizada de proyectos: Define una organización común para los proyectos Java.
- Automatización de procesos: Facilita la compilación, pruebas y empaquetado de aplicaciones.
- Repositorio central: Almacena las bibliotecas y plugins utilizados en el proyecto.
- Uso de archivos POM (Project Object Model): Define la configuración del proyecto en un archivo XML.
1.2. Estructura de un proyecto Maven
Maven organiza los proyectos de una manera particular. Para generar un proyecto Maven debemos ejecutar en la paleta de comandos
Maven: New Project
Y elegir "maven-archetype-quickstart":
Dentro las versiones, elegimos la última estable (a día de hoy, la 1.4) y damos nombre al group-id
que desarrolla el proyecto, que suele ser la URL de la empresa en notación inversa:
Ahora damos nombre al artifact
a desarrollar, esto es, el nombre del proyecto:
Y elegimos la carpeta donde se guardará.
Ahora Visual Studio Code ejecuta una serie de comandos que se descargan las librerías necesarias y nos hace una serie de preguntas para configurar el proyecto. Esto se puede ver en la parte inferior del proyecto:
Podemos pulsar Intro para aceptar los valores por defecto.
Estos comando generará una estructura de proyecto con las siguientes carpetas:
|-- pom.xml (Archivo de configuración de Maven)
|-- src (Código fuente)
| |-- main
| | `-- java
| | `-- com
| | `-- mycompany
| | `-- app
| | `-- App.java
| `-- test (Pruebas unitarias)
| `-- java
| `-- com
| `-- mycompany
| `-- app
| `-- AppTest.java
`-- target (Carpeta de clases compiladas)
2.1. Archivo pom.xml
El archivo pom.xml
es el núcleo de un proyecto Maven. Contiene información como:
- Nombre y versión del proyecto.
- Dependencias necesarias.
- Configuración de compilación y empaquetado.
Ejemplo de pom.xml
básico:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ejemplo</groupId>
<artifactId>mi-proyecto</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
</project>
Dentro de las etiquetas de <project>
, debemos insertar las dependencias necesarias. Para nuestros test serán las siguientes:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.11.4</version>
</dependency>
</dependencies>
Hemos optado por crear un proyecto Maven para que el proceso instalar las dependencias necesarias para realizar los test sea independiente del IDE y el sistema operativo que usemos.
Para conocer mas sobre los proyectos Maven también podemos visitar la página web de Apache, el creador oficial de la herramienta:
https://maven.apache.org/guides/getting-started/
2.2. DEPENDENCIAS
Aparte de las librerías para realizar los test, Maven permite instalar multitud de librerías a través de las dependencias. Podemos encontrar las dependencias necesarias en los dos repositorios más famosos:
-
REPOSOTORIO SONATYPE. Es el repositorio recomendado por el desarrollador oficial, con las últimas actualizaciones del plugin.
-
REPOSOTORIO MAVEN. Es el repositorio de Maven creado por un desarrollador particular con una gran cantidad de plugins.
3. JUNIT
JUnit es una librería para realizar pruebas unitarias automatizadas. Está integrada en Eclipse y Visual Studio Code, por lo que no es necesario descargarse ningún paquete para poder usarla.
Hay problemas con su integración en Netbeans, pero hay soluciones no oficiales por internet.
La versión actual es la 5 (JUPITER) y tiene algunos cambios respecto a las versiones anteriores, por lo que es recomendable que todo el mundo use esta versión, si no, habrá problemas a la hora de corregir los ejercicios y/o exámenes.
Para encontrar más información sobre las novedades de JUNIT 5, lo mejor es visitar la web oficial:
3.1. MÉTODOS DE JUNIT PARA EJECUTAR TEST
Algunos de los métodos que nos sirven para probar nuestras aplicaciones son los siguientes:
-
assertEquals(String mensaje, valorEsperado, valorReal)
: Comprueba que el valorEsperado sea igual al valorReal. Si no son iguales y se incluye el String, entonces se lanzará el mensaje. ValorEsperado y valorReal pueden ser de diferentes tipos. -
assertTrue(String mensaje, boolean expresión)
: Comprueba que la expresión se evalúe a true. Si no es true y se incluye el String, al producirse error se lanzará el mensaje. -
assertFalse(String mensaje, boolean expresión)
: Comprueba que la expresión se evalúe a false. Si no es false y se incluye el String, al producirse error se lanzará el mensaje. -
assertNull(String mensaje, Object objeto)
: Comprueba que el objeto sea null. Si no es null y se incluye el String, al producirse error se lanzará el mensaje. -
assertNotNull(String mensaje, Object objeto)
: Comprueba que el objeto no sea null. Si es null y se incluye el String, al producirse error se lanzará el mensaje. -
assertSame(String mensaje, Object objetoEsperado, Object objetoReal)
: Comprueba que objetoEsperado y objetoReal sean el mismo objeto. Si no son el mismo y se incluye el String, al producirse error se lanzará el mensaje. -
assertNotSame(String mensaje, Object objetoEsperado, Object objetoReal)
: Comprueba que objetoEsperado y objetoReal no sean el mismo objeto. Si son el mismo y se incluye el String, al producirse error se lanzará el mensaje. -
fail(String mensaje)
: Hace que la prueba falle. Si se incluye un String la prueba falla lanzando el mensaje.
3.2. ANOTACIONES
Por último, debemos conocer un concepto que vamos a utilizar durante el desarrollo de nuestras pruebas: las anotaciones.
Las anotaciones son, según la wikipedia, son una forma de añadir metadatos al código fuente Java, y se pueden añadir a los elementos de programa tales como clases, métodos, metadatos, campos, parámetros, variables locales, y paquetes.
Para una información más detallada, podéis leer este útil enlace:
https://jarroba.com/annotations-anotaciones-en-java/
Junit, en su versión 5 (Jupiter) dispone de una serie de anotaciones que permiten complementar y ofrecer más información sobre las pruebas, asi como ejecutar código antes y después de las pruebas. Algunas de las más usadas son:
-
@BeforeEach
: si anotamos un método con esta etiqueta, el código será ejecutado antes de cualquier método de prueba. Podemos usarlo, por ejemplo, en una aplicación de acceso a base de datos para preparar la base de datos. -
@AfterEach
: Se pone en métodos cuyo código será ejecutado después de la ejecución de cada uno de los métodos de prueba. Se puede utilizar para limpiar datos. Puede haber varios métodos en la clase de prueba con estas dos anotaciones.
Existe otras anotaciones que permiten ejecutar código y afectan a la clase en sí. Veamos:
-
@BeforeAll
: El método marcado con esta anotación es invocado una vez al principio del lanzamiento de todas las pruebas. Se suele utilizar para inicializar atributos comunes a todas las pruebas o para realizar acciones que tardan un tiempo considerable en ejecutarse. Tanto los atributos modificados como la clase se deben definir comostatic
. -
@Afterall
: Este método será invocado cuando finalicen todas las pruebas. Se puede utilizar para limpiar los atributos de la clase u otras tareas finales.
Solamente puede haber un método en la clase de prueba con estas dos anotaciones.
3.3. CARACTERÍSTICAS DE LOS TEST
Para probar nuestra aplicación, debemos crear tantos test como métodos tenga la clase a probar, con las siguientes características:
- Los métodos son públicos, no devuelven nada y no reciben ningún argumento.
- El nombre de cada método es recomendable que se llame de la misma manera que el original o bien que vaya precedido de la palabra test (ej: testSuma(), testResta(), testMultiplica(), testDivide()) .
- Encima de cada uno de los métodos aparece la anotación
@Test
que indica al compilador que es un método de prueba. - Para los test parametrizados, incluiremos las anotaciones pertinentes.
Dentro de cada método de test, debemos seguir siempre el mismo procedimiento:
- Creamos una instancia de la clase con los valores que nos interese.
- Invocamos al método que queremos testar.
- Comprobamos que el valor obtenido coincide con el valor deseado. Para ello hacemos uso de los métodos que nos ofrece la Librería JUnit, vistos en el apartado anterior.
4. CREANDO NUESTRO PRIMER TEST
4.1. CREACIÓN DE LA CLASE A TESTEAR
Para comenzar nuestro ejemplo, en primer lugar debemos crear nuestra clase sobre la que vamos a realizar los test.
Para este ejemplo básico, hemos creado esta clase calculadora que incluye cuatro métodos y el constructor, que recibe dos números como entrada.
La clase calculadora queda así:
package es.ieslosalbares.pruebas;
public class Calculadora {
private final int num1;
private final int num2;
public Calculadora(int a, int b) {
num1 = a;
num2 = b;
}
public int suma() {
int resul = num1 + num2;
return resul;
}
public int resta() {
int resul = num1 - num2;
return resul;
}
public int multiplica() {
int resul = num1 * num2;
return resul;
}
public int divide() {
int resul;
if (num2== 0) {
resul = 0;
}
else resul = num1 / num2;
return resul;
}
}
4.2. CREANDO EL TEST
Ahora vamos a crear una clase de prueba para verificar nuestra calculadora. En genera podremos crear la plantilla del test desde el menú de nuestro IDE o bien hacerlo desde cero.
Para el caso de VSCode, hacemos clic con el encima de la clase a probar y seleccionamos "Source Actions -> Generate Test":
Por defecto nos sale el archivo sobre el que estamos trabajando, pero podemos escribir otro diferente.
En la parte izquierda elegimos los tipos de métodos sobre los que queremos generar los test y pulsamos "OK"
Lo que nos genera un nuevo archivo que se ubicará en la carpeta "test" de nuestro proyecto y que tendrá la siguiente estructura:
package es.ieslosalbares.pruebas;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculadoraTest {
public CalculadoraTest() {
}
@Test
public void testSuma() {
}
@Test
public void testResta() {
}
@Test
public void testMultiplica() {
}
@Test
public void testDivide() {
}
}
Vemos una estructura similar a una clase Java ordinaria.
Como muestra vamos a comenzar creando el test para el método suma()
. Debemos crear un objeto de la clase Calculadora y luego llamar al método suma()
. Luego comprobamos que el valor devuelto coincide con el valor esperado. Se ha incluido la anotación DisplayName
que muestra el mensaje del método al realizar el test. Para poder incluir esa anotación, debemos importar la librería DisplayName
.
import org.junit.jupiter.api.DisplayName;
y creamos el test mediante el procedimiento explicado anteriormente:
@Test
@DisplayName("1 + 1 = 2")
void sumarDosNumeros() {
Calculator calculadora = new Calculadora(1, 1);
assertEquals(2, calculadora.suma(), "1 + 1 debe ser igual a 2");
}
El procedimiento sería similar para el resto.
Para pasar el test, hacemos clic encima del triángulo derecho que está a la izquierda test que queremos ejecutar:
y en la ventana de resultados podemos ver que el test se pasa satisfactoriamente si aparece el círculo verde a la izquierda del test.
Ahora podemos crear una batería de pruebas para probar otro método de esta clase. Para ello vamos usar la anotación @ParameterizedTest para indicar que es un test con parámetros. Al usar este tipo de test, debemos incluir una segunda anotación para indicar de dónde vamos a obtener los valores que vamos a pasar al método. Existen muchas formas de generar valores, y dependerá del método a probar su elección. Aqui hemos optado por una serie de valores CSV (Valores Separados por Coma), que es la mas sencilla de manejar.
El código quedaría así:
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"0, 1, -1",
"2, 1, 1",
"237, 22, 215",
"1, 100, -99"
})
void resta(int num1, int num2, int resultadoEsperado) {
Calculadora calculadora = new Calculadora(num1 , num2);
assertEquals(resultadoEsperado, calculadora.resta(),
() -> num1 + " - " + num2 + " debe ser igual a " + resultadoEsperado);
}
Podéis ver un interesante tutorial sobre test parametrizados en los siguientes enlaces:
Si completamos los demás métodos, el test final quedaría así:
package es.ieslosalbares.pruebas;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
public class CalculadoraTest {
@Test
@DisplayName("1 + 1 = 2")
void sumarDosNumeros() {
Calculadora calculadora = new Calculadora(1, 1);
assertEquals(2, calculadora.suma(), "1 + 1 debe ser igual a 2");
}
@ParameterizedTest(name = "{0} - {1} = {2}")
@CsvSource({
"0, 1, -1",
"2, 1, 1",
"237, 22, 215",
"1, 100, -99"
})
void resta(int num1, int num2, int resultadoEsperado) {
Calculadora calculadora = new Calculadora(num1 , num2);
assertEquals(resultadoEsperado, calculadora.resta(),
() -> num1 + " - " + num2 + " debe ser igual a " + resultadoEsperado);
}
@ParameterizedTest(name = "{0} * {1} = {2}")
@CsvSource({
"0, 1, 0",
"2, 1, 2",
"10, 22, 220",
"12, 11, 132"
})
public void multiplica(int num1, int num2, int resultadoEsperado) {
Calculadora calculadora = new Calculadora(num1 , num2);
assertEquals(resultadoEsperado, calculadora.multiplica(),
() -> num1 + " * " + num2 + " debe ser igual a " + resultadoEsperado);
}
@ParameterizedTest(name = "{0} / {1} = {2}")
@CsvSource({
"0, 1, 0",
"2, 1, 2",
"24, 6, 4",
"111, 10, 11"
})
public void divide(int num1, int num2, int resultadoEsperado) {
Calculadora calculadora = new Calculadora(num1 , num2);
assertEquals(resultadoEsperado, calculadora.divide(),
() -> num1 + " / " + num2 + " debe ser igual a " + resultadoEsperado);
}
}
Al ejecutar el test, vemos que todas las pruebas pasan correctamente.