Arma un pequeño ejecutor de pruebas solo con el JDK a partir de una anotación y reflexión, prueba código package-private con –patch-module y distribuye un servicio Java como JAR modular, imagen de runtime de jlink e instalador de jpackage.
Un servicio que pasa un smoke check no está terminado. Necesita pruebas que digan exactamente qué se rompió, y una forma de llegar a una máquina que quizá no tenga Java instalado. El JDK no trae un framework de pruebas, pero tiene todo lo que uno pequeño necesita. Para distribuir, tiene jar, jdeps, jlink y jpackage.
Esta última parte de la serie le agrega un arnés de pruebas (test harness) al servicio de tareas de la parte sobre HTTP, y después empaqueta ese módulo de tres maneras. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar tú mismo uno de los programas de un solo archivo, guárdalo como Main.java y ejecuta java Main.java. Las sesiones de terminal vienen del proyecto, y su script de verificación lo reconstruye todo solo con herramientas del JDK.
Qué hace un ejecutor de pruebas
Un ejecutor de pruebas (test runner) encuentra los métodos de prueba, ejecuta cada uno y cuenta cuáles pasaron y cuáles fallaron. JUnit hace mucho más, pero ese núcleo cabe en un archivo. Una anotación marca las pruebas, y la reflexión las encuentra:
@Retention(RetentionPolicy.RUNTIME)
@interface Test {}
static class CartTests {
int total(List<Integer> prices) {
return prices.stream().mapToInt(Integer::intValue).sum();
}
int withDiscount(int total, int percent) {
return total * ((100 - percent) / 100);
}
void check(boolean ok, String message) {
if (!ok) {
throw new AssertionError(message);
}
}
@Test
void emptyCartCostsNothing() {
check(total(List.of()) == 0, "an empty cart should cost 0");
}
@Test
void addsUpPrices() {
check(total(List.of(250, 199)) == 449, "250 + 199 should be 449");
}
@Test
void takesTenPercentOff() {
int price = withDiscount(449, 10);
check(price == 404, "expected 404 but was " + price);
}
}
void main() throws ReflectiveOperationException {
List<Method> tests = Arrays.stream(CartTests.class.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(Test.class))
.sorted(Comparator.comparing(Method::getName))
.toList();
int passed = 0;
int failed = 0;
for (Method test : tests) {
var instance = new CartTests();
try {
test.invoke(instance);
passed++;
IO.println("ok " + test.getName());
} catch (InvocationTargetException e) {
failed++;
IO.println("FAIL " + test.getName() + ": " + e.getCause().getMessage());
}
}
IO.println(passed + " passed, " + failed + " failed");
}
Imprime:
ok addsUpPrices
ok emptyCartCostsNothing
FAIL takesTenPercentOff: expected 404 but was 0
2 passed, 1 failed
La prueba que falla encontró un bug real: (100 - percent) / 100 es división entera, y 90 / 100 da 0. Esto es lo que hace cada parte del ejecutor:
@interface Testdeclara una anotación. Por sí sola no hace nada. Es una etiqueta que el ejecutor busca.getDeclaredMethods()devuelve todos los métodos de la clase, eisAnnotationPresentse queda solo con los etiquetados.total,withDiscountycheckno tienen etiqueta, así que no se ejecutan.new CartTests()se ejecuta una vez por prueba, así que ninguna prueba puede dejarle estado a la siguiente.invokeenvuelve lo que lance la prueba en unaInvocationTargetException.getCause()es el fallo real.
Las pruebas se ordenan por nombre antes de ejecutarse. El Javadoc de getDeclaredMethods dice que sus resultados “no están ordenados y no siguen ningún orden en particular”, así que un ejecutor que usara ese orden podría imprimir algo distinto en otra JVM. Ordenarlas hace que la salida sea la misma en cada ejecución.
No hay imports, porque un archivo fuente compacto importa todo java.base, y eso incluye java.lang.annotation y java.lang.reflect.
Sin @Retention(RUNTIME), el ejecutor no encuentra nada
Una anotación dura solo lo que dice su política de retención. La política por defecto, CLASS, escribe la anotación en el archivo de clase pero no la carga en tiempo de ejecución, así que la reflexión no la ve:
@interface Forgotten {}
@Retention(RetentionPolicy.RUNTIME)
@interface Kept {}
static class Checks {
@Forgotten
void first() {
}
@Kept
void second() {
}
}
void main() throws NoSuchMethodException {
for (String name : List.of("first", "second")) {
Method method = Checks.class.getDeclaredMethod(name);
IO.println(name + ": " + Arrays.toString(method.getAnnotations()));
}
}
Imprime:
first: []
second: [@Main.Kept()]
Quita la línea @Retention del primer programa y su ejecutor encuentra cero pruebas, imprime 0 passed, 0 failed y parece un éxito. Por eso el ejecutor del proyecto trata “no se encontraron pruebas” como un fallo.
El nombre Main.Kept es el archivo fuente compacto asomándose. Todo lo que se declara en el archivo queda anidado dentro de una clase llamada Main que nunca escribiste.
El arnés del proyecto: una anotación, no una lista de lambdas
Las pruebas del proyecto viven en una carpeta nueva, test, junto a src, en los mismos paquetes que el código que prueban:
19-tasks-service/
├── run-checks.sh
├── checks/
│ └── SmokeCheck.java
├── src/
│ └── com.example.tasks/ ...
└── test/
└── com/example/tasks/
├── testing/
│ ├── Assert.java
│ ├── Test.java
│ └── TestRunner.java
├── store/
│ └── InMemoryTaskStoreTest.java
└── http/
├── JsonTest.java
└── TaskServerTest.java
El diseño más simple es una lista de lambdas con nombre, como Map.entry("ids start at 1", () -> ...). No necesita reflexión, y ninguna política de retención puede esconder una prueba. Pero cada prueba nueva obliga a editar la lista, y una prueba que alguien olvida agregar nunca se ejecuta y nunca falla. Con una anotación, escribir el método ya es registrarlo. JUnit tomó la misma decisión.
La anotación es la del ejecutor de un solo archivo, con un @Target para que solo vaya en métodos:
package com.example.tasks.testing;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Marks a method as a test. The method takes no arguments and returns nothing. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
TestRunner recibe como argumentos los nombres de las clases de prueba. Para cada clase, hace lo mismo que la versión de un solo archivo:
private void runClass(Class<?> testClass) throws ReflectiveOperationException {
IO.println(testClass.getName());
Constructor<?> constructor = testClass.getDeclaredConstructor();
constructor.setAccessible(true);
for (Method method : testMethods(testClass)) {
// A new instance per test, so no test sees another test's fields.
Object instance = constructor.newInstance();
try {
method.invoke(instance);
passed++;
IO.println(" ok " + method.getName());
} catch (InvocationTargetException e) {
failed++;
IO.println(" FAIL " + method.getName() + ": " + describe(e.getCause()));
}
}
}
/** The @Test methods, sorted by name: getDeclaredMethods has no fixed order. */
private static List<Method> testMethods(Class<?> testClass) {
var methods = Arrays.stream(testClass.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(Test.class))
.sorted(Comparator.comparing(Method::getName))
.toList();
for (Method m : methods) {
if (m.getParameterCount() != 0 || Modifier.isStatic(m.getModifiers())) {
throw new IllegalStateException(m + ": a test takes no arguments and isn't static");
}
m.setAccessible(true);
}
return methods;
}
setAccessible(true) deja que el ejecutor llame a métodos de prueba y constructores que no son public. Eso está permitido porque el ejecutor y las pruebas están en el mismo módulo, algo que prepara la siguiente sección. Después de la última clase, main imprime los totales y llama a System.exit(1) si algo falló o no se ejecutó nada.
Assert contiene las verificaciones. assertEquals compara con Objects.equals y pone los dos valores en su mensaje. assertThrows devuelve la excepción, así que una prueba también puede verificar su mensaje:
/** Runs code, and returns what it threw if that is an instance of type. */
public static <T extends Throwable> T assertThrows(Class<T> type, Code code) {
try {
code.run();
} catch (Throwable thrown) {
if (type.isInstance(thrown)) {
return type.cast(thrown);
}
throw new AssertionError("expected " + type.getSimpleName() + " but "
+ thrown.getClass().getSimpleName() + " was thrown", thrown);
}
throw new AssertionError("expected " + type.getSimpleName() + " but nothing was thrown");
}
Probar código package-private con --patch-module
Json es package-private en el servicio, así que solo el código de com.example.tasks.http, dentro del módulo, puede llamarlo. Las pruebas podrían saltárselo y verificar el JSON solo a través de las respuestas HTTP. Pero entonces un bug en el código de escape aparece como un cuerpo de respuesta incorrecto, a tres capas de la causa. Así que las pruebas llaman a Json directamente.
Compilar una prueba en ese paquete, pero fuera del módulo, falla. Este es el primero de 14 errores:
$ javac -p out --add-modules com.example.tasks -d out/test test/com/example/tasks/http/JsonTest.java test/com/example/tasks/testing/*.java
test/com/example/tasks/http/JsonTest.java:1: error: package exists in another module: com.example.tasks
package com.example.tasks.http;
^
Un paquete pertenece a exactamente un módulo, y com.example.tasks.http ya pertenece a com.example.tasks. --patch-module agrega la carpeta de pruebas a ese módulo, para una compilación y una ejecución. El módulo compilado y el JAR que distribuyes no cambian.
$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ javac -Xlint:all -Werror --release 25 -d out/test -p out \
--patch-module com.example.tasks=test \
--add-modules java.net.http --add-reads com.example.tasks=java.net.http \
$(find test -name '*.java')
$ java -p out --patch-module com.example.tasks=out/test \
--add-modules java.net.http --add-reads com.example.tasks=java.net.http \
-m com.example.tasks/com.example.tasks.testing.TestRunner \
com.example.tasks.store.InMemoryTaskStoreTest \
com.example.tasks.http.JsonTest \
com.example.tasks.http.TaskServerTest 2> test-errors.log
com.example.tasks.store.InMemoryTaskStoreTest
ok concurrentCreatesGetDistinctIds
ok deleteRemovesOnlyThatTask
ok idsAreNotReusedAfterDelete
ok idsStartAtOneAndGoUp
ok listIsSortedById
ok titleMustNotBeNull
com.example.tasks.http.JsonTest
ok escapesQuotesBackslashesAndControlCharacters
ok readsEscapes
ok readsStringsAndBooleans
ok rejectsARepeatedField
ok rejectsMalformedJsonWithItsPosition
ok rejectsNumbers
ok whatItWritesItCanReadBack
ok writesATask
ok writesAnEmptyListAsBrackets
com.example.tasks.http.TaskServerTest
ok brokenStoreIs500WithoutDetails
ok createThenFollowLocation
ok wrongMethodIs405WithAllow
18 passed, 0 failed
Cada flag tiene una sola tarea:
--patch-module com.example.tasks=testcompila las fuentes de prueba como parte del módulo. En tiempo de ejecución,=out/testagrega sus clases.--add-reads com.example.tasks=java.net.httpdeja que el módulo leajava.net.http, que las pruebas HTTP necesitan paraHttpClient. Elrequirespropio del módulo no lo incluye. Sin este flag, javac reportapackage java.net.http is not visible, “but module com.example.tasks does not read it”.--add-modules java.net.httpagrega ese módulo a la compilación y a la ejecución, para empezar. Sin ninguno de los dos flags, la ejecución falló conNoClassDefFoundError: java/net/http/HttpClient.
test-errors.log recibió dos stack traces: el servicio registrando las respuestas 500 que una prueba provoca a propósito.
Pruebas del almacén: provocar una carrera a propósito
La mayoría de las pruebas del almacén (store) tienen pocas líneas cada una: los ids empiezan en 1, un id borrado no se reutiliza y un título null lanza una excepción. La prueba de concurrencia necesita más cuidado, porque 100 hilos que arrancan uno detrás de otro quizá nunca se solapen:
@Test
void concurrentCreatesGetDistinctIds() throws InterruptedException {
int threads = 100;
var start = new CountDownLatch(1);
Set<Long> ids = ConcurrentHashMap.newKeySet();
var workers = new ArrayList<Thread>();
for (int i = 0; i < threads; i++) {
workers.add(Thread.ofPlatform().start(() -> {
try {
start.await(); // every thread waits here until countDown below
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
ids.add(store.create("task", false).id());
}));
}
start.countDown(); // release all 100 threads at once
for (Thread worker : workers) {
worker.join();
}
assertEquals(threads, ids.size());
assertEquals(threads, store.list().size());
assertEquals(100L, store.list().getLast().id());
}
El CountDownLatch es una puerta de largada. Cada hilo se bloquea en start.await(), y el único countDown() libera los 100 juntos, así que sus llamadas a create se solapan todo lo que la máquina permite.
Una prueba que no puede fallar no demuestra nada, así que rompí el almacén a propósito. Reemplacé lastId.incrementAndGet() por una lectura y una escritura separadas: lastId.get() + 1 y después lastId.set(...). La prueba falló en 20 de 20 ejecuciones, con entre 68 y 99 ids distintos en lugar de 100.
El mismo truco atrapó una prueba inútil. Mi primera listIsSortedById creaba cinco tareas, borraba una y verificaba el orden. Borré la línea .sorted(...) de list(), y la prueba siguió pasando. Un ConcurrentHashMap pone una clave Long pequeña en el bucket que lleva el número de su valor, así que los ids que caben en la tabla salen en orden, estén ordenados o no. Ahora la prueba conserva solo los ids del 14 al 17, así que el mapa se queda en 16 buckets, donde los ids 16 y 17 caen en los buckets 0 y 1, antes que 14 y 15. Sin el ordenamiento, falla con expected <[14, 15, 16, 17]> but was <[16, 17, 14, 15]>.
Pruebas de Json, y un fallo a propósito
Las pruebas de Json están en com.example.tasks.http, así que llaman a métodos package-private como si fueran públicos:
/** Json is package-private, so this test lives in the same package, inside the module. */
class JsonTest {
@Test
void writesATask() {
assertEquals("{\"id\":7,\"title\":\"Buy milk\",\"done\":true}",
Json.task(new Task(7, "Buy milk", true)));
}
@Test
void writesAnEmptyListAsBrackets() {
assertEquals("[]", Json.tasks(List.of()));
}
@Test
void escapesQuotesBackslashesAndControlCharacters() {
assertEquals("\"say \\\"hi\\\" \\\\ tab\\t bell\\u0007 Zoë\"",
Json.string("say \"hi\" \\ tab\t bell\u0007 Zoë"));
}
Otras pruebas leen strings, escapes y booleanos, verifican la posición en malformed JSON at character 9, y convierten títulos a JSON y de vuelta. Para ver cómo se ve un fallo, borré la línea de Json.string que escribe un tab como \t, y volví a ejecutar los mismos comandos javac y java. Esta es la salida, recortada a las líneas que cambiaron:
com.example.tasks.http.JsonTest
FAIL escapesQuotesBackslashesAndControlCharacters: expected <"say \"hi\" \\ tab\t bell\u0007 Zoë"> but was <"say \"hi\" \\ tab\u0009 bell\u0007 Zoë">
...
17 passed, 1 failed
El ejecutor terminó con estado 1. Sin esa línea, un tab cae en la rama general de caracteres de control y sale como \u0009. Eso sigue siendo JSON válido, así que whatItWritesItCanReadBack pasó. Solo la prueba que compara la salida exacta notó el cambio.
Pruebas HTTP contra un servidor real, con un almacén roto
Las pruebas HTTP arrancan el servidor real en el puerto 0, como hace el smoke check, y lo llaman con HttpClient. La interesante prueba el camino del 500, que InMemoryTaskStore nunca toma. TaskStore es una interfaz, así que la prueba le pasa un almacén que falla:
/** A store that fails on every call, the way a store with a dead database would. */
private static final class BrokenStore implements TaskStore {
@Override
public List<Task> list() {
throw new IllegalStateException("connection to db.internal:5432 refused");
}
@Override
public Optional<Task> get(long id) {
return Optional.of(list().getFirst());
}
@Override
public Task create(String title, boolean done) {
return list().getFirst();
}
@Override
public boolean delete(long id) {
return !list().isEmpty();
}
}
@Test
void brokenStoreIs500WithoutDetails() throws Exception {
try (var server = TaskServer.start(new BrokenStore(), 0)) {
for (String method : List.of("GET", "DELETE")) {
var response = send(server, method, "/tasks/1", null);
assertEquals(500, response.statusCode());
assertEquals("{\"error\":\"internal server error\"}\n", response.body());
}
}
}
El mensaje de la excepción nombra el host y el puerto de una base de datos, y el cuerpo de la respuesta no. El error completo va al log, que es lo que terminó en test-errors.log. Un try con recursos detiene el servidor incluso cuando una aserción lanza una excepción.
Qué agrega JUnit
Un arnés escrito a mano muestra qué hace un ejecutor de pruebas, y ahí se queda. Los proyectos Java reales usan JUnit, y su API Jupiter es el estándar desde JUnit 5. Esto es lo que te da y el arnés no:
- Descubrimiento: busca pruebas en paquetes, carpetas y JARs. No hay una lista de nombres de clases que mantener al día.
- Ciclo de vida:
@BeforeEach,@AfterEach,@BeforeAlly@TempDirse encargan de la preparación y la limpieza, y las extensiones agregan más. - Pruebas parametrizadas:
@ParameterizedTestcon@ValueSourceo@CsvSourceejecuta un método con muchas entradas y reporta cada una por separado. - Mejores fallos:
assertAllreporta varias verificaciones fallidas a la vez, y hay timeouts,@Disabled, etiquetas y reportes que leen los servidores de CI. - Integración: Maven, Gradle y los IDEs principales ejecutan pruebas de JUnit, una por una o todas juntas, y muestran los resultados.
Un JAR modular que conoce su clase principal
Un JAR modular creado con --main-class guarda en su descriptor de módulo la clase con la que arranca, así que java solo necesita el nombre del módulo. La parte sobre módulos cubre los JARs modulares en general. Para el servicio, se ve así:
$ jar --create --file tasks.jar --main-class com.example.tasks.Main -C out/com.example.tasks .
$ jar --describe-module --file tasks.jar | tail -n +2
exports com.example.tasks.http
exports com.example.tasks.store
requires java.base mandated
requires jdk.httpserver
contains com.example.tasks
main-class com.example.tasks.Main
$ java -p tasks.jar -m com.example.tasks
listening on port 8080
La primera línea de --describe-module, que contiene la ruta completa del JAR, está recortada. Al presionar Ctrl+C se imprimió stopping y stopped. El JAR contiene 17 KB de tu código, y aun así necesita un runtime de Java 25 en la máquina que lo ejecuta.
jdeps: ¿qué módulos del JDK usa el código?
jdeps lee clases compiladas y reporta de qué dependen. --print-module-deps imprime los módulos del JDK como una lista separada por comas, lista para pasarle a jlink:
$ jdeps --print-module-deps tasks.jar
java.base,jdk.httpserver
$ jdeps --jdk-internals tasks.jar
El segundo comando lista los usos de APIs internas del JDK, que pueden romperse al actualizar. No imprimió nada, porque no hay ninguno.
Para este servicio modular, jlink lee las líneas requires por su cuenta. jdeps se gana su lugar con un JAR común que no tiene module-info.java. Ahí, --print-module-deps es la única forma de saber qué módulos necesita un runtime recortado.
jlink: un runtime de Java con solo lo que el servicio necesita
jlink arma una imagen de runtime: una carpeta con su propio bin/java y solo los módulos que nombras, más los módulos que esos requieren. Este es el comando para el servicio de tareas:
$ jlink --add-modules com.example.tasks --module-path out \
--launcher tasks=com.example.tasks/com.example.tasks.Main \
--strip-debug --no-header-files --no-man-pages --output image
$ ls image/bin
java
jwebserver
keytool
tasks
$ image/bin/java --list-modules
com.example.tasks
java.base@25.0.4
jdk.httpserver@25.0.4
$ du -sh image /usr/lib/jvm/java-25-openjdk-amd64
55M image
331M /usr/lib/jvm/java-25-openjdk-amd64
$ image/bin/tasks
listening on port 8080
Los tamaños son de esta máquina, un paquete de OpenJDK 25.0.4 para Ubuntu 24.04 en x86-64. Los tuyos van a variar. Las opciones hacen esto:
--add-modules com.example.taskses la raíz.jlinksigue losrequiresdesde ahí y encuentrajdk.httpserveryjava.base.--launcher tasks=module/classescribebin/tasks. Hay que nombrar la clase, porque el módulo compilado enoutno tiene una clase principal registrada. Con--module-path tasks.jar, basta contasks=com.example.tasks.--strip-debug,--no-header-filesy--no-man-pagesquitan la información de depuración, los headers de C y las páginas del manual. Sin ellas, la imagen ocupaba 60M. Agregar--compress zip-6la bajó a 42M, a costa de algo de tiempo de arranque.
jwebserver y keytool vienen incluidos porque jdk.httpserver y java.base los traen.
Explicado como si tuvieras diez años
Para pasar un fin de semana en casa de tu abuela, podrías llevarte todo tu ropero. Tendrías todo lo que pudieras necesitar, pero te haría falta un camión.
En cambio, miras el plan del viaje y armas una maleta pequeña: dos camisetas, pijama y un cepillo de dientes. La maleta es liviana, y tiene todo lo que este viaje necesita.
El JDK es el ropero. jlink lee la lista de lo que necesita tu módulo y empaca solo eso.
La versión precisa
El JDK está dividido en módulos, y en este JDK cada uno también viene como un archivo .jmod en la carpeta jmods. jlink resuelve el grafo de módulos a partir de los módulos raíz que le pasas, usando las líneas requires de cada module-info.class. Después enlaza las clases de todos los módulos de ese grafo en un único archivo lib/modules, y copia las bibliotecas nativas, la JVM y los launchers que esos módulos necesitan. La imagen no puede cargar un módulo que no esté en ella: image/bin/java --list-modules muestra tres, y para ese runtime no existe nada más.
Dónde falla la analogía: puedes elegir la ropa de un viaje adivinando, pero jlink no adivina. Empaca exactamente lo que dice requires. Si el código carga una clase por nombre en tiempo de ejecución, con reflexión o con una búsqueda de servicios, y ninguna línea requires nombra su módulo, jlink deja ese módulo afuera, y el programa falla cuando llega ahí. Además, una maleta sirve en cualquier lugar, pero la imagen no: contiene código nativo de Linux x86-64 enlazado contra glibc, así que solo corre en un sistema compatible.
Uso de disco medido con du -sh en esta máquina, dibujado a escala. El JDK incluye los 69 módulos y los archivos jmods que solo lee jlink. La imagen contiene com.example.tasks, jdk.httpserver y java.base.
El launcher de jlink no reenvía SIGTERM
El launcher que escribe jlink es un pequeño script de shell, y eso importa cuando algo detiene el servicio con una señal:
$ cat image/bin/tasks
#!/bin/sh
JLINK_VM_OPTIONS=
DIR=`dirname $0`
$DIR/java $JLINK_VM_OPTIONS -m com.example.tasks/com.example.tasks.Main "$@"
El script arranca java como proceso hijo y espera. No usa exec para convertirse en java. Cuando le mandé SIGTERM al proceso del script, el shell terminó con estado 143, y java siguió corriendo sin padre, todavía escuchando en su puerto. El shutdown hook nunca se ejecutó. Mandarle SIGTERM al proceso java en cambio imprimió stopping y stopped, como debe ser.
Un gestor de servicios o un contenedor le manda SIGTERM al proceso que arrancó, así que en esos casos arranca tú mismo bin/java -m com.example.tasks/com.example.tasks.Main en lugar de usar el launcher.
jpackage: una carpeta de aplicación o un .deb
jpackage envuelve una imagen de runtime y un launcher en algo que un sistema operativo instala: un .deb o .rpm en Linux, un .msi o .exe en Windows, y un .dmg o .pkg en macOS. Solo crea paquetes para el sistema en el que corre. En esta máquina funcionaron tanto una carpeta de aplicación como un paquete de Debian:
$ jpackage --type app-image --name tasks --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
$ ls dist/tasks/bin dist/tasks/lib
dist/tasks/bin:
tasks
dist/tasks/lib:
app
libapplauncher.so
runtime
tasks.png
$ jpackage --type deb --name tasks --app-version 1.0.0 --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
$ dpkg-deb --field dist/tasks_1.0.0_amd64.deb Package Version Depends Installed-Size
Package: tasks
Version: 1.0.0
Depends: libc6, libgcc-s1, libstdc++6, zlib1g
Installed-Size: 56077
$ ls -lh dist/*.deb | awk '{print $5, $9}'
14M dist/tasks_1.0.0_amd64.deb
$ jpackage --type rpm --name tasks --module-path out \
--module com.example.tasks/com.example.tasks.Main --dest dist
Error: Invalid or unsupported type: [rpm]
jpackage ejecutó jlink por su cuenta, y la carpeta de aplicación ocupó 55M, lo mismo que la imagen. Su bin/tasks es un programa nativo, no un script. Ejecuta la JVM dentro de su propio proceso, y un SIGTERM enviado a él ejecutó el shutdown hook. La compilación del .deb usó dpkg-deb y fakeroot, que ya estaban en esta máquina. Se instalaría bajo /opt/tasks, lo que requiere root, así que no lo instalé. Un .rpm necesita rpmbuild, que esta máquina no tiene.
Una imagen de contenedor a partir de la salida de jlink
Una imagen de jlink se copia a un contenedor como una sola carpeta, sin ningún JDK en la imagen final. Este archivo es ilustrativo. No lo construí ni lo ejecuté como parte de este post:
FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-25-jdk-headless
WORKDIR /src
COPY src src
RUN javac -d out --module-source-path src -m com.example.tasks \
&& jlink --add-modules com.example.tasks --module-path out \
--strip-debug --no-header-files --no-man-pages --output /opt/tasks
FROM ubuntu:24.04
COPY --from=build /opt/tasks /opt/tasks
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/opt/tasks/bin/java", "-m", "com.example.tasks/com.example.tasks.Main"]
La etapa final también está basada en glibc, porque el código nativo de la imagen necesita glibc, así que una imagen base de Alpine no podría ejecutarla. ENTRYPOINT arranca java directamente, por el motivo de SIGTERM que vimos arriba.
Qué verifica ahora run-checks.sh
El script de verificación del proyecto ahora compila y ejecuta todo lo de este post, y al final lo borra todo, incluida la imagen que arma en una carpeta temporal:
$ ./run-checks.sh
ok compiled com.example.tasks
ok smoke check: 45 checks passed
ok Main starts on port 0 and stops cleanly on SIGTERM
ok a bad port prints usage and exits 2
ok tests: 18 passed, 0 failed
ok java -p tasks.jar -m com.example.tasks starts and stops
ok jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed
Las verificaciones del JAR y de la imagen arrancan el servicio en el puerto 0 y lo detienen con SIGTERM, como la verificación de Main. Para el launcher, el script le manda la señal a su proceso hijo java.
Hacia dónde seguir
Esta serie se quedó dentro del JDK a propósito, para que pudieras ver cada pieza. Los proyectos reales agregan herramientas encima:
- Maven o Gradle para declarar dependencias, descargarlas y ejecutar el build, en lugar de un script de shell.
- JUnit en lugar del arnés de este post, con el mismo tipo de métodos de prueba.
- Jackson para leer y escribir JSON, en lugar de la clase
Jsonescrita a mano. - Spring Boot, Helidon o Micronaut para el ruteo, la configuración, el binding de JSON y las métricas en un servicio más grande.
- JDK Flight Recorder y JDK Mission Control para grabar e inspeccionar lo que hace una JVM en ejecución, desde la recolección de basura hasta los locks lentos.
Qué recordar
- Un ejecutor de pruebas es una anotación con
@Retention(RUNTIME), reflexión para encontrar los métodos, una instancia nueva por prueba y un conteo. Ordena los métodos por nombre, porque el orden de la reflexión no está especificado. - Trata cero pruebas encontradas como un fallo, y rompe el código a propósito para demostrar que cada prueba puede fallar.
- Para probar código package-private en un módulo, compila y ejecuta las pruebas con
--patch-module. Una prueba en el mismo paquete pero fuera del módulo falla conpackage exists in another module. - Usa un
CountDownLatchpara liberar muchos hilos a la vez cuando una prueba necesita que se solapen. jar --main-classguarda la clase principal en un JAR modular, así que basta conjava -p tasks.jar -m com.example.tasks.jdeps --print-module-depslista los módulos del JDK que usa el código.jlinkarma un runtime con solo los módulos que necesitas: 55M aquí, frente a 331M del JDK. Su launcher es un script que no reenvíaSIGTERM.jpackageconvierte el runtime en una carpeta de aplicación o un instalador nativo, para el sistema operativo en el que corre.
Pruébalo hasta confiar en él, y después distribuye solo lo que necesita.