Ir al contenido
wannacode

Refactorización en pasos pequeños

En resumen: La refactorización cambia la estructura del código sin cambiar su comportamiento, en pasos pequeños con nombre como Rename y Extract Method, con las pruebas en verde después de cada uno.

Problema

La factura que nadie quiere tocar

La tienda online imprime las facturas con el método Invoices.inv. Lleva dos años funcionando:

static String inv(String n, String street, String city, String zip, List<Item> items, String type) {
double t = 0;
for (Item i : items) {
t = t + i.price() * i.qty();
}
double d = 0;
if (type.equals("gold")) {
d = t * 0.05;
} else if (type.equals("company")) {
if (t >= 500) {
d = t * 0.10;
}
}
String s = "Factura: " + n + "\n";
s = s + street + ", " + zip + " " + city + "\n";
s = s + String.format("Importe: %.2f €\n", t);
s = s + String.format("Descuento: %.2f €\n", d);
s = s + String.format("Total con IVA: %.2f €", afterDiscount(t, d) * 1.20);
return s;
}
static double afterDiscount(double t, double d) {
return t - d;
}

¿Qué es t? ¿Y d? Seis parámetros seguidos, cuatro de ellos texto: quien cambie de sitio la ciudad y el código postal obtiene una factura con «Madrid 28013» en el lugar equivocado, y el compilador no dice nada. La cadena de if según el tipo de cliente sigue creciendo: el mes que viene llegan los socios con un 3 %. Y afterDiscount hace una sola resta, pero te obliga a saltar a otro sitio para verla.

Quieres ordenarla. Pero las facturas van a contabilidad, y un error en ellas es un error en los impuestos.

La idea

Refactorización: la forma cambia, el comportamiento no

Refactorizar es cambiar la forma del código manteniendo el comportamiento: las mismas entradas dan las mismas salidas. No es «reescribir». Tampoco es «ya que estaba, arreglé un error». Esos son otros cambios, que se hacen por separado.

Se hace en pasos pequeños con nombre. Cada paso es tan pequeño que apenas puede salir mal, y después de cada uno ejecutas las pruebas (la red de seguridad de la lección anterior). Los nombres vienen del catálogo de Martin Fowler y son los mismos en cualquier lenguaje y cualquier IDE: Rename, Extract Method, Inline, Introduce Parameter Object, Replace Conditional with Polymorphism.

Primero va la red. Cuatro pruebas fijan los cuatro tipos de factura que la tienda imprime hoy: un cliente normal, un cliente oro, una empresa por encima y por debajo de 500 €.

class InvoiceTest {
List<Item> coffee = List.of(new Item("Café en grano", 12.50, 2), new Item("Taza", 8.00, 1));
List<Item> moreCoffee = List.of(new Item("Café en grano", 12.50, 4));
List<Item> flour = List.of(new Item("Harina 25 kg", 30.00, 20));
@Test
void regular() {
assertEquals("Factura: María Torres\nCalle Mayor 5, 28013 Madrid\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €",
Invoices.inv("María Torres", "Calle Mayor 5", "Madrid", "28013", coffee, "regular"));
}
@Test
void gold() {
assertEquals("Factura: Lucía Navarro\nCalle Alcalá 12, 41001 Sevilla\nImporte: 50.00 €\nDescuento: 2.50 €\nTotal con IVA: 57.00 €",
Invoices.inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold"));
}
@Test
void companyFrom500() {
assertEquals("Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 600.00 €\nDescuento: 60.00 €\nTotal con IVA: 648.00 €",
Invoices.inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", flour, "company"));
}
@Test
void companyUnder500() {
assertEquals("Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €",
Invoices.inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", coffee, "company"));
}
}
Resultado
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Míralo

Paso 1: Rename

Los nombres de una letra pasan a ser palabras: t es subtotal, d es discount, s es text, inv es invoice. zip pasa a ser postcode, y type pasa a ser customerType.

static String invoice(String name, String street, String city, String postcode, List<Item> items, String customerType) {
double subtotal = 0;
for (Item item : items) {
subtotal = subtotal + item.price() * item.qty();
}
double discount = 0;
if (customerType.equals("gold")) {
discount = subtotal * 0.05;
} else if (customerType.equals("company")) {
if (subtotal >= 500) {
discount = subtotal * 0.10;
}
}
String text = "Factura: " + name + "\n";
text = text + street + ", " + postcode + " " + city + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", afterDiscount(subtotal, discount) * 1.20);
return text;
}
Las pruebas
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Rename también cambia todos los sitios que llaman a el método, incluidas las pruebas. Las facturas esperadas no se tocan.

En IntelliJ IDEA es Mayús+F6: el IDE encuentra cada uso por los tipos, no por el texto, y cambia solo eso.

Míralo

Paso 2: Extract Method

El método hace dos trabajos distintos que no tienen nada que ver con el texto de la factura: el total de las líneas y el descuento. Cada uno pasa a un método propio, con un nombre que dice qué devuelve. Eso es Extract Method:

static String invoice(String name, String street, String city, String postcode, List<Item> items, String customerType) {
double subtotal = itemsTotal(items);
double discount = discountFor(customerType, subtotal);
String text = "Factura: " + name + "\n";
text = text + street + ", " + postcode + " " + city + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", afterDiscount(subtotal, discount) * 1.20);
return text;
}
static double itemsTotal(List<Item> items) {
double sum = 0;
for (Item item : items) {
sum = sum + item.price() * item.qty();
}
return sum;
}
static double discountFor(String customerType, double subtotal) {
double discount = 0;
if (customerType.equals("gold")) {
discount = subtotal * 0.05;
} else if (customerType.equals("company")) {
if (subtotal >= 500) {
discount = subtotal * 0.10;
}
}
return discount;
}
Las pruebas
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Ahora invoice se lee de arriba abajo como una lista: cuánto es, cuánto es el descuento, cuál es el texto. Los detalles están a un salto de distancia, solo si los necesitas.

En IntelliJ IDEA: selecciona las líneas y pulsa Ctrl+Alt+M (⌘⌥M en Mac).

El IDE mira qué variables entran en las líneas seleccionadas y cuáles salen, y crea los parámetros y el valor de retorno. Si haces el paso a mano, es justo ahí donde se tuerce.

Pruébalo

Predice la salida

Alguien hace Extract Method a mano. El descuento pasa a un método que lo guarda en su propio parámetro en lugar de devolverlo:

static String invoice(String name, String street, String city, String postcode, List<Item> items, String customerType) {
double subtotal = itemsTotal(items);
double discount = 0;
applyDiscount(customerType, subtotal, discount);
String text = "Factura: " + name + "\n";
text = text + street + ", " + postcode + " " + city + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", afterDiscount(subtotal, discount) * 1.20);
return text;
}
static void applyDiscount(String customerType, double subtotal, double discount) {
if (customerType.equals("gold")) {
discount = subtotal * 0.05;
} else if (customerType.equals("company")) {
if (subtotal >= 500) {
discount = subtotal * 0.10;
}
}
}
Respuesta
Más precisamente, para curiosos
Resultado
FALLA companyFrom500()
FALLA gold()
OK companyUnder500()
OK regular()
Pruebas: 4, pasaron: 2, fallaron: 2

discount dentro de applyDiscount es una copia del valor. El valor nuevo se queda allí dentro y desaparece cuando termina el método, y fuera discount sigue siendo 0. Donde el descuento ya es 0 de todos modos (un cliente normal, una empresa por debajo de 500 €), las pruebas pasan. Por eso la red tiene una prueba para cada rama del if. El código extraído tiene que devolver el valor, como lo hace el IDE.

Míralo

Paso 3: Inline

Lo contrario de Extract Method. afterDiscount hace una resta, y su nombre no dice nada más que subtotal - discount. Se usa en un solo sitio. El cuerpo va donde está la llamada, y el método desaparece. Eso es Inline:

static double afterDiscount(double subtotal, double discount) {
return subtotal - discount;
}
static String invoice(String name, String street, String city, String postcode, List<Item> items, String customerType) {
double subtotal = itemsTotal(items);
double discount = discountFor(customerType, subtotal);
String text = "Factura: " + name + "\n";
text = text + street + ", " + postcode + " " + city + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", (subtotal - discount) * 1.20);
return text;
}
Las pruebas
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Extract Method e Inline no se contradicen. Extraes cuando el nombre explica algo que el código no dice (itemsTotal). Lo devuelves a su sitio cuando el nombre es solo un salto (afterDiscount). En IntelliJ IDEA es Ctrl+Alt+N.

Míralo

Paso 4: Introduce Parameter Object

El nombre, la calle, la ciudad y el código postal siempre viajan juntos, y sin embargo son cuatro parámetros seguidos. Un intercambio no se nota:

Invoices.invoice("María Torres", "Calle Mayor 5", "28013", "Madrid", coffee, "regular")
Salida
Factura: María Torres
Calle Mayor 5, Madrid 28013
Importe: 33.00 €
Descuento: 0.00 €
Total con IVA: 39.60 €

Los cuatro pasan a ser un objeto: el cliente. Eso es Introduce Parameter Object.

Un record escribe los campos, el constructor y los métodos de acceso:

record Customer(String name, String street, String city, String postcode) {}
static String invoice(Customer customer, List<Item> items, String customerType) {
double subtotal = itemsTotal(items);
double discount = discountFor(customerType, subtotal);
String text = "Factura: " + customer.name() + "\n";
text = text + customer.street() + ", " + customer.postcode() + " " + customer.city() + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", (subtotal - discount) * 1.20);
return text;
}
Las pruebas
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Las llamadas en las pruebas cambian con la forma, y las facturas esperadas se quedan igual. Aquí está una llamada a través de los pasos:

Invoices.inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold")
Invoices.invoice("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold")
Invoices.invoice(new Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), moreCoffee, "gold")
Invoices.invoice(new Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), moreCoffee, new Gold())
En Java un intercambio sigue siendo posible en el constructor del record, pero ahora está en un solo sitio, donde puede haber una comprobación (un código postal tiene cinco dígitos). Java no tiene argumentos con nombre. IntelliJ IDEA tiene «Introduce Parameter Object» en el menú Refactor.

Míralo

Paso 5: Replace Conditional with Polymorphism

Lo que queda es la cadena de if según el tipo de cliente en discountFor. Cada tipo nuevo es una rama nueva aquí, y mañana también en otros sitios (el correo, el informe). Cada tipo pasa a ser una clase que implementa una interfaz común, y conoce su propio descuento:

interface CustomerType {
double discount(double subtotal);
}
class Regular implements CustomerType {
@Override
public double discount(double subtotal) {
return 0;
}
}
class Gold implements CustomerType {
@Override
public double discount(double subtotal) {
return subtotal * 0.05;
}
}
class Company implements CustomerType {
@Override
public double discount(double subtotal) {
if (subtotal >= 500) {
return subtotal * 0.10;
}
return 0;
}
}
static String invoice(Customer customer, List<Item> items, CustomerType customerType) {
double subtotal = itemsTotal(items);
double discount = customerType.discount(subtotal);
String text = "Factura: " + customer.name() + "\n";
text = text + customer.street() + ", " + customer.postcode() + " " + customer.city() + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", (subtotal - discount) * 1.20);
return text;
}
Las pruebas
OK companyFrom500()
OK companyUnder500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 4, fallaron: 0

Esto es Strategy del módulo de patrones: la regla es un objeto aparte y la factura solo pregunta. Si el tipo de cliente cambia mientras el programa se ejecuta (pasa a ser oro tras el décimo pedido), el mismo movimiento lleva a State. El texto "gold" de la base de datos se convierte en un objeto en un solo sitio, a la entrada, con una tabla como en la lección de Strategy.

record Customer(String name, String street, String city, String postcode) {}
interface CustomerType {
double discount(double subtotal);
}
static String invoice(Customer customer, List<Item> items, CustomerType customerType) {
double subtotal = itemsTotal(items);
double discount = customerType.discount(subtotal);
String text = "Factura: " + customer.name() + "\n";
text = text + customer.street() + ", " + customer.postcode() + " " + customer.city() + "\n";
text = text + String.format("Importe: %.2f €\n", subtotal);
text = text + String.format("Descuento: %.2f €\n", discount);
text = text + String.format("Total con IVA: %.2f €", (subtotal - discount) * 1.20);
return text;
}
Paso a paso, en palabras
  1. La función antes del primer paso: 18 líneas en un solo bloque. Abajo están las cuatro pruebas y los totales de las cuatro facturas.
  2. Rename: t pasa a ser subtotal, d pasa a ser discount, s pasa a ser text. Las pruebas están en verde, los totales son los mismos.
  3. Extract Method: el bucle va a itemsTotal, la cadena de if a discountFor. En su lugar queda una línea para cada uno.
  4. Inline: afterDiscount solo restaba. Su cuerpo vuelve a la línea y la función desaparece.
  5. Introduce Parameter Object: nombre, calle, ciudad y código postal pasan a ser un solo cliente.
  6. Replace Conditional with Polymorphism: el if desaparece. Cada tipo de cliente es una clase detrás de CustomerType, y la función llama a customerType.discount(subtotal).
  7. Cinco pasos. La forma de invoice es otra, pero las cuatro pruebas y las cuatro facturas son las mismas que al principio.

El programa que imprime las cuatro facturas se ejecutó con el código de antes del primer paso y con el código después del quinto. La salida es la misma:

Salida antes y después
Factura: María Torres
Calle Mayor 5, 28013 Madrid
Importe: 33.00 €
Descuento: 0.00 €
Total con IVA: 39.60 €
Factura: Lucía Navarro
Calle Alcalá 12, 41001 Sevilla
Importe: 50.00 €
Descuento: 2.50 €
Total con IVA: 57.00 €
Factura: Panadería Amanecer S.L.
Avenida del Puerto 80, 46001 Valencia
Importe: 600.00 €
Descuento: 60.00 €
Total con IVA: 648.00 €
Factura: Panadería Amanecer S.L.
Avenida del Puerto 80, 46001 Valencia
Importe: 33.00 €
Descuento: 0.00 €
Total con IVA: 39.60 €

Pruébalo

Encuentra el error

Alguien escribe así la clase para una empresa:

class Company implements CustomerType {
@Override
public double discount(double subtotal) {
return subtotal * 0.10;
}
}

Una empresa pide café por 33 €:

Invoices.invoice(new Customer("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001"), coffee, new Company())
Salida
Factura: Panadería Amanecer S.L.
Avenida del Puerto 80, 46001 Valencia
Importe: 33.00 €
Descuento: 3.30 €
Total con IVA: 35.64 €

¿En qué línea está el error?

Elige la línea con el error.

Más precisamente, para curiosos
Las pruebas
FALLA companyUnder500()
OK companyFrom500()
OK gold()
OK regular()
Pruebas: 4, pasaron: 3, fallaron: 1

La idea

Cuándo parar

La refactorización no tiene fin si no le pones uno. Paras cuando el código es lo bastante claro para el cambio que viene. ¿La tarea es añadir socios con un 3 %? El paso 5 lo convierte en una clase nueva. ¿La tarea es otra? Quizá no necesites el paso 5.

No merece la pena cuando:

  • nadie toca ni lee el código. El código feo que funciona y no cambia no es urgente;
  • hay dos variantes y no van a crecer. Un if es más claro que una interfaz y dos clases;
  • no hay red debajo del código. Entonces el primer paso es la red, no la refactorización.

Y una regla: no mezclar. Cambias la forma o el comportamiento, nunca los dos en un mismo paso. Kent Beck lo llama «los dos sombreros»: con uno reorganizas y las pruebas no se tocan, con el otro añades y escribes una prueba nueva. Cambias de sombrero a propósito.

Ejercicio

Socios con un 3 %

Resuelve la tarea en tu editor.

Socios. La tienda añade el tipo de cliente «socio» con un 3 % de descuento en cada pedido. Empieza con el código después del paso 5. Primero escribe una prueba para un socio con el pedido de 33 €, luego la clase Partner. Las demás clases y invoice no se tocan.

Salida
Factura: María Torres
Calle Mayor 5, 28013 Madrid
Importe: 33.00 €
Descuento: 0.99 €
Total con IVA: 38.41 €

Más tareas

  • Extract Method para el texto. Extrae la línea de la dirección a un método addressLine(Customer). Ejecuta las pruebas después del paso.
  • El otro orden. Haz los cinco pasos en otro orden: primero Introduce Parameter Object y luego Rename. ¿Pasan las pruebas después de cada uno? ¿Qué paso era más fácil antes?
  • El tipo desde la base de datos. El tipo de cliente llega como texto ("gold"). Escribe la tabla que lo convierte en un objeto y decide qué pasa con un tipo desconocido. El código de hoy da un descuento de 0 para un tipo desconocido: mantenlo con una prueba.

Test

Ponte a prueba

1. ¿Cuál de estas cosas es refactorización?
2. Después de Introduce Parameter Object las pruebas no compilan, porque llaman al método con seis parámetros. ¿Qué haces?
3. ¿Cuándo es mejor Inline que Extract Method?
4. La cadena de if según el tipo de cliente se convierte en una interfaz y una clase por cada tipo. ¿Cómo se llama esto en el módulo de patrones?
5. Cinco pasos, pruebas después de cada uno. En el cuarto, una prueba falla. ¿Qué significa?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·