Saltar al contenido principal

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:

  1. cargar las tareas de un archivo indicado al arrancar;
  2. ignorar con un aviso las líneas cuyo formato sea inválido;
  3. añadir una tarea pendiente con identificador único;
  4. marcar una tarea existente como completada;
  5. guardar el estado y mostrar cuántas tareas contiene;
  6. 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​

pom.xml
<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​

Tarea.java
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​

GestorTareas.java
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​

RepositorioTareas.java
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​

Main.java
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​

GestorTareasTest.java
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:

RepositorioTareasTest.java
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​

.gitignore
target/
*.log
README.md
# 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.jar existe;
  • la primera ejecución guarda una tarea y la segunda carga esa tarea y guarda dos;
  • tareas-prueba.txt contiene 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 un target/ 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 -jar aunque 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 IOException sin 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.