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:
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));45.0170.050 € 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 |
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
Discount están en una clase aparte, DiscountTest: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)); }}@Testdelante 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:
FALLA extraFivePercentFrom100(): expected: <85.0> but was: <90.0>OK tenPercentOff()Tests: 2, pasan: 1, fallan: 1El 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:
OK extraFivePercentFrom100()OK tenPercentOff()Tests: 2, pasan: 2, fallan: 0Cada 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
- DiscountTest tiene dos tests. JUnit ejecuta cada uno por separado y enciende una luz a su lado.
- Test 1, preparar: un precio de 50 € y un 10% de descuento.
- Actuar: Discount.apply(50, 10) devuelve 45.0.
- Comprobar: assertEquals compara el 45.0 esperado con el 45.0 devuelto. Son iguales, así que el test pasa.
- Test 2: exactamente 100 € y un 10%. Esperamos 85.0, porque desde 100 € hay otro 5%.
- La condición en el método es price > 100, y 100 no es mayor que 100. El método devuelve 90.0.
- Esperado 85.0, devuelto 90.0: el test falla y dice exactamente eso.
- Corriges la condición a price >= 100 y ejecutas los tests otra vez.
- Ahora 100 cumple la condición y el método devuelve 85.0.
- 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 ensrc/main/java. IntelliJ muestra una flecha verde junto a cada test y junto a la clase: haces clic y ves el resultado.
src/main/java/Discount.java el códigosrc/test/java/DiscountTest.java sus tests- JUnit se añade como dependencia. Con Maven esto va en
pom.xml, y los tests se ejecutan conmvn test:
<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
maina 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:
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)); }}3 por 1,2 en un double no es exactamente 3,6. Los números decimales se guardan en binario, y en las cuentas se pierde una parte diminuta. assertEquals para double compara de forma exacta, así que el test falla:
FALLA tea(): expected: <3.6> but was: <3.5999999999999996>OK coffee()Tests: 2, pasan: 1, fallan: 1assertEquals(3.6, Vat.withVat(3), 0.001) pasa, porque la diferencia es menor que 0,001.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:
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; }}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()); }}OK canPayWhatIsThere()OK withdrawTooMuch()Tests: 2, pasan: 2, fallan: 0assertTrueyassertFalsecomprueban 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:assertThrowslo 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 €:
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()); }}FALLA retirar 70 € deja 30 €: Faltan 10 €OK retirar 40 € deja 60 €Tests: 2, pasan: 1, fallan: 1JUnit 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:
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()); }}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
@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étodosLa 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»:
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)); }}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: 0Cada 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:
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)); }}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.
El test que falla dice: en un pedido de exactamente 50 €, el envío es de 4 €, y debería ser 0. Es decir, 50 no cumple la condición. total > 50 deja fuera el propio 50. Debería ser total >= 50. El test señala el caso, y el código muestra la causa.
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:
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")); }}- 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; }}FALLA codeIsCaseInsensitive(): expected: <10> but was: <0>FALLA knownCode(): expected: <10> but was: <0>OK unknownCode()Tests: 3, pasan: 1, fallan: 2- 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; }}OK codeIsCaseInsensitive()OK knownCode()OK unknownCode()Tests: 3, pasan: 3, fallan: 0- 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:
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:
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:
assertNotEquals(a, b) si son distintosassertSame(a, b) si es el mismo objeto (==), no solo igualesassertNotSame(a, b)assertNull(x), assertNotNull(x)assertArrayEquals(a, b) los arrays tienen los mismos elementosassertIterableEquals(a, b) dos listas con los mismos elementos en el mismo ordenassertLinesMatch(a, b) líneas de texto (también admite expresiones regulares)assertAll(() -> ..., () -> ...) ejecuta todas las comprobaciones e informa de todos los fallosassertTimeout(Duration.ofMillis(100), () -> ...)fail("mensaje") el test falla al instanteEjercicio
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 €:
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: 22. 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
InsufficientFundsExceptioncon 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:
class Grades { static double average(int[] grades) { int sum = 0; for (int g : grades) { sum += g; } return sum / grades.length; }}FALLA averageWithAFraction(): expected: <5.5> but was: <5.0>OK wholeAverage()Tests: 2, pasan: 1, fallan: 1Encuentra la causa y corrige el método, para que pasen los dos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: HashMap y HashSet · Clases anidadas · Lambdas · Streams
- Continúa en: Hilos · Reflexión y anotaciones · Las pruebas como red de seguridad · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Programas en el mundo real