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 la función inv. Lleva dos años funcionando:
export function inv(n, street, city, zip, items, type) { let t = 0; for (const i of items) { t = t + i.price * i.qty; } let d = 0; if (type === "gold") { d = t * 0.05; } else if (type === "company") { if (t >= 500) { d = t * 0.1; } } let s = `Factura: ${n}\n`; s = s + `${street}, ${zip} ${city}\n`; s = s + `Importe: ${t.toFixed(2)} €\n`; s = s + `Descuento: ${d.toFixed(2)} €\n`; s = s + `Total con IVA: ${(afterDiscount(t, d) * 1.2).toFixed(2)} €`; return s;}function afterDiscount(t, 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 €.
import { test } from "node:test";import assert from "node:assert/strict";import { inv } from "./invoice.js";
const coffee = [{ name: "Café en grano", price: 12.5, qty: 2 }, { name: "Taza", price: 8, qty: 1 }];const moreCoffee = [{ name: "Café en grano", price: 12.5, qty: 4 }];const flour = [{ name: "Harina 25 kg", price: 30, qty: 20 }];
test("cliente normal", () => { assert.equal( inv("María Torres", "Calle Mayor 5", "Madrid", "28013", coffee, "regular"), "Factura: María Torres\nCalle Mayor 5, 28013 Madrid\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €", );});
test("cliente oro", () => { assert.equal( inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold"), "Factura: Lucía Navarro\nCalle Alcalá 12, 41001 Sevilla\nImporte: 50.00 €\nDescuento: 2.50 €\nTotal con IVA: 57.00 €", );});
test("empresa desde 500 €", () => { assert.equal( inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", flour, "company"), "Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 600.00 €\nDescuento: 60.00 €\nTotal con IVA: 648.00 €", );});
test("empresa por debajo de 500 €", () => { assert.equal( inv("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001", coffee, "company"), "Factura: Panadería Amanecer S.L.\nAvenida del Puerto 80, 46001 Valencia\nImporte: 33.00 €\nDescuento: 0.00 €\nTotal con IVA: 39.60 €", );});OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0Mí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.
export function invoice(name, street, city, postcode, items, customerType) { let subtotal = 0; for (const item of items) { subtotal = subtotal + item.price * item.qty; } let discount = 0; if (customerType === "gold") { discount = subtotal * 0.05; } else if (customerType === "company") { if (subtotal >= 500) { discount = subtotal * 0.1; } } let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`; return text;}OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0Rename también cambia todos los sitios que llaman a la función, incluidas las pruebas. Las facturas esperadas no se tocan.
En VS Code es F2. Para una variable local es seguro. Para una propiedad de un objeto (customer.postcode) el editor no sabe qué objeto es cuál: obj[key] o un objeto que llegó como JSON no son vínculos que pueda ver. Por eso en JavaScript, después de un cambio de nombre, las pruebas te dicen más que en Java o C#.Míralo
Paso 2: Extract Method
La función 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 una función propia, con un nombre que dice qué devuelve. Eso es Extract Method:
export function invoice(name, street, city, postcode, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`; return text;}function itemsTotal(items) { let sum = 0; for (const item of items) { sum = sum + item.price * item.qty; } return sum;}
function discountFor(customerType, subtotal) { let discount = 0; if (customerType === "gold") { discount = subtotal * 0.05; } else if (customerType === "company") { if (subtotal >= 500) { discount = subtotal * 0.1; } } return discount;}OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0Ahora 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.
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 una función que lo guarda en su propio parámetro en lugar de devolverlo:
export function invoice(name, street, city, postcode, items, customerType) { const subtotal = itemsTotal(items); const discount = 0; applyDiscount(customerType, subtotal, discount); let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`; return text;}
function applyDiscount(customerType, subtotal, discount) { if (customerType === "gold") { discount = subtotal * 0.05; } else if (customerType === "company") { if (subtotal >= 500) { discount = subtotal * 0.1; } }}Más precisamente, para curiosos
FALLA cliente oroFALLA empresa desde 500 €OK cliente normalOK empresa por debajo de 500 €Pruebas: 4, pasaron: 2, fallaron: 2discount dentro de applyDiscount es una copia del valor. El valor nuevo se queda allí dentro y desaparece cuando termina la función, 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 la función desaparece. Eso es Inline:
function afterDiscount(subtotal, discount) { return subtotal - discount;}export function invoice(name, street, city, postcode, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0Extract 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 VS Code, para una variable es Ctrl+. y «Inline variable»; una función se pone en línea a mano.
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:
invoice("María Torres", "Calle Mayor 5", "28013", "Madrid", coffee, "regular")Factura: María TorresCalle Mayor 5, Madrid 28013Importe: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €Los cuatro pasan a ser un objeto: el cliente. Eso es Introduce Parameter Object.
En JavaScript no necesitas una clase: el cliente es un objeto simple con nombres de campo,{ name, street, city, postcode }.export function invoice(customer, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${customer.name}\n`; text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0Las 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:
inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold")invoice("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", moreCoffee, "gold")invoice({ name: "Lucía Navarro", street: "Calle Alcalá 12", city: "Sevilla", postcode: "41001" }, moreCoffee, "gold")invoice({ name: "Lucía Navarro", street: "Calle Alcalá 12", city: "Sevilla", postcode: "41001" }, moreCoffee, new Gold()){ city: "Madrid", postcode: "28013" } es lo mismo que { postcode: "28013", city: "Madrid" }.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 con un método discount, y conoce su propio descuento:
export class Regular { discount(subtotal) { return 0; }}
export class Gold { discount(subtotal) { return subtotal * 0.05; }}
export class Company { discount(subtotal) { if (subtotal >= 500) { return subtotal * 0.1; } return 0; }}export function invoice(customer, items, customerType) { const subtotal = itemsTotal(items); const discount = customerType.discount(subtotal); let text = `Factura: ${customer.name}\n`; text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}OK cliente normalOK cliente oroOK empresa desde 500 €OK empresa por debajo de 500 €Pruebas: 4, pasaron: 4, fallaron: 0discount, y la factura lo llama (duck typing). Si la regla es un único cálculo, basta con una función en una tabla, como en la lección de Strategy.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.
export function invoice(customer, items, customerType) { const subtotal = itemsTotal(items); const discount = customerType.discount(subtotal); let text = `Factura: ${customer.name}\n`; text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}export function inv(n, street, city, zip, items, type) { let t = 0; for (const i of items) { t = t + i.price * i.qty; } let d = 0; if (type === "gold") { d = t * 0.05; } else if (type === "company") { if (t >= 500) { d = t * 0.1; } } let s = `Factura: ${n}\n`; s = s + `${street}, ${zip} ${city}\n`; s = s + `Importe: ${t.toFixed(2)} €\n`; s = s + `Descuento: ${d.toFixed(2)} €\n`; s = s + `Total con IVA: ${(afterDiscount(t, d) * 1.2).toFixed(2)} €`; return s;}export function invoice(name, street, city, postcode, items, customerType) { let subtotal = 0; for (const item of items) { subtotal = subtotal + item.price * item.qty; } let discount = 0; if (customerType === "gold") { discount = subtotal * 0.05; } else if (customerType === "company") { if (subtotal >= 500) { discount = subtotal * 0.1; } } let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`; return text;}export function invoice(name, street, city, postcode, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`; return text;}export function invoice(name, street, city, postcode, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${name}\n`; text = text + `${street}, ${postcode} ${city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}export function invoice(customer, items, customerType) { const subtotal = itemsTotal(items); const discount = discountFor(customerType, subtotal); let text = `Factura: ${customer.name}\n`; text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}export function invoice(customer, items, customerType) { const subtotal = itemsTotal(items); const discount = customerType.discount(subtotal); let text = `Factura: ${customer.name}\n`; text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`; text = text + `Importe: ${subtotal.toFixed(2)} €\n`; text = text + `Descuento: ${discount.toFixed(2)} €\n`; text = text + `Total con IVA: ${((subtotal - discount) * 1.2).toFixed(2)} €`; return text;}Paso a paso, en palabras
- 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.
- 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.
- Extract Method: el bucle va a itemsTotal, la cadena de if a discountFor. En su lugar queda una línea para cada uno.
- Inline: afterDiscount solo restaba. Su cuerpo vuelve a la línea y la función desaparece.
- Introduce Parameter Object: nombre, calle, ciudad y código postal pasan a ser un solo cliente.
- Replace Conditional with Polymorphism: el if desaparece. Cada tipo de cliente es una clase, y la función llama a customerType.discount(subtotal).
- 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:
Factura: María TorresCalle Mayor 5, 28013 MadridImporte: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €
Factura: Lucía NavarroCalle Alcalá 12, 41001 SevillaImporte: 50.00 €Descuento: 2.50 €Total con IVA: 57.00 €
Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 600.00 €Descuento: 60.00 €Total con IVA: 648.00 €
Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 33.00 €Descuento: 0.00 €Total con IVA: 39.60 €Pruébalo
Encuentra el error
Alguien escribe así la clase para una empresa:
export class Company { discount(subtotal) { return subtotal * 0.1; }}Una empresa pide café por 33 €:
invoice({ name: "Panadería Amanecer S.L.", street: "Avenida del Puerto 80", city: "Valencia", postcode: "46001" }, coffee, new Company())Factura: Panadería Amanecer S.L.Avenida del Puerto 80, 46001 ValenciaImporte: 33.00 €Descuento: 3.30 €Total con IVA: 35.64 €¿En qué línea está el error?
Elige la línea con el error.
Cuando la rama de la empresa pasó del if a una clase, se quedó fuera la comprobación subtotal >= 500. Una empresa por debajo de 500 € recibe un 10 %. La prueba de una empresa por debajo de 500 € falla enseguida: empresa por debajo de 500 €. Sin ella, las empresas con pedidos pequeños habrían pagado menos durante meses.
Más precisamente, para curiosos
FALLA empresa por debajo de 500 €OK cliente normalOK cliente oroOK empresa desde 500 €Pruebas: 4, pasaron: 3, fallaron: 1La 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
ifes 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.
Factura: María TorresCalle Mayor 5, 28013 MadridImporte: 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 una función 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Nombres en el código · Funciones: una tarea, pocos parámetros, sin efectos secundarios ocultos · Code smells: método largo, clase grande, duplicación, primitivos en vez de tipos, cadenas largas de if · Las pruebas como red de seguridad
- Continúa en: SOLID sin dogma · Ejercicio: una clase desordenada, limpiada paso a paso
- Ejercicios sobre el tema: Código limpio y refactorización