Un proyecto de verdad

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. elValorEsPrecioPorStock dice más que test1, y es lo que vas a leer cuando falle.
  • assertEquals con decimales lleva un tercer argumento, la tolerancia. Por lo que vimos en la lección 3: comparar double por igualdad exacta es pedir un disgusto.
  • assertThrows comprueba 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
Ese jar necesita que haya un Java instalado en la máquina donde se ejecute. Si quieres un ejecutable que no dependa de nada, están 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.