La última lección junta todo lo anterior en un proyecto pequeño pero completo: paquetes, tres clases, cuatro pruebas con JUnit y un jar que se ejecuta. Primero a mano, para ver qué pasa, y al final con Maven, que es como se hace de verdad.
El proyecto
Hasta ahora todo cabía en un archivo. Un programa de verdad no, así que vamos a montar uno pequeño pero completo: un inventario con tres clases, cuatro pruebas y un ejecutable al final.
estructurainventario/
├── src/com/decodigo/inventario/
│ ├── Producto.java un record con validación
│ ├── Inventario.java la lógica
│ └── Main.java el punto de entrada
└── test/com/decodigo/inventario/
└── InventarioTest.java
Paquetes
Un paquete es un espacio de nombres, y en Java tiene una regla rígida: la ruta de carpetas tiene que coincidir con el nombre del paquete. Si la primera línea dice
package com.decodigo.inventario;
el archivo tiene que estar en com/decodigo/inventario/. No hay forma de saltárselo. La convención de usar un dominio al revés viene de garantizar que dos bibliotecas distintas nunca choquen.
Y aquí aparece la otra cara de public: lo que no lleva modificador solo se ve dentro del mismo paquete. Por eso en las clases del proyecto sí aparece public, y en los ejemplos sueltos de las lecciones anteriores no hacía falta.
package com.decodigo.inventario;
public record Producto(String nombre, double precio, int stock) {
public Producto {
if (nombre == null || nombre.isBlank())
throw new IllegalArgumentException("el nombre no puede estar vacío");
if (precio < 0) throw new IllegalArgumentException("precio negativo: " + precio);
if (stock < 0) throw new IllegalArgumentException("stock negativo: " + stock);
}
public double valor() { return precio * stock; }
public boolean hay() { return stock > 0; }
}
public class Inventario {
private final Map<String, Producto> porNombre = new LinkedHashMap<>();
public void anadir(Producto p) { porNombre.put(p.nombre(), p); }
public Optional<Producto> buscar(String nombre) {
return Optional.ofNullable(porNombre.get(nombre));
}
public List<Producto> disponibles() {
return porNombre.values().stream().filter(Producto::hay).toList();
}
public double valorTotal() {
return porNombre.values().stream().mapToDouble(Producto::valor).sum();
}
}
Ahí está casi todo el curso junto: un record con constructor compacto, un Map, Optional en vez de null, y dos streams.
Compilar y ejecutar
javac -d clases $(find src -name "*.java") java -cp clases com.decodigo.inventario.Main
lo que genera javacclases/com/decodigo/inventario/Inventario.class clases/com/decodigo/inventario/Main.class clases/com/decodigo/inventario/Producto.class
salida3 productos, 155.50 € en total disponibles: [gorra, mochila] gorra -> Producto[nombre=gorra, precio=12.5, stock=7] pajarita -> no hay
Dos opciones que hay que conocer: -d dice dónde dejar los .class —y javac recrea solo el árbol de carpetas del paquete—, y -cp (classpath) dice dónde buscarlos al ejecutar. Y al lanzar se da el nombre completo de la clase, no la ruta del archivo.
Pruebas con JUnit
JUnit no viene con el JDK; es una biblioteca aparte. Para hacerlo a mano basta con un único archivo, el lanzador de consola:
curl -LO https://repo1.maven.org/maven2/org/junit/platform/\ junit-platform-console-standalone/1.14.1/junit-platform-console-standalone-1.14.1.jar
package com.decodigo.inventario;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class InventarioTest {
@Test
void elValorEsPrecioPorStock() {
assertEquals(87.5, new Producto("gorra", 12.5, 7).valor(), 1e-9);
}
@Test
void rechazaElNombreVacio() {
var e = assertThrows(IllegalArgumentException.class, () -> new Producto(" ", 1, 1));
assertEquals("el nombre no puede estar vacío", e.getMessage());
}
@Test
void soloDevuelveLosQueTienenStock() {
var inv = new Inventario();
inv.anadir(new Producto("con", 1, 5));
inv.anadir(new Producto("sin", 1, 0));
assertEquals(1, inv.disponibles().size());
}
@Test
void buscarDevuelveVacioSiNoEsta() {
assertTrue(new Inventario().buscar("fantasma").isEmpty());
}
}
javac -cp clases:junit-platform-console-standalone-1.14.1.jar -d clases-test $(find test -name "*.java")
java -jar junit-platform-console-standalone-1.14.1.jar execute \
-cp clases:clases-test --select-package com.decodigo.inventario --details=summary
salidaTest run finished after 57 ms [ 4 tests found ] [ 4 tests started ] [ 4 tests successful ] [ 0 tests failed ]
Tres cosas de esas pruebas merecen un comentario:
- El nombre del método es la frase que describe lo que se comprueba.
elValorEsPrecioPorStockdice más quetest1, y es lo que vas a leer cuando falle. assertEqualscon decimales lleva un tercer argumento, la tolerancia. Por lo que vimos en la lección 3: comparardoublepor igualdad exacta es pedir un disgusto.assertThrowscomprueba el error, no solo el camino feliz. Es la mitad del trabajo que la gente se salta.
Y lo que sale cuando una falla, que es para lo que sirven:
salidaFailures (1):
JUnit Jupiter:FallaTest:aproposito()
=> org.opentest4j.AssertionFailedError: expected: <100.0> but was: <87.5>
Empaquetar
jar --create --file inventario.jar --main-class com.decodigo.inventario.Main -C clases .
java -jar inventario.jarinventario.jar 4624 bytes 3 productos, 155.50 € en total disponibles: [gorra, mochila]
Un .jar es un zip con los .class dentro y un manifiesto que dice cuál es la clase principal. Con --main-class se puede lanzar con java -jar y ya está:
jar --listMETA-INF/MANIFEST.MF com/decodigo/inventario/Inventario.class com/decodigo/inventario/Main.class com/decodigo/inventario/Producto.class
jlink —que monta un runtime mínimo con solo los módulos que usas— y la compilación nativa con GraalVM.Y en la vida real: Maven o Gradle
Todo lo anterior se hace a mano una vez, para entender qué está pasando. En un proyecto de verdad eso lo lleva una herramienta de construcción, que además resuelve las dependencias sola. Con Maven, el proyecto entero se describe así:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.decodigo</groupId>
<artifactId>inventario</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
mvn test # compila y pasa las pruebas mvn package # genera el jar en target/
A cambio hay que colocar el código donde Maven lo espera: src/main/java y src/test/java. Esa estructura es una convención tan extendida que cualquier herramienta de Java la reconoce, y por eso conviene adoptarla desde el principio aunque compiles a mano.
Hasta aquí
Con esto tienes lo que hace falta para leer y escribir Java actual: el sistema de tipos, las clases y los records, las interfaces selladas, las colecciones, las excepciones, los streams y un proyecto que compila, se prueba y se empaqueta.
Lo que viene después, cuando haga falta, suele ser: concurrencia —virtual threads, que llegaron en Java 21 y cambian bastante el panorama—, acceso a bases de datos, y algún marco de trabajo web como Spring Boot o Quarkus. Nada de eso es fácil sin lo de estas diez lecciones, y casi todo es asequible con ello.
Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.