Ir al contenido
wannacode

Tests con JUnit

En resumen: Un test unitario es código que comprueba un trozo pequeño, normalmente un método, con cada cambio; en Java se escribe con JUnit, @Test y assertEquals.

Problema

El descuento que se rompió en silencio

La regla de la tienda es: el descuento va en porcentaje, y en una compra de 100 € o más se suma otro 5%. Escribes el método y lo pruebas a mano:

Main.java
class Discount {
// Descuento en porcentaje. Desde 100 € se suma otro 5%.
static double apply(double price, int percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
}
System.out.println(Discount.apply(50, 10));
System.out.println(Discount.apply(200, 10));
Salida
45.0
170.0

50 € con un 10% son 45 €, y 200 € con un 15% son 170 €. Parece correcto y lo publicas. Una semana después, un cliente con una compra de exactamente 100 € paga 90 € en lugar de 85 €. La condición es price > 100, y debería ser price >= 100.

Nadie probó exactamente 100 €. Y con cada cambio en el método tienes que probar otra vez todos los casos a mano.

La idea

Código que comprueba código

En lugar de mirar la salida con tus propios ojos, escribes métodos pequeños que llaman a tu código y comparan el resultado con el esperado. Eso es un test. Ejecutas todos los tests con un solo clic, después de cada cambio, y te dicen qué se ha roto.

Tipo Qué comprueba Ejemplo
Test unitario un trozo pequeño, normalmente un método, separado del resto Discount.apply(100, 10) devuelve 85
De integración cómo funcionan las piezas juntas: con una base de datos, un archivo, otro servicio el pedido se guarda en la base de datos y se vuelve a leer
De extremo a extremo (end-to-end) toda la aplicación, tal como la usa una persona el cliente añade al carrito, paga y recibe un correo
En esta lección escribimos tests unitarios. En Java se usa para ellos la biblioteca JUnit.

Por qué vale la pena:

  • el error se ve enseguida, mientras aún recuerdas qué cambiaste, y no cuando el cliente se queja;
  • el test muestra cómo se usa el código: qué caso devuelve qué;
  • puedes ordenar el código sin miedo. Si los tests siguen en verde después del cambio, el comportamiento no ha cambiado.
Más precisamente, para curiosos

Hay otros tipos de tests: funcionales (si el programa cumple los requisitos), de rendimiento (cuántas peticiones aguanta), de seguridad (si tiene agujeros) y de interfaz de usuario (los botones, los formularios). Todos se apoyan en los tests unitarios: son los más numerosos, los más rápidos y los más baratos de mantener.

Míralo

El primer test

Los tests de Discount están en una clase aparte, DiscountTest:
DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
  • @Test delante del método le dice a JUnit «esto es un test, ejecútalo». Estas etiquetas con @ se llaman anotaciones. Ya has visto @Override. Cómo las encuentra JUnit lo verás en la última lección del módulo.
  • assertEquals(esperado, real) compara los dos valores. Si son distintos, el test falla.

Ejecutamos los dos tests contra el código de la primera tarjeta:

Resultado
FALLA extraFivePercentFrom100(): expected: <85.0> but was: <90.0>
OK tenPercentOff()
Tests: 2, pasan: 1, fallan: 1

El primer test pasa. El segundo falla y dice exactamente qué ha pasado: esperaba 85.0, y el método ha devuelto 90.0. Corriges la condición a price >= 100, los ejecutas otra vez y los dos están en verde:

Resultado
OK extraFivePercentFrom100()
OK tenPercentOff()
Tests: 2, pasan: 2, fallan: 0

Cada test hace tres cosas: prepara los valores, llama al código y comprueba el resultado.

class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
Paso a paso, en palabras
  1. DiscountTest tiene dos tests. JUnit ejecuta cada uno por separado y enciende una luz a su lado.
  2. Test 1, preparar: un precio de 50 € y un 10% de descuento.
  3. Actuar: Discount.apply(50, 10) devuelve 45.0.
  4. Comprobar: assertEquals compara el 45.0 esperado con el 45.0 devuelto. Son iguales, así que el test pasa.
  5. Test 2: exactamente 100 € y un 10%. Esperamos 85.0, porque desde 100 € hay otro 5%.
  6. La condición en el método es price > 100, y 100 no es mayor que 100. El método devuelve 90.0.
  7. Esperado 85.0, devuelto 90.0: el test falla y dice exactamente eso.
  8. Corriges la condición a price >= 100 y ejecutas los tests otra vez.
  9. Ahora 100 cumple la condición y el método devuelve 85.0.
  10. Esperado y devuelto coinciden. Los dos tests están en verde.
Más precisamente, para curiosos
  • En un proyecto real, los tests están en src/test/java, junto al código en src/main/java. IntelliJ muestra una flecha verde junto a cada test y junto a la clase: haces clic y ves el resultado.
Estructura
src/main/java/Discount.java el código
src/test/java/DiscountTest.java sus tests
  • JUnit se añade como dependencia. Con Maven esto va en pom.xml, y los tests se ejecutan con mvn test:
pom.xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
  • Aquí, en el sitio, los tests se ejecutan desde main a través de la interfaz de programación de JUnit (Launcher API). El método de abajo ejecuta las clases e imprime los resultados ordenados por nombre. Están ordenados porque JUnit no promete el orden de los tests:
El ejecutor
static void runTests(Class<?>... classes) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(Arrays.stream(classes).map(DiscoverySelectors::selectClass).toList())
.build();
List<String> lines = new ArrayList<>();
int[] failed = { 0 };
TestExecutionListener listener = new TestExecutionListener() {
@Override
public void executionFinished(TestIdentifier id, TestExecutionResult result) {
if (!id.isTest()) return;
if (result.getStatus() == TestExecutionResult.Status.SUCCESSFUL) {
lines.add("OK " + id.getDisplayName());
} else {
failed[0]++;
lines.add("FALLA " + id.getDisplayName() + ": " + result.getThrowable().map(Throwable::getMessage).orElse(""));
}
}
};
LauncherFactory.create().execute(request, listener);
Collections.sort(lines);
lines.forEach(System.out::println);
System.out.println("Tests: " + lines.size() + ", pasan: " + (lines.size() - failed[0]) + ", fallan: " + failed[0]);
}

Pruébalo

IVA con double

El precio con IVA es el precio por 1,2. Dos tests:

¿Qué mostrarán los tests?

class Vat {
static double withVat(double price) {
return price * 1.2;
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VatTest {
@Test
void coffee() {
assertEquals(12.0, Vat.withVat(10));
}
@Test
void tea() {
assertEquals(3.6, Vat.withVat(3));
}
}
Respuesta

La idea

Sí o no, y una excepción esperada

Además de la igualdad, a menudo compruebas una condición, o que el código se niega. Aquí está Account del módulo «Errores y excepciones», con un método nuevo, canPay:

Account.java
class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(int shortfall) {
super("Faltan " + shortfall + " €");
}
}
class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public boolean canPay(int amount) {
return amount <= balance;
}
public void withdraw(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("El importe debe ser positivo");
}
if (amount > balance) {
throw new InsufficientFundsException(amount - balance);
}
balance -= amount;
}
}
AccountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void canPayWhatIsThere() {
Account account = new Account(100);
assertTrue(account.canPay(100));
assertFalse(account.canPay(101));
}
@Test
void withdrawTooMuch() {
Account account = new Account(100);
InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
() -> account.withdraw(130));
assertEquals("Faltan 30 €", e.getMessage());
assertEquals(100, account.getBalance());
}
}
Salida
OK canPayWhatIsThere()
OK withdrawTooMuch()
Tests: 2, pasan: 2, fallan: 0
  • assertTrue y assertFalse comprueban una condición.
  • assertThrows(Tipo.class, código) falla si el código no lanza una excepción de ese tipo. El código se pasa como lambda: assertThrows lo ejecuta y captura la excepción. Después la devuelve, para que compruebes también el mensaje.

El segundo test comprueba también algo que se olvida fácilmente: después de la negativa, el saldo es el mismo.

La idea

Cada test empieza de cero

Aquí los dos tests comparten una cuenta con 100 €:

AccountTest.java
class AccountTest {
private static Account account = new Account(100);
@Test
@DisplayName("retirar 40 € deja 60 €")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("retirar 70 € deja 30 €")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Salida
FALLA retirar 70 € deja 30 €: Faltan 10 €
OK retirar 40 € deja 60 €
Tests: 2, pasan: 1, fallan: 1

JUnit ejecutó primero el test de 40 €. Al segundo le quedan 60 €, y retira 70 €. Si el orden fuera el contrario, fallaría el otro. Un test que depende de otro es una trampa. Por eso el objeto se crea de nuevo antes de cada test con @BeforeEach:

AccountTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
private Account account;
@BeforeEach
void newAccount() {
account = new Account(100);
}
@Test
@DisplayName("retirar 40 € deja 60 €")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("retirar 70 € deja 30 €")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Salida
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 2, pasan: 2, fallan: 0
@DisplayName le da al test un nombre con palabras. Se muestra en el resultado en lugar del nombre del método.
Más precisamente, para curiosos
Más anotaciones que te vas a encontrar:
Anotaciones
@AfterEach después de cada test (limpieza)
@BeforeAll una vez antes de todos los tests de la clase (método static)
@AfterAll una vez después de todos (método static)
@Disabled("...") el test no se ejecuta por ahora
@Timeout(5) el test falla si tarda más de 5 segundos
@Nested una clase anidada con un grupo de tests relacionados
@Tag("fast") una etiqueta, para ejecutar solo una parte de los tests
@RepeatedTest(5) el mismo test 5 veces
@ValueSource(ints = { 1, 2, 3 }) un valor por cada ejecución de un test parametrizado
@CsvFileSource(resources = "/data.csv") las filas vienen de un archivo
@MethodSource("prices") los argumentos vienen de un método static
@TempDir Path dir una carpeta temporal que se borra después del test
@ExtendWith(...), @RegisterExtension extensiones de JUnit
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
los nombres se crean a partir de los nombres de los métodos

La idea

Una comprobación, muchos casos

Cuatro casos para el descuento son cuatro métodos casi iguales. @ParameterizedTest ejecuta un método con valores distintos, y @CsvSource los da como filas «precio, porcentaje, esperado»:

DiscountTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@ParameterizedTest(name = "{0} € con {1}% = {2} €")
@CsvSource({
"50, 10, 45.0",
"99, 0, 99.0",
"100, 10, 85.0",
"200, 20, 150.0"
})
void apply(double price, int percent, double expected) {
assertEquals(expected, Discount.apply(price, percent));
}
}
Salida
OK 100 € con 10% = 85.0 €
OK 200 € con 20% = 150.0 €
OK 50 € con 10% = 45.0 €
OK 99 € con 0% = 99.0 €
Tests: 4, pasan: 4, fallan: 0

Cada fila es un test aparte con su propio resultado. name en @ParameterizedTest arma su nombre con los valores: {0} es el primero, {1} el segundo. Un caso nuevo es una fila nueva, no un método nuevo. Los casos importantes están en los bordes: exactamente 100 €, 99 €, 0%.

Pruébalo

Encuentra el error con el test

El envío cuesta 4 €, y desde 50 € es gratis. El test:

DeliveryTest.java
class DeliveryTest {
@ParameterizedTest(name = "pedido de {0} €: envío {1} €")
@CsvSource({ "20, 4", "50, 0", "80, 0" })
void price(int total, int expected) {
assertEquals(expected, Delivery.price(total));
}
}
Salida
FALLA pedido de 50 €: envío 0 €: expected: <0> but was: <4>
OK pedido de 20 €: envío 4 €
OK pedido de 80 €: envío 0 €
Tests: 3, pasan: 2, fallan: 1

¿En qué línea del código está el error?

Elige la línea con el error.

La idea

Primero el test

La caja tiene que aceptar el código promocional OTOÑO10 para un 10%, da igual si está en minúsculas o en mayúsculas. Esta vez escribes primero los tests:

PromoTest.java
class PromoTest {
@Test
void knownCode() {
assertEquals(10, Promo.percent("OTOÑO10"));
}
@Test
void codeIsCaseInsensitive() {
assertEquals(10, Promo.percent("otoño10"));
}
@Test
void unknownCode() {
assertEquals(0, Promo.percent("NINGUNO"));
}
}
  1. Rojo. Por ahora el método devuelve 0. Los tests fallan, y eso está bien: significa que de verdad comprueban algo.
class Promo {
static int percent(String code) {
return 0;
}
}
Salida
FALLA codeIsCaseInsensitive(): expected: <10> but was: <0>
FALLA knownCode(): expected: <10> but was: <0>
OK unknownCode()
Tests: 3, pasan: 1, fallan: 2
  1. Verde. Escribes el código más simple con el que pasan:
class Promo {
static int percent(String code) {
if (code.equalsIgnoreCase("OTOÑO10")) {
return 10;
}
return 0;
}
}
Salida
OK codeIsCaseInsensitive()
OK knownCode()
OK unknownCode()
Tests: 3, pasan: 3, fallan: 0
  1. Ordenar. Ahora puedes mover los códigos a un HashMap, añadir otros nuevos y ordenar el método. Los tests te avisarán si rompes algo.

Esto es TDD (test-driven development, desarrollo guiado por tests). Escribir primero el test te obliga a decidir qué tiene que hacer exactamente el método, antes de pensar en cómo.

Más precisamente

Suites y objetos falsos

Una suite es un grupo de clases de tests que se ejecutan juntas, por ejemplo todos los tests de la caja:

CheckoutSuite.java
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
import org.junit.platform.suite.api.SuiteDisplayName;
@Suite
@SuiteDisplayName("Tests de la caja")
@SelectClasses({ DiscountTest.class, AccountTest.class, DeliveryTest.class })
class CheckoutSuite {
}

Al ejecutarla, ejecuta los tests de las tres clases.

Más precisamente, para curiosos
  • A veces el código depende de algo que no quieres llamar en un test: el tipo de cambio de un banco, un pago, un correo. Entonces le pasas un objeto falso (mock) que devuelve lo que tú le digas. La biblioteca más conocida para esto es Mockito:
PriceInUsdTest.java
import static org.mockito.Mockito.*;
interface ExchangeRates {
double eurTo(String currency);
}
class PriceInUsdTest {
@Test
void convertsWithTodaysRate() {
ExchangeRates rates = mock(ExchangeRates.class);
when(rates.eurTo("USD")).thenReturn(1.10);
Converter converter = new Converter(rates);
assertEquals(11.0, converter.toUsd(10), 0.001);
verify(rates).eurTo("USD");
}
}

mock crea un objeto que implementa la interfaz, when(...).thenReturn(...) dice qué devolver, y verify comprueba que el método se llamó. Aquí, en el sitio, Mockito solo se compila: para ejecutarse necesita otra biblioteca más (byte-buddy).

  • Más comprobaciones además de assertEquals, assertTrue y assertThrows:
Comprobaciones
assertNotEquals(a, b) si son distintos
assertSame(a, b) si es el mismo objeto (==), no solo iguales
assertNotSame(a, b)
assertNull(x), assertNotNull(x)
assertArrayEquals(a, b) los arrays tienen los mismos elementos
assertIterableEquals(a, b) dos listas con los mismos elementos en el mismo orden
assertLinesMatch(a, b) líneas de texto (también admite expresiones regulares)
assertAll(() -> ..., () -> ...) ejecuta todas las comprobaciones e informa de todos los fallos
assertTimeout(Duration.ofMillis(100), () -> ...)
fail("mensaje") el test falla al instante

Ejercicio

Escribe los tests

Resuelve los ejercicios en tu editor, con JUnit 5 en el proyecto.

1. La calculadora de descuentos. Escribe DiscountTest para Discount.apply de la primera tarjeta. Cubre estos casos: 80 € con un 10% son 72 €, 100 € con un 0% son 95 €, 150 € con un 20% son 112,50 €, 0 € con un 30% son 0 € y exactamente 100 € con un 10% son 85 €. Ejecútalos también contra la versión rota con price > 100. Tienen que fallar exactamente los dos casos con 100 €:

Resultado contra la versión rota
FALLA 100 € con 0% = 95.0 €: expected: <95.0> but was: <100.0>
FALLA exactly100GetsTheExtra(): expected: <85.0> but was: <90.0>
OK 0 € con 30% = 0.0 €
OK 150 € con 20% = 112.5 €
OK 80 € con 10% = 72.0 €
Tests: 5, pasan: 3, fallan: 2

2. La cuenta. Escribe AccountTest para Account de la tarjeta «Sí o no, y una excepción esperada». Crea la cuenta con 100 € en @BeforeEach. Comprueba que:

  • retirar 30 € deja 70 €;
  • se puede retirar todo, y queda 0;
  • retirar 150 € lanza InsufficientFundsException con el mensaje «Faltan 50 €», y el saldo sigue en 100 €;
  • 0 y -20 € lanzan IllegalArgumentException (un test parametrizado con @ValueSource(ints = { 0, -20 })).

3. El error en la nota media. El método parece estar bien:

Grades.java
class Grades {
static double average(int[] grades) {
int sum = 0;
for (int g : grades) {
sum += g;
}
return sum / grades.length;
}
}
Escribe dos tests: la media de 4 y 6 es 5,0, y la media de 5 y 6 es 5,5 (con una diferencia permitida de 0,001). Uno de ellos falla:
Resultado
FALLA averageWithAFraction(): expected: <5.5> but was: <5.0>
OK wholeAverage()
Tests: 2, pasan: 1, fallan: 1

Encuentra la causa y corrige el método, para que pasen los dos.

Test

Ponte a prueba

1. ¿Qué comprueba un test unitario?
2. El test falla con «expected: <85.0> but was: <90.0>». ¿Qué devolvió el método?
3. ¿Por qué la cuenta se crea en @BeforeEach y no una sola vez en un campo?
4. ¿Cómo compruebas que withdraw(130) lanza InsufficientFundsException?
5. ¿Cuál es el orden en TDD?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·