Apuntes · Capítulo 3Objetos y clases
Encapsulamiento, static y records
Encapsulamiento, static y records
La semana pasada vimos qué hay detrás de una variable de tipo clase: una
referencia a un objeto en el heap. También vimos lo fácil que es dejar un
objeto en un estado sin sentido: new Thermometer(null, 18.0) compila, corre y
termina en un NullPointerException dos líneas después, lejos del lugar donde
estuvo el error de verdad. Y en la semana 1 dejamos una pregunta abierta: con
los campos ya private, ¿basta eso para que un objeto no se pueda romper?
Este capítulo responde esa pregunta. En la primera parte vemos el
encapsulamiento (encapsulation): cómo una clase decide qué muestra y qué
esconde, qué es un invariante (invariant) y cómo el constructor y los
métodos lo protegen. En la segunda vemos lo que pertenece a la clase y no a
cada objeto (static), cómo se organizan las clases en paquetes
(packages), qué es una clase inmutable (immutable) y cuándo basta con un
record. Al final, en el laboratorio, escribimos los primeros unit
tests con JUnit. El patrón de pruebas de este capítulo es el que usaremos el
resto del semestre.
Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9): subsección 5.1.3 Getters and Setters; 4.7.1 Preconditions and Postconditions; 8.2.2 y 8.2.3, sobre precondiciones e invariantes; 4.3.7 Throwing Exceptions (solo
throw); 4.2.4 Member Variables y 4.8.3 Named Constants; la sección 4.6 (4.6.2 y 4.6.3) y la subsección 2.6.5, sobre paquetes; 5.3.1 Some Built-in Classes; y la sección 7.4 Records (7.4.1 y 7.4.2). Para JUnit, la guía de usuario y la subsección 8.4.2 Annotations. El código del capítulo está encode/week-03y se prueba conmake testdentro del contenedor del curso.
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): Un objeto que todavía se puede romper y Modificadores de acceso.
- En clase: en la sesión A, el resto de Encapsulamiento, de Invariantes de clase a Error común: un getter que entrega el arreglo interno; en la sesión B,
static: lo que es de la clase, Paquetes, Inmutabilidad y Records. En el laboratorio, Unit tests con JUnit y El laboratorio: pruebas paraFraction.- Después de clase: ¿Record o clase?, Qué probar y El patrón del curso, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.
Encapsulamiento
Un objeto que todavía se puede romper
Empecemos por una cuenta bancaria. Una primera versión, escrita con lo que ya sabemos, se parece mucho a la tarjeta de la semana 1:
public class BankAccount {
private long balance; // céntimos
public void deposit(long amount) {
balance += amount;
}
public long balance() {
return balance;
}
}
El campo es private, así que nadie puede escribir account.balance = -500.
Pero nadie lo necesita: account.deposit(-500) deja el saldo en −500 usando
solo los métodos públicos. Esconder el campo fue el primer paso. El segundo es
que los métodos que lo cambian no acepten cualquier cosa.
Esa es la idea del encapsulamiento: el objeto es el único responsable de su
estado. Los demás le piden cosas a través de una interfaz pública pequeña
(sus métodos public), y la clase decide qué pedidos acepta. Si la clase está
bien escrita, no importa qué haga el resto del programa: el objeto nunca queda
en un estado imposible.
Modificadores de acceso
Java tiene cuatro niveles de acceso. Esta semana usamos tres:
| Modificador | Quién puede usar el campo o método |
|---|---|
private |
Solo el código de la misma clase |
| (ninguno) | El código de las clases del mismo paquete (acceso de paquete) |
public |
Cualquier clase |
El cuarto, protected, tiene que ver con la herencia y lo vemos en la
semana 5.
La regla práctica: los campos son private, siempre. Los métodos son
public si forman parte de lo que la clase ofrece, y private si son
ayudantes internos, como requirePositive más abajo. El acceso de paquete
sirve para que varias clases que trabajan juntas compartan algo sin mostrárselo
al resto; lo vemos en la sección de paquetes.
Idea clave.
privateno es una medida de seguridad contra personas malintencionadas: es una herramienta para razonar. Sibalanceesprivate, todo lo que le puede pasar al saldo está escrito en un solo archivo, y basta leer ese archivo para saber si alguna vez puede ser negativo.
Invariantes de clase
Un invariante de clase (class invariant) es una condición sobre los campos que vale siempre que alguien de afuera mira el objeto. Para la cuenta bancaria, el invariante es:
el saldo nunca es negativo (
balance >= 0).
“Siempre” quiere decir dos cosas concretas:
- El constructor establece el invariante: cuando termina, el objeto recién creado ya lo cumple.
- Cada método público lo preserva: si el invariante valía al entrar, vale al salir.
Dentro de un método, a mitad de camino, el invariante se puede romper un momento; lo que importa es que se cumpla cuando el método termina. Si las dos condiciones se cumplen, ningún cliente de la clase puede ver nunca un objeto que no cumpla el invariante.
Otro ejemplo, que usamos en el laboratorio: una fracción. Hay muchas maneras de
escribir un mismo número racional (1/2, 2/4, −3/−6…). Si la clase Fraction
guarda siempre la forma canónica, comparar y mostrar fracciones se vuelve
fácil. Su invariante es:
el denominador es positivo y la fracción está reducida (numerador y denominador no tienen factores comunes).
Un invariante es también documentación: le dice a quien lee la clase qué
puede dar por cierto. Por eso conviene escribirlo en el comentario de la clase,
como en BankAccount.java.
El constructor valida
Si el constructor recibe datos con los que no puede establecer el invariante, no debe crear el objeto. En Java, la manera de negarse es lanzar una excepción (throw an exception):
public Fraction(long numerator, long denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("denominador cero");
}
if (denominator < 0) { // el signo va en el numerador
numerator = -numerator;
denominator = -denominator;
}
long g = gcd(Math.abs(numerator), denominator);
this.numerator = numerator / g;
this.denominator = denominator / g;
}
throw detiene el constructor en ese punto y el new que lo llamó no
devuelve nada: el objeto inválido nunca llega a existir.
IllegalArgumentException es la excepción de la biblioteca estándar que
significa “me pasaron un argumento que no acepto”. Por ahora es la única que
usamos; las excepciones se estudian completas en la semana 9.
Las otras líneas del constructor no rechazan nada: normalizan. Un
denominador negativo o una fracción sin reducir se pueden arreglar, así que el
constructor las arregla. FractionDemo.java
lo muestra, y al final intenta crear 1/0:
$ java -cp build FractionDemo
2/4 -> 1/2
1/-3 -> -1/3
suma: 1/6
producto: -1/6
half sigue siendo 1/2
Exception in thread "main" java.lang.IllegalArgumentException: denominador cero
at Fraction.<init>(Fraction.java:12)
at FractionDemo.main(FractionDemo.java:11)
El stack trace dice qué pasó (el mensaje que escribimos), dónde
(Fraction.<init> es el constructor, línea 12) y quién lo llamó
(FractionDemo.main, línea 11). El error aparece en el momento y en el lugar
en que se cometió, no dos líneas después como el null de la semana 2.
Los métodos también validan sus argumentos. Los dos métodos de BankAccount
que cambian el saldo comparten un ayudante private:
/** Deposita un monto positivo. */
public void deposit(long amount) {
requirePositive(amount);
balance += amount;
}
/**
* Retira un monto positivo si alcanza el saldo. Devuelve si se pudo; si no
* alcanza, el saldo no cambia.
*/
public boolean withdraw(long amount) {
requirePositive(amount);
if (amount > balance) {
return false;
}
balance -= amount;
return true;
}
private static void requirePositive(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("el monto debe ser positivo: " + amount);
}
}
Fíjense en que withdraw trata distinto dos situaciones. Un monto negativo o
cero es un error de quien llama: no tiene sentido retirar −500, y el
programa que lo pide tiene un defecto, así que se lanza una excepción. Que el
saldo no alcance, en cambio, es algo normal que le pasa a cualquier cuenta,
y se informa con el valor de retorno, igual que pay en la tarjeta de la
semana 1. En los dos casos el invariante se mantiene.
En Java. Las condiciones que un método exige al que lo llama se llaman precondiciones (preconditions): “
amountes positivo”. Lo que el método promete al terminar es su postcondición (postcondition): “el saldo aumentó enamount”. El invariante es una condición que es a la vez postcondición del constructor y precondición y postcondición de cada método público. Eck lo explica en las subsecciones 4.7.1 y 8.2.2.


Getters sí, setters con cuidado
Un getter es un método que devuelve el valor de un campo, como
balance(). Un setter es uno que le asigna un valor, como sería
setBalance(long b). Muchos tutoriales enseñan a escribir los dos para cada
campo, casi de forma automática. En este curso no lo hacemos, y conviene
entender por qué:
public void setBalance(long b) { // no lo hagan
balance = b;
}
Con este método, account.setBalance(-500) hace exactamente lo mismo que
account.balance = -500. El campo es private en el papel, pero cualquiera lo
cambia como quiere: un setter por campo rompe el encapsulamiento igual que un
campo público. Se podría validar dentro del setter, pero sigue faltando lo
importante: el setter no dice por qué cambia el saldo.
Comparen con deposit y withdraw. Son métodos con intención: dicen qué
le pasa a la cuenta en el lenguaje del problema, y cada uno sabe qué reglas
aplicar. Un retiro mira el saldo; un depósito no. Un setBalance no puede
saber cuál de los dos casos es.
Idea clave. Diseñen los métodos a partir de lo que el objeto hace, no de los campos que tiene. En lugar de pedirle al objeto sus datos para decidir afuera (
if (a.balance() >= x) a.setBalance(a.balance() - x)), díganle qué hacer (a.withdraw(x)) y dejen que decida él. Martin Fowler lo llama Tell Don’t Ask.
Los getters son menos peligrosos: leer un campo no lo cambia. Con una excepción importante.
Error común: un getter que entrega el arreglo interno
LeakyScores.java guarda las notas
de una evaluación, en escala vigesimal. Su invariante es que cada nota está
entre 0 y 20, y el constructor lo verifica:
public class LeakyScores {
private final int[] values;
public LeakyScores(int[] values) {
for (int v : values) {
if (v < 0 || v > 20) {
throw new IllegalArgumentException("nota fuera de 0..20: " + v);
}
}
this.values = values; // guarda la referencia de quien llamó
}
public int[] values() {
return values; // entrega la referencia interna
}
// ... highest() ...
}
El campo es private. Pero un arreglo es un objeto, y la variable guarda una
referencia: el alias de la semana 2. El constructor guarda la misma
referencia que le pasaron, y el getter entrega esa misma referencia.
CopyDemo.java lo aprovecha por los
dos lados:
int[] input = {14, 17, 11};
LeakyScores leaky = new LeakyScores(input);
leaky.values()[0] = 99; // nadie llamó a un método que cambie notas
input[1] = -5;
$ java -cp build grades.CopyDemo
LeakyScores: máxima 99, primera 99, segunda -5
Scores: máxima 17, primera 14, segunda 17
Las notas quedaron en 99 y −5, y la clase nunca se enteró. La solución es una
copia defensiva (defensive copy): al recibir el arreglo, la clase guarda una
copia propia; al entregarlo, entrega otra copia. Es lo que hace
Scores.java, la segunda línea de la
salida:
public Scores(int[] values) {
// ... validar ...
this.values = values.clone(); // copia defensiva al entrar
}
public int[] values() {
return values.clone(); // copia defensiva al salir
}


Fíjense en el final de private final int[] values. No evitó nada: final
impide que el campo apunte a otro arreglo, pero no impide cambiar los
elementos del arreglo al que apunta. Una referencia final no hace inmutable
al objeto.
Error común. Pensar que
privateprotege también a los objetos a los que apuntan los campos. Si un campo es una referencia a un objeto mutable (un arreglo, y en la semana 4 unArrayList), devolverlo o guardarlo sin copiar abre una puerta trasera. ConStringno pasa: unStringno se puede modificar, así que compartirlo es seguro.
static: lo que es de la clase
Campos y métodos de la clase
Cada cuenta tiene su saldo; eso es un campo de instancia, uno por objeto.
Pero a veces queremos un dato que no es de ninguna cuenta en particular, sino
de todas: cuántas cuentas se abrieron, por ejemplo, para numerarlas. Ese dato
se declara static:
public class BankAccount {
private static int accountsOpened = 0; // de la clase: uno solo para todas las cuentas
private final String id;
private final String owner;
private long balance; // céntimos; siempre >= 0
public BankAccount(String owner) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("el titular no puede estar vacío");
}
accountsOpened++;
this.id = String.format("CTA-%04d", accountsOpened);
this.owner = owner;
this.balance = 0;
}
/** Cuántas cuentas se abrieron desde que arrancó el programa. */
public static int accountsOpened() {
return accountsOpened;
}
// ...
}
Un campo static (también llamado campo de clase) existe una sola vez,
sin importar cuántos objetos se creen, e incluso si no se crea ninguno. Un
método static pertenece a la clase y se llama con el nombre de la clase:
BankAccount.accountsOpened(), no con un objeto.
$ java -cp build bank.BankDemo
CTA-0001 Alice: retiro true, saldo 7500
CTA-0002 Bob: retiro false, saldo 0
cuentas abiertas: 2
...
Ya conocen métodos static de la biblioteca: Math.max(a, b),
Math.abs(x), String.format(...), IO.println(...). Son funciones que no
necesitan un objeto: todo lo que usan les llega por parámetro. El gcd de
Fraction es igual, y por eso es private static.
Las constantes son campos static final: uno solo para toda la clase, y
que no cambia. Se escriben en mayúsculas con guiones bajos:
public static final int MAX_SCORE = 20;
Y ahora podemos explicar el main “largo” que vimos en la semana 1:
public static void main(String[] args)
Es public para que la JVM lo pueda llamar desde afuera, y static porque la
JVM lo llama antes de que exista ningún objeto. String[] args son los
argumentos de la línea de comandos. Desde esta semana, los programas de
demostración se escriben así, en clases normales.
Por qué un método static no puede usar this
Dentro de un método de instancia, balance quiere decir this.balance: el
campo del objeto que recibió el mensaje. Un método static no recibe ningún
mensaje de ningún objeto, así que no hay this, y no hay de qué objeto leer
balance. El compilador lo dice así:
public class StaticError {
private long balance;
public static void main(String[] args) {
balance = 100;
IO.println(balance);
}
}
$ javac -Xlint:all StaticError.java
StaticError.java:5: error: non-static variable balance cannot be referenced from a static context
balance = 100;
^
StaticError.java:6: error: non-static variable balance cannot be referenced from a static context
IO.println(balance);
^
2 errors
Al revés no hay problema: un método de instancia sí puede usar campos y métodos
static, como el constructor de BankAccount usa accountsOpened.
Error común. Ante ese mensaje, la “solución” rápida es agregarle
statical campo. Compila, pero cambia el significado: ahora hay un solo saldo para todas las cuentas.StaticPitfall.javalo muestra con un contador de clics que debía ser de cada objeto:static class Tally { private static int count; // error: un solo contador para todos void click() { count++; } int count() { return count; } }$ java -cp build StaticPitfall door: 3, window: 3Se hicieron dos clics en
doory uno enwindow, y los dos dicen 3. La solución correcta es crear un objeto (new Tally()) y enviarle mensajes, no volverstaticlo que no lo es.
Los campos static que cambian, como accountsOpened, son estado
compartido por todo el programa, y eso trae los mismos problemas que las
variables globales de C: cualquier parte del programa que cree una cuenta lo
cambia. Úsenlos poco. Las constantes static final y los métodos static sin
estado no tienen ese problema.
Paquetes
Cuando un programa tiene decenas de clases, hace falta ordenarlas. Un
paquete (package) es un grupo de clases relacionadas con un nombre común.
La biblioteca estándar está organizada así: java.lang (String, Math,
IO), java.util (ArrayList, Random), java.time (LocalDate)…
Desde esta semana, el código del curso va en paquetes. En code/week-03, las
clases de la cuenta están en el paquete bank y las de las notas en grades.
Cada archivo dice a qué paquete pertenece en su primera línea:
package bank;
public class BankAccount {
// ...
}
Tres reglas:
- El paquete corresponde a una carpeta: las clases del paquete
bankestán enbank/. Un paquetepe.uni.bodegaestaría enpe/uni/bodega/. - El nombre completo de la clase incluye el paquete:
bank.BankAccount. Dentro del mismo paquete basta conBankAccount; desde otro paquete hay que escribir el nombre completo o importarlo:import bank.BankAccount;.java.langse importa siempre solo, por esoStringno necesitaimport. javac -d builddeja cada.classen la carpeta de su paquete dentro debuild/, yjava -cp buildnecesita el nombre completo de la clase.
$ java -cp build bank.BankDemo
CTA-0001 Alice: retiro true, saldo 7500
...
$ java -cp build BankDemo
Error: Could not find or load main class BankDemo
Caused by: java.lang.ClassNotFoundException: BankDemo
Sin el paquete, la JVM busca build/BankDemo.class, que no existe.
Las clases sin package, como Fraction y StaticPitfall, están en el
paquete sin nombre (unnamed package). Sirve para programas chicos y
ejemplos, pero una clase de un paquete con nombre no puede importar una clase
del paquete sin nombre; por eso, en un proyecto, todo va en paquetes.
En Java. El
Makefilede esta semana busca las fuentes en todas las subcarpetas (find . -name '*.java') y ejecuta las demostraciones por su nombre completo (bank.BankDemo). Es el que usaremos de aquí en adelante.
Ahora se entiende el acceso de paquete: un miembro sin modificador es visible para todas las clases de su paquete y para ninguna de afuera. Sirve para que las clases que colaboran dentro de un paquete se ayuden entre sí sin agrandar su interfaz pública. Lo usaremos la próxima semana.
Inmutabilidad
Una clase es inmutable si sus objetos no cambian nunca después de
construidos. String es el ejemplo de siempre: "lima".toUpperCase() no
cambia "lima", devuelve otro String. Escribir una clase inmutable tiene una
receta:
- Todos los campos son
private final. El compilador impide asignarlos fuera del constructor. - No hay métodos que modifiquen el estado.
- Las operaciones que “cambian” el valor devuelven un objeto nuevo.
- Si un campo apunta a un objeto mutable (un arreglo), se copia al entrar y
al salir, como en
Scores.
Fraction sigue la receta. plus y times no tocan this: construyen una
fracción nueva, y el constructor de esa fracción nueva la reduce, así que el
invariante se cumple sin esfuerzo adicional:
/** Devuelve this + other como una fracción nueva; this no cambia. */
public Fraction plus(Fraction other) {
return new Fraction(numerator * other.denominator + other.numerator * denominator,
denominator * other.denominator);
}
En la salida de FractionDemo, half sigue siendo 1/2 después de sumar y
multiplicar. Y si alguien intenta escribir un método que modifique un campo,
el final lo detiene al compilar:
$ javac -Xlint:all FinalError.java
FinalError.java:9: error: cannot assign a value to final variable numerator
numerator = -numerator;
^
1 error
¿Qué se gana? Lo más importante: un objeto inmutable se puede compartir sin miedo al alias. Si dos partes del programa tienen una referencia a la misma fracción, ninguna puede cambiársela a la otra. Quien recibe un objeto inmutable lo puede guardar sin copiarlo. La clase inmutable, en cambio, sí copia los componentes mutables que recibe o expone (un arreglo, una lista): sin esas copias dejaría de ser inmutable. Además, un objeto inmutable se puede usar como clave en un mapa (semana 11) y es más fácil de razonar: lo que vale al crearlo vale para siempre. El precio es crear objetos nuevos en cada operación, que en Java casi siempre es barato.
Idea clave. Hagan inmutables los valores (una fracción, una cantidad de dinero, una fecha) y dejen mutables las entidades que de verdad cambian con el tiempo (una cuenta, un inventario).
java.time.LocalDate,Stringy las clases wrapper comoIntegerson inmutables por esa razón.
Records
Escribir una clase inmutable a mano tiene bastante ceremonia: campos
private final, un constructor que los asigna, un getter por campo. Para los
casos en que la clase es solo un dato con valor, Java tiene los records
(JEP 395, desde Java 16).
Money.java es una cantidad de dinero en una
moneda:
public record Money(long cents, String currency) {
/** Constructor compacto: valida antes de asignar los campos. */
public Money {
if (cents < 0) {
throw new IllegalArgumentException("monto negativo: " + cents);
}
if (currency == null || currency.length() != 3) {
throw new IllegalArgumentException("código de moneda inválido: " + currency);
}
}
/** Atajo para soles: Money.soles(1050) es S/ 10.50. */
public static Money soles(long cents) {
return new Money(cents, "PEN");
}
/** Devuelve una suma nueva; this no cambia. */
public Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("monedas distintas: " + currency + " y " + other.currency);
}
return new Money(cents + other.cents, currency);
}
}
La línea record Money(long cents, String currency) declara los
componentes del record. A partir de ella, el compilador genera:
- un campo
private finalpor componente; - el constructor canónico,
Money(long cents, String currency), que asigna los campos; - un método de acceso por componente, con el mismo nombre:
cents()ycurrency()(nogetCents()); equals,hashCodeytoString, que usan todos los componentes.
javap -p muestra lo que hay en el .class:
$ javap -p -cp build bank.Money
Compiled from "Money.java"
public final class bank.Money extends java.lang.Record {
private final long cents;
private final java.lang.String currency;
public bank.Money(long, java.lang.String);
public static bank.Money soles(long);
public bank.Money plus(bank.Money);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public long cents();
public java.lang.String currency();
}
El constructor compacto (compact constructor) es el bloque public Money { ... }, sin lista de parámetros. Se ejecuta al principio del constructor
canónico, con los parámetros a la vista, y los campos se asignan solos al
final. Es el lugar natural para validar: el invariante del record (monto no
negativo, código de tres letras) queda establecido igual que en una clase.
Un record puede tener métodos propios (plus) y métodos static (soles, un
static factory method: un método static que crea el objeto y se lee
mejor que el constructor). Lo que no puede tener son campos de instancia aparte de sus
componentes, ni cambiarlos: un record siempre es inmutable.
$ java -cp build bank.BankDemo
...
Money[cents=1050, currency=PEN] + S/ 2.50 = Money[cents=1300, currency=PEN]
iguales: true
El toString generado muestra el nombre y los componentes, mucho más útil que
el TransitCard@31221be2 de la semana 1. Y equals compara componentes,
no referencias: total y new Money(1_300, "PEN") son dos objetos distintos
y equals dice que son iguales. Comparen con los dos Thermometer de la
semana 2, iguales en todo y con equals en false. El contrato que cumplen
equals y hashCode, y cómo escribirlos a mano en una clase, es el tema de la
semana 5.
¿Record o clase?
Un record es un dato con valor: su identidad no importa, solo sus
componentes. Dos Money(1300, "PEN") son la misma cantidad de dinero aunque
sean dos objetos.
Una cuenta bancaria no es eso. Tiene un saldo que cambia, un invariante que
cada operación tiene que cuidar, y una identidad propia: dos cuentas con el
mismo saldo no son la misma cuenta. BankAccount es una clase y no puede ser
un record.
Fraction está en el medio: es un valor inmutable y podría ser un record (el
constructor compacto puede reducir los parámetros antes de que se asignen). La
dejamos como clase para escribir a mano lo que un record trae hecho; en la
semana 5 le agregaremos equals.
Error común. Usar un record para algo que tiene que cambiar, y “cambiarlo” creando uno nuevo cada vez en todas partes. Si el problema habla de algo que cambia con el tiempo y tiene reglas, es una clase con estado. Si habla de un valor que se calcula, se compara y se pasa de un lado a otro, es un record.
Unit tests con JUnit
Hasta aquí, para ver si una clase funciona, escribimos un programa de demostración y miramos la salida. Eso tiene dos problemas: alguien tiene que mirar la salida y saber qué debía decir, y hay que volver a mirarla cada vez que el código cambia. Un unit test es un método que ejecuta un pedacito del programa y verifica él mismo el resultado. Si el resultado es el esperado, la prueba pasa en silencio; si no, falla y dice qué esperaba y qué obtuvo. Un programa con buenas pruebas se puede cambiar con confianza: si algo se rompe, una prueba lo avisa.
Usamos JUnit (versión 6), el framework de pruebas más usado en Java. El
contenedor del curso lo trae en $JUNIT_JAR.
Anatomía de una prueba
BankAccountTest.java prueba la
cuenta. Este es su esqueleto con dos pruebas:
package bank;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test;
class BankAccountTest {
@Test
void depositIncreasesBalance() {
// Preparar
BankAccount account = new BankAccount("Alice");
// Actuar
account.deposit(10_000);
// Verificar
assertEquals(10_000, account.balance());
}
@Test
void withdrawMoreThanBalanceChangesNothing() {
BankAccount account = new BankAccount("Alice");
account.deposit(1_000);
boolean ok = account.withdraw(2_500);
assertFalse(ok);
assertEquals(1_000, account.balance());
}
}
Por partes:
- La clase de prueba se llama como la clase probada más
Test, está en el mismo paquete (package bank;) y en la misma carpeta. No necesita serpublic. Al estar en el mismo paquete, puede usar también lo que tiene acceso de paquete. @Testes una anotación (annotation): una marca que no cambia lo que hace el método, pero que JUnit busca para saber qué métodos son pruebas. Los métodos de prueba no reciben parámetros y no devuelven nada (void).- El nombre de la prueba dice qué se espera, como una frase:
depositIncreasesBalance,withdrawMoreThanBalanceChangesNothing. Cuando una prueba falla, el nombre es lo primero que se lee, y debería bastar para saber qué se rompió. - Tres partes, separadas por una línea en blanco: preparar (crear los objetos y llevarlos al estado de partida), actuar (enviar el mensaje que se está probando, una sola vez) y verificar (comparar el resultado con lo esperado). En inglés se conoce como arrange, act, assert. En la primera prueba están marcadas con comentarios; en las demás basta con las líneas en blanco.
- Las assertions son métodos
staticde la claseAssertions, importados conimport staticpara escribirassertEqualsen lugar deAssertions.assertEquals. Si la condición no se cumple, la prueba falla en esa línea.
Las assertions que usamos esta semana:
| Assertion | Pasa si |
|---|---|
assertEquals(esperado, real) |
los dos valores son iguales (con equals si son objetos) |
assertNotEquals(no esperado, real) |
son distintos |
assertTrue(condición) |
la condición es true |
assertFalse(condición) |
la condición es false |
assertThrows(Excepción.class, () -> código) |
ese código lanza esa excepción |
Error común. Invertir los argumentos de
assertEquals. El primero es el valor esperado y el segundo el real. Si se invierten, la prueba pasa y falla en los mismos casos, pero el mensaje de error dice lo contrario de lo que pasó y confunde a quien lo lee.
Idea clave. Una prueba, un comportamiento. Si una prueba verifica diez cosas y falla, hay que leerla entera para saber cuál. Diez pruebas pequeñas con buenos nombres dicen, sin abrir ningún archivo, exactamente qué funciona y qué no. Cada prueba crea sus propios objetos: ninguna depende de lo que hizo otra ni del orden en que se ejecutan.
Correr las pruebas
make test compila todo y ejecuta las pruebas con el Console Launcher de
JUnit, que busca en build/ todas las clases con métodos @Test:
$ make test
...
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ ├─ BankAccountTest ✔
│ │ ├─ depositIncreasesBalance() ✔
│ │ ├─ newAccountStartsAtZero() ✔
│ │ ├─ withdrawWithEnoughBalance() ✔
│ │ ├─ withdrawMoreThanBalanceChangesNothing() ✔
│ │ ├─ rejectsNegativeDeposit() ✔
│ │ ├─ rejectsZeroWithdrawal() ✔
│ │ ├─ rejectsBlankOwner() ✔
│ │ └─ eachAccountGetsADifferentId() ✔
│ ├─ ScoresTest ✔
│ │ ├─ ...
│ └─ MoneyTest ✔
│ ├─ ...
└─ JUnit Vintage ✔
...
[ 18 tests successful ]
[ 0 tests failed ]
Fíjense en que las pruebas no corren en el orden en que están escritas. No importa, porque ninguna depende de otra. Jupiter es el nombre del motor de JUnit que ejecuta nuestras pruebas; Vintage y Suite son otros dos motores que vienen en el mismo jar y aquí no encuentran nada.
Cuando una prueba falla
Para ver una falla, cambiemos a propósito el valor esperado en
depositIncreasesBalance por 1_000:
│ └─ BankAccountTest ✔
│ ├─ depositIncreasesBalance() ✘ expected: <1000> but was: <10000>
│ ├─ newAccountStartsAtZero() ✔
...
Failures (1):
JUnit Jupiter:BankAccountTest:depositIncreasesBalance()
MethodSource [className = 'bank.BankAccountTest', methodName = 'depositIncreasesBalance', methodParameterTypes = '']
=> org.opentest4j.AssertionFailedError: expected: <1000> but was: <10000>
org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
bank.BankAccountTest.depositIncreasesBalance(BankAccountTest.java:29)
...
[ 7 tests successful ]
[ 1 tests failed ]
La falla se lee de arriba abajo: qué prueba (depositIncreasesBalance), qué
assertion (assertEquals), qué esperaba (1000), qué obtuvo (10000) y en qué
línea del archivo de prueba (29). Aquí el error estaba en la prueba; casi
siempre está en el código, y la prueba dice dónde empezar a buscar.
Probar que se rechaza lo inválido
Las pruebas de los casos normales no bastan: también hay que probar que la
clase rechaza lo que debe rechazar, porque de eso depende el invariante.
Para eso está assertThrows:
@Test
void rejectsNegativeDeposit() {
BankAccount account = new BankAccount("Alice");
assertThrows(IllegalArgumentException.class, () -> account.deposit(-500));
assertEquals(0, account.balance());
}
assertThrows recibe la clase de la excepción esperada y el código que
debería lanzarla. Ese código va después de () ->. La flecha arma un pedazo
de código que assertThrows puede ejecutar cuando le toca, en lugar de
ejecutarlo antes de llamarlo; se llama lambda y la estudiamos en la semana
12. Por ahora úsenla como una receta: () -> seguido de la expresión que debe
fallar. La segunda assertion comprueba que, además de lanzar la excepción, el
saldo no cambió.
Romper el invariante a propósito
¿Las pruebas de verdad protegen el invariante? Hagamos la prueba al revés:
quitemos la validación de deposit, como si alguien la hubiera borrado por
apuro, y corramos make test:
│ ├─ rejectsNegativeDeposit() ✘ Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
...
[ 7 tests successful ]
[ 1 tests failed ]
Exactamente una prueba falla, y su nombre dice qué regla se perdió. Este es el ejercicio del laboratorio: romper una clase y ver qué prueba lo detecta. Si ninguna lo detecta, falta una prueba. Ojo: lo que comprobamos así son las pruebas; el código de verdad se deja como estaba.
Qué probar
Para cada clase, piensen en tres grupos de casos:
- Los normales: el uso esperado. Un depósito, un retiro que alcanza.
- Los bordes: el saldo exacto (retirar todo), la lista vacía, el valor más chico y el más grande permitido (las notas 0 y 20), un empate.
- Los inválidos: lo que el invariante prohíbe y la clase debe rechazar. Un monto negativo o cero, un titular en blanco, una nota de 21.
Las pruebas de MoneyTest.java y
ScoresTest.java siguen el mismo
patrón. ScoresTest incluye pruebas que fallarían con LeakyScores, como
changingTheReturnedArrayDoesNotChangeTheScores: una prueba también puede
verificar que una copia defensiva está en su lugar.
En Java. Un
staticque cambia, comoaccountsOpened, se comparte entre todas las pruebas de la misma ejecución, y las pruebas corren en un orden que no controlamos. Por esoeachAccountGetsADifferentIdno espera un código concreto como"CTA-0001": solo verifica que dos cuentas tengan códigos distintos. Es otra razón para evitar el estadostaticmutable.
El patrón del curso
De aquí en adelante, cada clase del curso y del proyecto viene con sus pruebas, escritas así:
XTest.javajunto aX.java, en el mismo paquete, con clase sinpublic.- Imports estáticos de las assertions que se usan.
- Un método
@Testpor comportamiento, con un nombre que dice qué se espera. - Preparar, actuar y verificar, separados por líneas en blanco.
assertEquals(esperado, real).- Casos normales, bordes e inválidos; los inválidos con
assertThrows(IllegalArgumentException.class, () -> ...).
El laboratorio: pruebas para Fraction
En el laboratorio escriben FractionTest.java para la clase
Fraction, que todavía no tiene pruebas, con
el patrón de arriba. Sugerencias para empezar: que 2/4 se reduzca al crearse,
que el signo pase al numerador, que se rechace el denominador cero, que plus
y times den el resultado reducido y que no cambien la fracción original. Como
Fraction todavía no tiene equals (eso es de la semana 5), comparen el
numerador y el denominador por separado:
assertEquals(1, f.numerator());
assertEquals(2, f.denominator());
Después rompan el invariante a propósito (por ejemplo, quiten la reducción) y verifiquen que alguna de sus pruebas lo detecta.
El último paso es pasar Fraction a un paquete numbers: moverla a la
carpeta numbers/ y agregarle package numbers;. Al compilar, FractionDemo,
que se quedó en el paquete sin nombre, deja de encontrarla:
./FractionDemo.java:4: error: cannot find symbol
Fraction half = new Fraction(2, 4);
^
symbol: class Fraction
location: class FractionDemo
Resolverlo es parte del ejercicio: ¿qué le falta a FractionDemo? ¿Dónde tiene
que estar la prueba? ¿Cómo cambia la línea del Makefile que ejecuta la
demostración?
Resumen
- Encapsulamiento: la clase es la única responsable de su estado. Los
campos son
private; los demás usan una interfaz pública pequeña. - Un invariante de clase es una condición sobre los campos que vale siempre: el constructor la establece y cada método público la preserva.
- El constructor y los métodos validan sus argumentos y rechazan lo
inválido con
throw new IllegalArgumentException(...). Un objeto inválido nunca llega a existir. - Métodos con intención (
deposit,withdraw) en lugar de un setter por campo. Tell Don’t Ask. - Un campo que apunta a un objeto mutable necesita copias defensivas al
entrar y al salir;
finalen la referencia no basta. static: campos y métodos de la clase, no de cada objeto. Constantesstatic final. Un métodostaticno tienethis. No vuelvanstaticun campo para que compile.- Paquetes:
package,import, una carpeta por paquete,javac -d buildyjava -cp build paquete.Clase. El acceso sin modificador es de paquete. - Una clase inmutable tiene campos
private final, no tiene métodos que modifiquen y devuelve objetos nuevos. Se comparte sin miedo al alias. - Un record declara sus componentes y el compilador genera campos,
constructor, métodos de acceso,
equals,hashCodeytoString. Se valida en el constructor compacto. Record para valores; clase para entidades con estado. - Un unit test con JUnit verifica por sí mismo su resultado:
@Test, un comportamiento por prueba, preparar-actuar-verificar,assertEquals,assertTrue,assertFalseyassertThrows.
Ejercicios
-
Escriban el invariante de una clase
Timeque guarda una hora del día en horas y minutos. ¿Qué tiene que hacer el constructor connew Time(25, 0)? ¿Y un métodoplusMinutes(90)aplicado a las 23:00? -
La clase
BankAccounttiene un campoownersin setter. Un compañero propone agregarsetOwner(String)“por si alguien se casa y cambia de apellido”. ¿Qué tendría que validar ese método? ¿Le pondrían otro nombre? ¿Por qué? -
Sin ejecutarlo, digan qué imprime este fragmento si se corre justo al empezar el programa y por qué. Después compruébenlo con un programa en el paquete
bank:BankAccount a = new BankAccount("Alice"); BankAccount b = new BankAccount("Bob"); BankAccount c = a; IO.println(BankAccount.accountsOpened() + " " + c.id()); -
Escriban una clase
Countercon un métodonext()que devuelva 1, 2, 3… en llamadas sucesivas, una cuenta por objeto. Agreguen un métodostaticque diga cuántosCounterse crearon. Escriban pruebas para los dos comportamientos. ¿Cuál de los dos es frágil si las pruebas corren en otro orden? ¿Cómo lo prueban sin depender del orden? -
Moneytieneplus. Agréguenleminus(Money other). ¿Qué tiene que pasar si el resultado sería negativo? Escriban las pruebas antes que el método, véanlas fallar y después escriban el método. -
Este getter de una clase
Route(una ruta de bus) devuelve sus paraderos:private final String[] stops; public String[] stops() { return stops; }Escriban una prueba que demuestre que un cliente puede cambiar los paraderos de la ruta sin llamar a ningún método de
Route. Después arreglen la clase y verifiquen que la prueba pasa. -
¿Cuáles de estos serían un record y cuáles una clase con estado? Justifiquen cada uno: un punto en el plano, un carrito de compras, un rango de fechas, un semáforo, un DNI, una partida de ajedrez.
-
En
code/week-03, ejecutenjavap -p -cp build bank.BankAccounty comparen la salida con la debank.Money. ¿Qué tiene uno que no tiene el otro? ¿De dónde salió cada método deMoney? -
Escriban un record
Temperature(double celsius)que rechace valores por debajo del cero absoluto (−273.15 °C), con un static factory methodfromFahrenheit(double f)y un métodotoFahrenheit(). Escriban sus pruebas. (Para comparardoublecalculados, usenassertEquals(esperado, real, 1e-9), la versión con tolerancia.) -
Tomen la clase
Thermometerde la semana 2 y hagan que el constructor rechace un lugarnullo en blanco. ¿Qué pasa ahora conNullDemo? ¿En qué línea aparece el error, y por qué esa línea es mejor que la de antes? -
Una clase tiene un campo
private static final int[] PRIMES = {2, 3, 5, 7};. ¿Es una constante de verdad? Muestren con un ejemplo qué puede pasar. -
Lean Tell Don’t Ask de Martin Fowler y revisen la clase
Productque escribieron para P0. ¿Tiene setters? ¿Hay código fuera de la clase que le pide datos para decidir algo que el producto podría decidir solo (por ejemplo, si alcanza el stock para una venta)? Propongan los métodos con intención que los reemplazarían y el invariante que protegerían.