Proyecto: aplicación Java reproducible y probada
Vas a entregar Tareas Local, una aplicación de consola que guarda tareas en un archivo. Otra persona debe poder obtener el código, construirlo, probarlo, ejecutarlo y diagnosticar un error usando solo el repositorio.
Este proyecto integra Java 4 sin añadir base de datos, red, concurrencia ni frameworks.
Requisitos
La aplicación debe:
- cargar las tareas de un archivo indicado al arrancar;
- ignorar con un aviso las líneas cuyo formato sea inválido;
- añadir una tarea pendiente con identificador único;
- marcar una tarea existente como completada;
- guardar el estado y mostrar cuántas tareas contiene;
- rechazar datos de dominio inválidos mediante excepciones claras.
La entrega incluirá estructura Maven, versiones fijadas, pruebas, logging, Javadoc y README. El criterio principal será este comando desde un checkout limpio:
mvn clean verify
Diseño antes de programar
Separaremos responsabilidades:
Main ── coordina el caso de uso
├── GestorTareas ── aplica reglas y mantiene la colección
├── RepositorioTareas ── lee y escribe el archivo
└── Tarea ── representa el dominio
El archivo usa una línea por tarea:
1|false|Preparar la entrega
2|true|Revisar las pruebas
No es un formato universal: es una decisión pequeña y documentada para practicar persistencia local.
Estructura final
tareas-local/
├── pom.xml
├── README.md
├── .gitignore
└── src/
├── main/java/es/skilly/tareas/
│ ├── Main.java
│ ├── Tarea.java
│ ├── GestorTareas.java
│ └── RepositorioTareas.java
└── test/java/es/skilly/tareas/
├── GestorTareasTest.java
└── RepositorioTareasTest.java
1. Build reproducible
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>es.skilly</groupId>
<artifactId>tareas-local</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.14.2</junit.version>
<slf4j.version>2.0.17</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>es.skilly.tareas.Main</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.11.2</version>
</plugin>
</plugins>
</build>
</project>
Cada dependencia tiene una función: SLF4J define la API de logging, slf4j-simple la implementa al ejecutar y JUnit solo participa en las pruebas.
2. Modelo de dominio
package es.skilly.tareas;
/** Representa una tarea persistida por la aplicación. */
public class Tarea {
private final long id;
private final String descripcion;
private boolean completada;
/**
* Crea una tarea.
*
* @param id identificador positivo
* @param descripcion texto no vacío
* @param completada estado inicial
* @throws IllegalArgumentException si los datos no son válidos
*/
public Tarea(long id, String descripcion, boolean completada) {
if (id <= 0) {
throw new IllegalArgumentException("El id debe ser positivo");
}
if (descripcion == null || descripcion.isBlank()) {
throw new IllegalArgumentException("La descripción es obligatoria");
}
this.id = id;
this.descripcion = descripcion;
this.completada = completada;
}
public long getId() {
return id;
}
public String getDescripcion() {
return descripcion;
}
public boolean isCompletada() {
return completada;
}
public void completar() {
completada = true;
}
}
La clase protege sus invariantes al construirse. No permite cambiar el identificador ni la descripción después.
3. Reglas y colección
package es.skilly.tareas;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
/** Mantiene tareas únicas y aplica sus operaciones de dominio. */
public class GestorTareas {
private final Map<Long, Tarea> tareas = new LinkedHashMap<>();
/**
* Añade una tarea si su identificador no existe.
*
* @param tarea tarea que se incorpora
* @throws IllegalArgumentException si es nula o su id ya existe
*/
public void anadir(Tarea tarea) {
if (tarea == null) {
throw new IllegalArgumentException("La tarea es obligatoria");
}
if (tareas.containsKey(tarea.getId())) {
throw new IllegalArgumentException("El id ya existe: " + tarea.getId());
}
tareas.put(tarea.getId(), tarea);
}
/**
* Marca una tarea existente como completada.
*
* @param id identificador buscado
* @throws NoSuchElementException si el id no existe
*/
public void completar(long id) {
Tarea tarea = tareas.get(id);
if (tarea == null) {
throw new NoSuchElementException("No existe la tarea " + id);
}
tarea.completar();
}
public List<Tarea> listar() {
return new ArrayList<>(tareas.values());
}
}
listar() devuelve una lista nueva: quien la recibe puede recorrerla sin acceder al mapa interno.
4. Persistencia local y diagnóstico
package es.skilly.tareas;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Lee y escribe tareas en un archivo de texto. */
public class RepositorioTareas {
private static final Logger logger =
LoggerFactory.getLogger(RepositorioTareas.class);
/**
* Carga las líneas válidas de un archivo.
*
* @param ruta archivo de tareas
* @return tareas válidas; lista vacía si el archivo aún no existe
* @throws IOException si el archivo existe pero no puede leerse
*/
public List<Tarea> cargar(Path ruta) throws IOException {
if (Files.notExists(ruta)) {
logger.info("Todavía no existe el archivo {}", ruta);
return new ArrayList<>();
}
List<Tarea> tareas = new ArrayList<>();
int numeroLinea = 0;
for (String linea : Files.readAllLines(ruta)) {
numeroLinea++;
String[] partes = linea.split("\\|", 3);
if (partes.length != 3) {
logger.warn("Línea {} ignorada: formato inválido", numeroLinea);
continue;
}
try {
long id = Long.parseLong(partes[0]);
if (!partes[1].equals("true") && !partes[1].equals("false")) {
throw new IllegalArgumentException("Estado inválido");
}
boolean completada = Boolean.parseBoolean(partes[1]);
tareas.add(new Tarea(id, partes[2], completada));
} catch (IllegalArgumentException error) {
logger.warn("Línea {} ignorada: {}", numeroLinea, error.getMessage());
}
}
logger.info("Carga completada: {} tareas", tareas.size());
return tareas;
}
/**
* Sobrescribe el archivo con el estado recibido.
*
* @param ruta archivo de destino
* @param tareas tareas que se guardan
* @throws IOException si no puede escribirse
*/
public void guardar(Path ruta, List<Tarea> tareas) throws IOException {
List<String> lineas = tareas.stream()
.map(tarea -> tarea.getId() + "|" + tarea.isCompletada()
+ "|" + tarea.getDescripcion())
.toList();
Path directorio = ruta.getParent();
if (directorio != null) {
Files.createDirectories(directorio);
}
Files.write(ruta, lineas);
logger.info("Guardado completado: {} tareas en {}", tareas.size(), ruta);
}
}
Una línea dañada no detiene toda la carga: se registra con WARN. Un fallo real de entrada/salida conserva su IOException para que la coordinación decida qué hacer.
5. Caso de uso ejecutable
package es.skilly.tareas;
import java.io.IOException;
import java.nio.file.Path;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Main {
private static final Logger logger = LoggerFactory.getLogger(Main.class);
public static void main(String[] args) {
Path ruta = Path.of(args.length > 0 ? args[0] : "tareas.txt");
RepositorioTareas repositorio = new RepositorioTareas();
GestorTareas gestor = new GestorTareas();
try {
for (Tarea tarea : repositorio.cargar(ruta)) {
gestor.anadir(tarea);
}
long nuevoId = gestor.listar().stream()
.mapToLong(Tarea::getId)
.max()
.orElse(0) + 1;
gestor.anadir(new Tarea(nuevoId, "Revisar proyecto Java 4", false));
repositorio.guardar(ruta, gestor.listar());
System.out.println("Tareas guardadas: " + gestor.listar().size());
} catch (IOException error) {
logger.error("No se pudo trabajar con el archivo {}", ruta, error);
System.err.println("No se pudieron guardar las tareas");
}
}
}
La salida funcional informa al usuario; el log conserva contexto técnico y stack trace. La ruta se recibe como argumento para poder comprobar la aplicación sin depender de un archivo personal.
6. Pruebas del dominio
package es.skilly.tareas;
import java.util.NoSuchElementException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class GestorTareasTest {
private GestorTareas gestor;
@BeforeEach
void preparar() {
gestor = new GestorTareas();
}
@Test
void anadeUnaTareaValida() {
gestor.anadir(new Tarea(1, "Probar", false));
assertEquals(1, gestor.listar().size());
}
@Test
void rechazaUnIdentificadorDuplicado() {
gestor.anadir(new Tarea(1, "Primera", false));
assertThrows(IllegalArgumentException.class,
() -> gestor.anadir(new Tarea(1, "Duplicada", false)));
}
@Test
void completaUnaTareaExistente() {
gestor.anadir(new Tarea(1, "Probar", false));
gestor.completar(1);
assertTrue(gestor.listar().get(0).isCompletada());
}
@Test
void avisaSiLaTareaNoExiste() {
assertThrows(NoSuchElementException.class, () -> gestor.completar(99));
}
}
Las pruebas cubren camino válido y errores sin depender del orden global de ejecución.
7. Pruebas de persistencia
@TempDir entrega un directorio aislado que JUnit limpia después:
package es.skilly.tareas;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.*;
class RepositorioTareasTest {
@TempDir
Path directorio;
@Test
void guardaYCargaLasMismasTareas() throws Exception {
Path archivo = directorio.resolve("tareas.txt");
RepositorioTareas repositorio = new RepositorioTareas();
List<Tarea> originales = List.of(
new Tarea(1, "Probar", false),
new Tarea(2, "Documentar", true)
);
repositorio.guardar(archivo, originales);
List<Tarea> cargadas = repositorio.cargar(archivo);
assertAll(
() -> assertEquals(2, cargadas.size()),
() -> assertEquals("Probar", cargadas.get(0).getDescripcion()),
() -> assertTrue(cargadas.get(1).isCompletada())
);
}
@Test
void ignoraUnaLineaInvalidaYConservaLasValidas() throws Exception {
Path archivo = directorio.resolve("tareas.txt");
Files.write(archivo, List.of("línea rota", "3|false|Corregir"));
List<Tarea> tareas = new RepositorioTareas().cargar(archivo);
assertEquals(1, tareas.size());
assertEquals(3, tareas.get(0).getId());
}
@Test
void creaLosDirectoriosPadreAlGuardar() throws Exception {
Path archivo = directorio.resolve("datos/tareas.txt");
RepositorioTareas repositorio = new RepositorioTareas();
repositorio.guardar(archivo, List.of(
new Tarea(1, "Preparar la entrega", false)
));
assertTrue(Files.exists(archivo));
List<Tarea> cargadas = repositorio.cargar(archivo);
assertEquals(1, cargadas.size());
assertEquals("Preparar la entrega", cargadas.get(0).getDescripcion());
}
}
Estas pruebas usan el sistema de archivos real, pero nunca una ruta del equipo del alumno.
8. Archivos de entrega
target/
*.log
# Tareas Local
Aplicación de consola construida con Java 17 y Maven 3.9 o compatible.
## Estructura
- `src/main/java`: aplicación y persistencia;
- `src/test/java`: pruebas JUnit;
- `pom.xml`: build, dependencias y plugins;
- `target/`: resultados regenerables.
## Verificar desde cero
```bash
mvn clean verify
```
## Ejecutar
```bash
mvn compile exec:java -Dexec.args="datos/tareas.txt"
```
La primera ejecución crea el archivo. Cada ejecución posterior carga las tareas
existentes y añade una nueva.
Cada línea de datos usa `id|completada|descripcion`, por ejemplo:
```text
1|false|Preparar la entrega
```
## Limpiar
```bash
mvn clean
```
## Generar Javadoc
```bash
mvn javadoc:javadoc
```
Abre `target/reports/apidocs/index.html`. `target/` no se versiona.
Validación de entrega
Simula lo que hará otra persona:
git clone URL_DEL_REPOSITORIO tareas-local
cd tareas-local
mvn clean verify
mvn compile exec:java -Dexec.args="tareas-prueba.txt"
mvn compile exec:java -Dexec.args="tareas-prueba.txt"
mvn javadoc:javadoc
Comprueba los resultados:
- siete pruebas ejecutadas, cero fallos y cero errores;
target/tareas-local-1.0.0.jarexiste;- la primera ejecución guarda una tarea y la segunda carga esa tarea y guarda dos;
tareas-prueba.txtcontiene dos líneas válidas;- los eventos relevantes aparecen en el log;
- Javadoc genera
target/reports/apidocs/index.html; - tras
mvn clean verify, el resultado no depende de untarget/anterior.
Para comprobar el error controlado, pasa como argumento una ruta que no pueda escribirse. La aplicación debe mostrar un mensaje breve al usuario y registrar la excepción completa, sin cerrar con un stack trace sin contexto.
Mini reto
Añade una prueba para una línea cuyo estado sea quizas. Debe ignorarse aunque Boolean.parseBoolean("quizas") devolvería false.
Solución
@Test
void ignoraUnEstadoQueNoEsBooleano() throws Exception {
Path archivo = directorio.resolve("tareas.txt");
Files.write(archivo, List.of("1|quizas|Revisar"));
List<Tarea> tareas = new RepositorioTareas().cargar(archivo);
assertTrue(tareas.isEmpty());
}
La comprobación explícita de true y false en el repositorio impide convertir silenciosamente un dato inválido.
Errores habituales
- Ejecutar Maven fuera del directorio que contiene
pom.xml. - Usar
java -jaraunque este proyecto no genera un JAR ejecutable con sus dependencias. - Guardar datos de prueba en una ruta personal en vez de usar
@TempDir. - Ocultar una
IOExceptionsin mensaje ni log, perdiendo la causa del fallo. - Versionar
target/o confiar en clases compiladas por una ejecución anterior. - Cambiar el formato del archivo sin actualizar repositorio, pruebas y README.
Ampliaciones dentro de Java 4
Puedes añadir una operación para listar solo tareas pendientes, más casos límite o niveles de log configurados mediante un recurso. Mantén el almacenamiento local, el mismo build y pruebas repetibles. Una base de datos, una API REST, concurrencia o un framework backend pertenecen a etapas posteriores.
Cierre del curso
Has pasado de ejecutar archivos sueltos a entregar un proyecto con estructura conocida, build reproducible, dependencias justificadas, pruebas automáticas, diagnóstico, logging y documentación. Esa base permite colaborar y cambiar el código con más confianza sin anticipar todavía bases de datos, servicios web ni frameworks backend.