07
Herencia e interfaces

Interfaces y composición

Herencia, polimorfismo e interfaces

Semana 7 — Interfaces y composición

Objetivos

  • Definir e implementar interfaces (interfaces) y usarlas como tipos.
  • Decidir entre interfaz y clase abstracta.
  • Elegir entre herencia y composición (composition): reconocer cuándo la herencia rompe el encapsulamiento y cuándo una subclase no es un buen sustituto.
  • Enunciar el principio de sustitución de Liskov y aplicarlo a ejemplos concretos.
  • Escribir test doubles a mano gracias a una interfaz.

Apuntes de la semana: notes/week-07.md · Código: code/week-07

Sesión A — Interfaces

Lecturas:

  • Eck, sec. 5.7 Interfaces, subsecciones 5.7.1 a 5.7.3.

Lecturas complementarias:

Temas:

  • Interfaz como contrato: un conjunto de métodos sin estado que una clase promete ofrecer. Ejemplo propio: interface Payable { long amountDue(); } implementada por una factura (Invoice) y por un empleado (el Employee de la semana 6), que no tienen ninguna superclase en común.
  • implements: una clase puede implementar varias interfaces pero extender una sola clase. Por qué Java evita la herencia múltiple de estado.
  • La interfaz como tipo: variables, parámetros y listas de tipo Payable. El dynamic dispatch de la semana 6 funciona igual.
  • Métodos default y static en interfaces: agregar comportamiento sin romper a quienes ya la implementan. Constantes en interfaces y por qué no abusar de ellas.
  • Comparable<T> como primer ejemplo de la API: una sola operación, compareTo, que permite ordenar. Ejemplo: record Song ordenado por duración. El orden y los comparadores se estudian en la semana 11.
  • ¿Interfaz o clase abstracta? Interfaz para una capacidad (“se puede pagar”, “se puede comparar”); clase abstracta para compartir estado y código entre parientes cercanos.
  • Error común: crear una interfaz por cada clase “por si acaso” (FooImpl implementa Foo y nadie más lo hará).

Sesión B — Composición vs. herencia · Sustitución de Liskov

Lecturas (Eck no trata estos temas):

  • B. Liskov y J. Wing, A Behavioral Notion of Subtyping, ACM TOPLAS 16(6), 1994. Leer la introducción y la sección 2 (motivación); el resto es formal y opcional.
  • Martin Fowler, catálogo de refactoring: Replace Superclass with Delegate.
  • API de Java 25: java.util.Stack, que extiende Vector: mirar la lista de métodos heredados.
  • Opcional: J. Bloch, Effective Java, 3.ª ed., ítem 18 (Favor composition over inheritance).

Temas:

  • Composición: un objeto tiene a otro como campo y le delega (delegation) trabajo. Ejemplo propio: una Playlist que tiene un ArrayList<Song> frente a una Playlist extends ArrayList<Song>, que hereda clear, set y todo lo demás, y ya no puede proteger su invariante.
  • La herencia acopla la subclase a los detalles de la superclase: si la superclase cambia cómo llama a sus propios métodos, la subclase puede romperse sin haber cambiado. Ejemplo: un CountingSet extends HashSet<String> que cuenta doble con addAll (superclase frágil), frente a su versión por composición.
  • Principio de sustitución de Liskov (LSP): todo lo que vale para un objeto del supertipo debe valer para uno del subtipo. Precondiciones que no se refuerzan, postcondiciones que no se debilitan, invariantes que se mantienen.
  • Casos clásicos: Square extends Rectangle con setters independientes de ancho y alto; java.util.Stack con get(i) y add(i, x) heredados, que permiten saltarse el orden LIFO del stack.
  • El ColorPoint de la semana 5, rehecho por composición (tiene un Point): equals vuelve a ser simétrico.
  • Regla práctica: herencia solo si la relación “es un” se cumple también en el comportamiento; si no, composición más una interfaz común.

Test doubles (para el laboratorio):

Laboratorio — Interfaces y test doubles

Código inicial en code/week-07/: Greeter y GreeterTest (pasos 1–3), UndoHistory y UndoHistoryTest (paso 4). Ejemplo resuelto de referencia en los apuntes: StockMonitor con un stub y un spy.

  1. Greeter.greet saluda según la hora real (LocalTime.now()), así que hoy no se puede probar qué saludo da a las 9 o a las 21. Extraer una interfaz para “la hora actual” y hacer que Greeter la reciba por el constructor.
  2. Escribir a mano, como clases que implementan esa interfaz, un stub (devuelve una hora fija) y un spy (recuerda cuántas veces se le preguntó la hora). Sin bibliotecas de mocks.
  3. Probar Greeter pasándole el test double por el constructor: una prueba por cada saludo y por cada borde (11:59, 12:00, 18:59, 19:00).
  4. UndoHistory extiende java.util.Stack. Convertirla en composición más delegación (Replace Superclass with Delegate) con UndoHistoryTest como red de seguridad, y agregar una prueba de algo que la versión heredada permitía y la nueva ya no debe permitir.

Entregas

  • P2 en curso (Catálogo). Entrega en semana 9. Las reglas de precio y descuento del catálogo se expresan como interfaces; el laboratorio muestra cómo probarlas con test doubles.

Preguntas de repaso

  1. ¿Por qué una clase puede implementar varias interfaces pero extender una sola clase? ¿Qué problema evita?
  2. ¿Qué gana una Playlist que tiene un ArrayList<Song> frente a una que lo extiende? ¿Qué pierde?
  3. Muestren con un método de prueba que Square extends Rectangle viola el principio de Liskov. ¿Qué expectativa del supertipo se rompe?
  4. ¿Qué diferencia hay entre un stub y un spy? ¿Por qué ambos necesitan una interfaz (o una clase no final) para existir?
  5. Agregar un método abstracto a una interfaz rompe a todas las clases que la implementan. ¿Cómo lo evita un método default? ¿Qué riesgo tiene?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (28)Pruebas (8)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

ColorPoint.javaDescargar ColorPoint.java
import java.util.Objects;

/**
 * Un punto con color por composición: TIENE un Point en lugar de SER uno.
 * Así cada clase compara solo objetos de su propio tipo y equals vuelve a ser
 * simétrico. Quien necesite el punto lo pide con asPoint().
 */
public final class ColorPoint {
    private final Point point;
    private final String color;

    public ColorPoint(int x, int y, String color) {
        this.point = new Point(x, y);
        this.color = color;
    }

    /** Vista del objeto como Point, para usarlo donde se espera un punto. */
    public Point asPoint() {
        return point;
    }

    public String color() {
        return color;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof ColorPoint cp && point.equals(cp.point) && color.equals(cp.color);
    }

    @Override
    public int hashCode() {
        return Objects.hash(point, color);
    }

    @Override
    public String toString() {
        return point + " " + color;
    }
}
ColorPointDemo.javaDescargar ColorPointDemo.java
// El caso Point/ColorPoint de la semana 5, resuelto con composición.
public class ColorPointDemo {
    public static void main(String[] args) {
        Point p = new Point(1, 2);
        ColorPoint red = new ColorPoint(1, 2, "rojo");
        ColorPoint blue = new ColorPoint(1, 2, "azul");

        IO.println("p.equals(red)   = " + p.equals(red));
        IO.println("red.equals(p)   = " + red.equals(p));
        IO.println("p.equals(red.asPoint()) = " + p.equals(red.asPoint()));
        IO.println("red.equals(blue) = " + red.equals(blue));
    }
}
ConsoleNotifier.javaDescargar ConsoleNotifier.java
/** Notificador real del ejemplo: imprime el aviso. */
public class ConsoleNotifier implements Notifier {
    @Override
    public void send(String message) {
        IO.println("[aviso] " + message);
    }
}
CountingSet.javaDescargar CountingSet.java
import java.util.Collection;
import java.util.HashSet;

/**
 * Cuenta cuántos elementos se intentó agregar, por herencia. Está mal:
 * HashSet.addAll llama a this.add por cada elemento, y ese add es el nuestro.
 */
@SuppressWarnings("serial")     // HashSet es Serializable; aquí no serializamos
public class CountingSet extends HashSet<String> {
    private int added = 0;

    @Override
    public boolean add(String s) {
        added++;
        return super.add(s);
    }

    @Override
    public boolean addAll(Collection<? extends String> c) {
        added += c.size();
        return super.addAll(c);      // ...que vuelve a llamar a add
    }

    public int added() {
        return added;
    }
}
DelegatingCountingSet.javaDescargar DelegatingCountingSet.java
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

/**
 * La misma cuenta por composición: tiene un Set y le delega. No depende de
 * cómo HashSet implemente addAll por dentro.
 */
public class DelegatingCountingSet {
    private final Set<String> items = new HashSet<>();
    private int added = 0;

    public boolean add(String s) {
        added++;
        return items.add(s);
    }

    public boolean addAll(Collection<String> c) {
        added += c.size();
        return items.addAll(c);      // items.add no es nuestro add: no cuenta doble
    }

    public boolean contains(String s) {
        return items.contains(s);
    }

    public int size() {
        return items.size();
    }

    public int added() {
        return added;
    }
}
Employee.javaDescargar Employee.java
/**
 * Copia de la jerarquía de la semana 6, ahora con una capacidad más:
 * todo empleado se puede pagar.
 */
public abstract class Employee implements Payable {
    private final String name;

    protected Employee(String name) {
        this.name = name;
    }

    public String name() {
        return name;
    }

    /** Pago del mes en céntimos; cada tipo de empleado lo calcula a su manera. */
    public abstract long monthlyPay();

    /** Lo que se debe a un empleado es su pago del mes. */
    @Override
    public long amountDue() {
        return monthlyPay();
    }

    /** Template Method: arma la boleta y deja el cálculo a la subclase. */
    public String payslip() {
        return name + ": " + formattedAmount();
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "[" + name + "]";
    }
}
FixedStockLevels.javaDescargar FixedStockLevels.java
/** Stub: responde siempre el mismo stock, sin base de datos ni inventario. */
public class FixedStockLevels implements StockLevels {
    private final int units;

    public FixedStockLevels(int units) {
        this.units = units;
    }

    @Override
    public int unitsOf(String product) {
        return units;
    }
}
FragileDemo.javaDescargar FragileDemo.java
import java.util.List;

// La superclase frágil: la subclase se rompe por un detalle interno de HashSet.
public class FragileDemo {
    public static void main(String[] args) {
        CountingSet inherited = new CountingSet();
        inherited.addAll(List.of("arroz", "azúcar", "aceite"));
        IO.println("herencia:    added() = " + inherited.added());

        DelegatingCountingSet composed = new DelegatingCountingSet();
        composed.addAll(List.of("arroz", "azúcar", "aceite"));
        IO.println("composición: added() = " + composed.added());
    }
}
Greeter.javaDescargar Greeter.java
import java.time.LocalTime;

/**
 * Laboratorio, código inicial: saluda según la hora. Depende de la hora real
 * (LocalTime.now()), así que una prueba no puede fijar qué saludo espera.
 */
public class Greeter {

    public String greet(String name) {
        int hour = LocalTime.now().getHour();
        String greeting;
        if (hour < 12) {
            greeting = "Buenos días";
        } else if (hour < 19) {
            greeting = "Buenas tardes";
        } else {
            greeting = "Buenas noches";
        }
        return greeting + ", " + name;
    }
}
HourlyEmployee.javaDescargar HourlyEmployee.java
public class HourlyEmployee extends Employee {
    private final long hourlyRate;      // en céntimos
    private final int hours;

    public HourlyEmployee(String name, long hourlyRate, int hours) {
        super(name);
        this.hourlyRate = hourlyRate;
        this.hours = hours;
    }

    @Override
    public long monthlyPay() {
        return hourlyRate * hours;
    }
}
InheritedPlaylist.javaDescargar InheritedPlaylist.java
import java.util.ArrayList;

/**
 * La misma idea por herencia: ES un ArrayList. Hace override de add(Song) para
 * cuidar la duración máxima, pero hereda decenas de métodos públicos que no la
 * cuidan (add(int, Song), addAll, set...).
 */
@SuppressWarnings("serial")     // ArrayList es Serializable; aquí no serializamos
public class InheritedPlaylist extends ArrayList<Song> {
    private final int maxSeconds;

    public InheritedPlaylist(int maxSeconds) {
        this.maxSeconds = maxSeconds;
    }

    @Override
    public boolean add(Song song) {
        if (totalSeconds() + song.seconds() > maxSeconds) {
            return false;
        }
        return super.add(song);
    }

    public int totalSeconds() {
        int sum = 0;
        for (Song s : this) {
            sum += s.seconds();
        }
        return sum;
    }
}
Invoice.javaDescargar Invoice.java
/** Una factura de un proveedor. No tiene nada que ver con los empleados. */
public class Invoice implements Payable {
    private final String number;
    private final long amount;      // en céntimos

    public Invoice(String number, long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + amount);
        }
        this.number = number;
        this.amount = amount;
    }

    public String number() {
        return number;
    }

    @Override
    public long amountDue() {
        return amount;
    }

    @Override
    public String toString() {
        return "Invoice[" + number + "]";
    }
}
LiskovDemo.javaDescargar LiskovDemo.java
// Un cliente escrito para Rectangle, usado con un Rectangle y con un Square.
public class LiskovDemo {

    /** Lo que cualquier cliente de Rectangle tiene derecho a esperar. */
    static void resize(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        IO.println(r.getClass().getSimpleName() + ": área " + r.area() + " (se esperaba 20)");
    }

    public static void main(String[] args) {
        resize(new Rectangle(2, 3));
        resize(new Square(2));
    }
}
Notifier.javaDescargar Notifier.java
/** A quién avisarle. En producción, un correo; en una prueba, un test double. */
public interface Notifier {
    void send(String message);
}
Payable.javaDescargar Payable.java
import java.util.List;

/** Algo que se puede pagar: una factura, el sueldo de un empleado... */
public interface Payable {

    /** Monto por pagar, en céntimos. */
    long amountDue();

    /** Monto con formato de soles. Las clases lo heredan sin escribir nada. */
    default String formattedAmount() {
        long cents = amountDue();
        return String.format("S/ %d.%02d", cents / 100, cents % 100);
    }

    /** Suma lo que se debe en una lista de cosas por pagar. */
    static long total(List<Payable> items) {
        long sum = 0;
        for (Payable p : items) {
            sum += p.amountDue();
        }
        return sum;
    }
}
PayableDemo.javaDescargar PayableDemo.java
import java.util.ArrayList;
import java.util.List;

// Una factura y dos empleados en la misma lista: lo único que tienen en común
// es la interfaz Payable.
public class PayableDemo {
    public static void main(String[] args) {
        List<Payable> bills = new ArrayList<>();
        bills.add(new Invoice("F001-120", 45_000));
        bills.add(new SalariedEmployee("alice", 320_000));
        bills.add(new HourlyEmployee("bob", 1_250, 80));

        for (Payable p : bills) {
            IO.println(p + " -> " + p.formattedAmount());
        }
        IO.println("Total: " + Payable.total(bills) + " céntimos");
    }
}
Playlist.javaDescargar Playlist.java
import java.util.ArrayList;
import java.util.List;

/**
 * Lista de reproducción por composición: tiene un ArrayList y le delega el
 * trabajo. Invariante: la duración total nunca pasa de maxSeconds.
 */
public class Playlist {
    private final int maxSeconds;
    private final ArrayList<Song> songs = new ArrayList<>();
    private int totalSeconds = 0;

    public Playlist(int maxSeconds) {
        this.maxSeconds = maxSeconds;
    }

    /** Agrega la canción si cabe; devuelve si se agregó. */
    public boolean add(Song song) {
        if (totalSeconds + song.seconds() > maxSeconds) {
            return false;
        }
        songs.add(song);                // delegación
        totalSeconds += song.seconds();
        return true;
    }

    public int totalSeconds() {
        return totalSeconds;
    }

    public int size() {
        return songs.size();            // delegación
    }

    /** Copia de solo lectura: quien la recibe no puede romper el invariante. */
    public List<Song> songs() {
        return List.copyOf(songs);
    }
}
PlaylistDemo.javaDescargar PlaylistDemo.java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

// Composición frente a herencia con la misma lista de reproducción de 10 minutos.
public class PlaylistDemo {
    public static void main(String[] args) {
        Song a = new Song("Contigo Perú", "Trío Malecón", 215);
        Song b = new Song("Valicha", "Grupo Garúa", 180);
        Song c = new Song("El cóndor pasa", "Los Rieleros", 260);

        // Comparable: compareTo dice cuál va primero.
        IO.println("a.compareTo(b) = " + a.compareTo(b));
        List<Song> sorted = new ArrayList<>(List.of(a, b, c));
        Collections.sort(sorted);
        IO.println("ordenadas: " + sorted);

        Playlist mine = new Playlist(600);
        IO.println("composición: " + mine.add(a) + " " + mine.add(b) + " " + mine.add(c)
                + " -> " + mine.totalSeconds() + " s");

        InheritedPlaylist bad = new InheritedPlaylist(600);
        IO.println("herencia:    " + bad.add(a) + " " + bad.add(b) + " " + bad.add(c)
                + " -> " + bad.totalSeconds() + " s");
        bad.add(0, c);                       // heredado: no pasa por el add que valida
        bad.addAll(List.of(c, c));           // heredado
        IO.println("después de add(0, c) y addAll: " + bad.totalSeconds() + " s de 600");
    }
}
Point.javaDescargar Point.java
/** Punto inmutable con igualdad de valor (el de la semana 5). */
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Point p && x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return 31 * x + y;
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}
Rectangle.javaDescargar Rectangle.java
/** Rectángulo con ancho y alto que se cambian por separado. */
public class Rectangle {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;           // campos directos: no llamar a los setters,
        this.height = height;         // de los que una subclase podría hacer override
    }

    /** Postcondición: cambia el ancho y deja el alto como estaba. */
    public void setWidth(int width) {
        this.width = width;
    }

    /** Postcondición: cambia el alto y deja el ancho como estaba. */
    public void setHeight(int height) {
        this.height = height;
    }

    public int width() {
        return width;
    }

    public int height() {
        return height;
    }

    public int area() {
        return width * height;
    }
}
SalariedEmployee.javaDescargar SalariedEmployee.java
public class SalariedEmployee extends Employee {
    private final long monthlySalary;   // en céntimos

    public SalariedEmployee(String name, long monthlySalary) {
        super(name);
        this.monthlySalary = monthlySalary;
    }

    @Override
    public long monthlyPay() {
        return monthlySalary;
    }
}
Song.javaDescargar Song.java
/** Una canción con su duración en segundos. Se ordena de la más corta a la más larga. */
public record Song(String title, String artist, int seconds) implements Comparable<Song> {

    public Song {
        if (seconds <= 0) {
            throw new IllegalArgumentException("duración no positiva: " + seconds);
        }
    }

    @Override
    public int compareTo(Song other) {
        return Integer.compare(seconds, other.seconds);
    }
}
SpyNotifier.javaDescargar SpyNotifier.java
import java.util.ArrayList;
import java.util.List;

/** Spy: no envía nada; anota cada mensaje para que la prueba lo revise. */
public class SpyNotifier implements Notifier {
    private final List<String> sent = new ArrayList<>();

    @Override
    public void send(String message) {
        sent.add(message);
    }

    public List<String> sent() {
        return List.copyOf(sent);
    }
}
Square.javaDescargar Square.java
/**
 * "Un cuadrado es un rectángulo" en geometría, pero no como Rectangle con
 * setters independientes: para seguir siendo cuadrado, cada setter cambia los
 * dos lados y rompe la postcondición del supertipo.
 */
public class Square extends Rectangle {

    public Square(int side) {
        super(side, side);
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}
StackDemo.javaDescargar StackDemo.java
import java.util.Stack;

// java.util.Stack extiende Vector: push, pop y peek respetan LIFO, pero hereda métodos
// que permiten a cualquier cliente saltarse ese orden.
public class StackDemo {
    public static void main(String[] args) {
        Stack<String> pages = new Stack<>();
        pages.push("inicio");
        pages.push("catálogo");
        pages.push("carrito");
        IO.println("stack: " + pages + ", peek = " + pages.peek());

        pages.add(0, "intrusa");        // heredado de Vector: inserta en el fondo
        String middle = pages.get(2);   // heredado: lee el medio sin sacar nada
        pages.remove(1);                // heredado: borra del medio
        IO.println("después de add(0, ...), get(2) y remove(1): " + pages + ", get(2) = " + middle);
    }
}
StockLevels.javaDescargar StockLevels.java
/** De dónde sale el stock actual de un producto. */
public interface StockLevels {
    int unitsOf(String product);
}
StockMonitor.javaDescargar StockMonitor.java
/**
 * Avisa cuando un producto queda con poco stock. No sabe de dónde sale el
 * stock ni cómo se envía el aviso: recibe las dos cosas por el constructor,
 * como interfaces.
 */
public class StockMonitor {
    private final StockLevels levels;
    private final Notifier notifier;
    private final int threshold;

    public StockMonitor(StockLevels levels, Notifier notifier, int threshold) {
        this.levels = levels;
        this.notifier = notifier;
        this.threshold = threshold;
    }

    /** Revisa un producto; avisa si le quedan menos de threshold unidades. */
    public void check(String product) {
        int units = levels.unitsOf(product);
        if (units < threshold) {
            notifier.send("Stock bajo: " + product + " (" + units + ")");
        }
    }
}
UndoHistory.javaDescargar UndoHistory.java
import java.util.Stack;

/**
 * Laboratorio, código inicial: historial para deshacer acciones. Hereda de
 * Stack, así que cualquier cliente puede usar add(i, x), get(i), remove(i)...
 * y desordenar el historial.
 */
@SuppressWarnings("serial")     // Stack es Serializable; aquí no serializamos
public class UndoHistory extends Stack<String> {

    /** Registra una acción hecha. */
    public void record(String action) {
        push(action);
    }

    /** Devuelve la última acción y la quita; null si no hay nada que deshacer. */
    public String undo() {
        return isEmpty() ? null : pop();
    }
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

ColorPointTest.javaDescargar ColorPointTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

import org.junit.jupiter.api.Test;

class ColorPointTest {
    private final Point p = new Point(1, 2);
    private final ColorPoint red = new ColorPoint(1, 2, "rojo");

    @Test
    void equalsIsSymmetricAgain() {
        assertFalse(p.equals(red));
        assertFalse(red.equals(p));
    }

    @Test
    void asPointGivesTheUnderlyingPoint() {
        assertEquals(p, red.asPoint());
    }

    @Test
    void colorMatters() {
        assertNotEquals(red, new ColorPoint(1, 2, "azul"));
        assertEquals(red, new ColorPoint(1, 2, "rojo"));
        assertEquals(red.hashCode(), new ColorPoint(1, 2, "rojo").hashCode());
    }
}
CountingSetTest.javaDescargar CountingSetTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.Test;

class CountingSetTest {

    @Test
    void inheritedVersionCountsTwiceWithAddAll() {
        // Documenta el error: HashSet.addAll llama al override de add.
        CountingSet s = new CountingSet();
        s.addAll(List.of("a", "b", "c"));
        assertEquals(6, s.added());
    }

    @Test
    void delegatingVersionCountsOnce() {
        DelegatingCountingSet s = new DelegatingCountingSet();
        s.addAll(List.of("a", "b", "c"));
        s.add("a");
        assertEquals(4, s.added());
        assertEquals(3, s.size());
    }
}
GreeterTest.javaDescargar GreeterTest.java
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

// Laboratorio: con la hora real solo se puede probar lo que no depende de ella.
// Después de extraer la interfaz, agreguen una prueba por cada saludo.
class GreeterTest {

    @Test
    void greetingEndsWithTheName() {
        assertTrue(new Greeter().greet("alice").endsWith(", alice"));
    }
}
PayableTest.javaDescargar PayableTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;
import org.junit.jupiter.api.Test;

class PayableTest {

    @Test
    void employeeOwesItsMonthlyPay() {
        Payable p = new HourlyEmployee("bob", 1_250, 80);
        assertEquals(100_000, p.amountDue());
    }

    @Test
    void defaultMethodFormatsAnyPayable() {
        assertEquals("S/ 450.05", new Invoice("F1", 45_005).formattedAmount());
        assertEquals("S/ 3200.00", new SalariedEmployee("alice", 320_000).formattedAmount());
    }

    @Test
    void totalAddsUnrelatedClasses() {
        List<Payable> bills = List.of(new Invoice("F1", 45_000), new SalariedEmployee("alice", 320_000));
        assertEquals(365_000, Payable.total(bills));
    }

    @Test
    void invoiceRejectsNonPositiveAmount() {
        assertThrows(IllegalArgumentException.class, () -> new Invoice("F1", 0));
    }
}
PlaylistTest.javaDescargar PlaylistTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

class PlaylistTest {
    private final Song a = new Song("A", "X", 215);
    private final Song b = new Song("B", "X", 180);
    private final Song c = new Song("C", "X", 260);

    @Test
    void rejectsSongThatDoesNotFit() {
        Playlist p = new Playlist(600);
        assertTrue(p.add(a));
        assertTrue(p.add(b));
        assertFalse(p.add(c));
        assertEquals(395, p.totalSeconds());
        assertEquals(2, p.size());
    }

    @Test
    void songsReturnsAReadOnlyCopy() {
        Playlist p = new Playlist(600);
        p.add(a);
        List<Song> view = p.songs();
        assertThrows(UnsupportedOperationException.class, () -> view.add(c));
    }

    @Test
    void inheritedPlaylistLetsInheritedMethodsBreakTheLimit() {
        // Documenta el problema: los métodos heredados se saltan la validación.
        InheritedPlaylist bad = new InheritedPlaylist(600);
        bad.add(a);
        bad.add(0, c);
        bad.add(0, c);
        assertTrue(bad.totalSeconds() > 600);
    }

    @Test
    void songsCompareByDuration() {
        assertTrue(b.compareTo(a) < 0);
        assertEquals(0, a.compareTo(new Song("otra", "X", 215)));
    }
}
RectangleTest.javaDescargar RectangleTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

import org.junit.jupiter.api.Test;

class RectangleTest {

    /** La expectativa de cualquier cliente de Rectangle. */
    private static int areaAfterResize(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        return r.area();
    }

    @Test
    void rectangleKeepsSidesIndependent() {
        Rectangle r = new Rectangle(2, 3);
        assertEquals(20, areaAfterResize(r));
    }

    @Test
    void squareBreaksTheExpectation() {
        // Documenta la violación de Liskov: la misma prueba, con un Square, da 16.
        Rectangle s = new Square(2);
        assertNotEquals(20, areaAfterResize(s));
    }
}
StockMonitorTest.javaDescargar StockMonitorTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import org.junit.jupiter.api.Test;

// StockMonitor se prueba con dos test doubles: un stub que fija el stock y un spy
// que recuerda los avisos.
class StockMonitorTest {

    @Test
    void notifiesWhenStockIsBelowThreshold() {
        SpyNotifier spy = new SpyNotifier();
        StockMonitor monitor = new StockMonitor(new FixedStockLevels(2), spy, 5);

        monitor.check("arroz");

        assertEquals(List.of("Stock bajo: arroz (2)"), spy.sent());
    }

    @Test
    void staysQuietWhenStockIsEnough() {
        SpyNotifier spy = new SpyNotifier();
        StockMonitor monitor = new StockMonitor(new FixedStockLevels(5), spy, 5);

        monitor.check("arroz");

        assertTrue(spy.sent().isEmpty());
    }
}
UndoHistoryTest.javaDescargar UndoHistoryTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;

// Laboratorio: estas pruebas deben seguir pasando después del cambio a composición.
class UndoHistoryTest {

    @Test
    void undoReturnsActionsInReverseOrder() {
        UndoHistory h = new UndoHistory();
        h.record("vender arroz");
        h.record("reponer azúcar");
        assertEquals("reponer azúcar", h.undo());
        assertEquals("vender arroz", h.undo());
    }

    @Test
    void undoOnEmptyHistoryGivesNull() {
        assertNull(new UndoHistory().undo());
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 7: interfaces, composición frente a herencia, Liskov y test doubles.
#   make         compila todo a build/
#   make test    corre las demostraciones y las pruebas JUnit
#   make clean   borra build/
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(wildcard *.java)
DEMOS  = PayableDemo PlaylistDemo FragileDemo LiskovDemo StackDemo ColorPointDemo

all: build/.stamp

build/.stamp: $(SRC)
	mkdir -p build
	$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
	touch $@

test: all
	@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
	java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
	  --disable-banner --disable-ansi-colors --details=tree

clean:
	rm -rf build

.PHONY: all test clean