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 la función inv. Lleva dos años funcionando:

def inv(n, street, city, zip, items, type):
t = 0
for i in items:
t = t + i.price * i.qty
d = 0
if type == "gold":
d = t * 0.05
elif type == "company":
if t >= 500:
d = t * 0.10
s = "Factura: " + n + "\n"
s = s + street + ", " + zip + " " + city + "\n"
s = s + f"Importe: {t:.2f} €\n"
s = s + f"Descuento: {d:.2f} €\n"
s = s + f"Total con IVA: {after_discount(t, d) * 1.20:.2f} €"
return s
def after_discount(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 after_discount 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 €.

COFFEE = [Item("Café en grano", 12.50, 2), Item("Taza", 8.00, 1)]
MORE_COFFEE = [Item("Café en grano", 12.50, 4)]
FLOUR = [Item("Harina 25 kg", 30.00, 20)]
class InvoiceTest(unittest.TestCase):
def test_regular(self):
self.assertEqual(
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 €",
)
def test_gold(self):
self.assertEqual(
inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold"),
"Factura: Lucía Navarro\nCalle Alcalá 12, 41001 Sevilla\nImporte: 50.00 €\nDescuento: 2.50 €\nTotal con IVA: 57.00 €",
)
def test_company_from_500(self):
self.assertEqual(
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 €",
)
def test_company_under_500(self):
self.assertEqual(
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 €",
)
Resultado
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_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 y type también son nombres de funciones integradas de Python, que los parámetros ocultan. Pasan a ser postcode y customer_type.

def invoice(name, street, city, postcode, items, customer_type):
subtotal = 0
for item in items:
subtotal = subtotal + item.price * item.qty
discount = 0
if customer_type == "gold":
discount = subtotal * 0.05
elif customer_type == "company":
if subtotal >= 500:
discount = subtotal * 0.10
text = "Factura: " + name + "\n"
text = text + street + ", " + postcode + " " + city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €"
return text
Las pruebas
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Pruebas: 4, pasaron: 4, fallaron: 0

Rename también cambia todos los sitios que llaman a la función, incluidas las pruebas. Las facturas esperadas no se tocan.

En PyCharm es Mayús+F6, en VS Code es F2. Sin tipos, sin embargo, el IDE a veces adivina: un nombre dentro de una cadena, getattr(obj, "name") o una clave de diccionario no son vínculos que pueda ver. Por eso en Python, 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:

def invoice(name, street, city, postcode, items, customer_type):
subtotal = items_total(items)
discount = discount_for(customer_type, subtotal)
text = "Factura: " + name + "\n"
text = text + street + ", " + postcode + " " + city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €"
return text
def items_total(items):
total = 0
for item in items:
total = total + item.price * item.qty
return total
def discount_for(customer_type, subtotal):
discount = 0
if customer_type == "gold":
discount = subtotal * 0.05
elif customer_type == "company":
if subtotal >= 500:
discount = subtotal * 0.10
return discount
Las pruebas
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_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 PyCharm: selecciona las líneas y pulsa Ctrl+Alt+M. En VS Code: Ctrl+. y «Extract method».

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:

def invoice(name, street, city, postcode, items, customer_type):
subtotal = items_total(items)
discount = 0
apply_discount(customer_type, subtotal, discount)
text = "Factura: " + name + "\n"
text = text + street + ", " + postcode + " " + city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {after_discount(subtotal, discount) * 1.20:.2f} €"
return text
def apply_discount(customer_type, subtotal, discount):
if customer_type == "gold":
discount = subtotal * 0.05
elif customer_type == "company":
if subtotal >= 500:
discount = subtotal * 0.10
Respuesta
Más precisamente, para curiosos
Resultado
FALLA test_company_from_500
FALLA test_gold
OK test_company_under_500
OK test_regular
Pruebas: 4, pasaron: 2, fallaron: 2

discount dentro de apply_discount 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. after_discount 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:

def after_discount(subtotal, discount):
return subtotal - discount
def invoice(name, street, city, postcode, items, customer_type):
subtotal = items_total(items)
discount = discount_for(customer_type, subtotal)
text = "Factura: " + name + "\n"
text = text + street + ", " + postcode + " " + city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €"
return text
Las pruebas
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_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 (items_total). Lo devuelves a su sitio cuando el nombre es solo un salto (after_discount). En PyCharm 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:

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.

@dataclass escribe __init__ y la comparación:

@dataclass(frozen=True)
class Customer:
name: str
street: str
city: str
postcode: str
def invoice(customer, items, customer_type):
subtotal = items_total(items)
discount = discount_for(customer_type, subtotal)
text = "Factura: " + customer.name + "\n"
text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €"
return text
Las pruebas
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_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:

inv("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold")
invoice("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001", MORE_COFFEE, "gold")
invoice(Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), MORE_COFFEE, "gold")
invoice(Customer("Lucía Navarro", "Calle Alcalá 12", "Sevilla", "41001"), MORE_COFFEE, Gold())
En Python tanto el constructor de la dataclass como las funciones aceptan argumentos por nombre: Customer(name="...", city="...", ...). Entonces el orden no importa.

Míralo

Paso 5: Replace Conditional with Polymorphism

Lo que queda es la cadena de if según el tipo de cliente en discount_for. 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:

class Regular:
def discount(self, subtotal):
return 0
class Gold:
def discount(self, subtotal):
return subtotal * 0.05
class Company:
def discount(self, subtotal):
if subtotal >= 500:
return subtotal * 0.10
return 0
def invoice(customer, items, customer_type):
subtotal = items_total(items)
discount = customer_type.discount(subtotal)
text = "Factura: " + customer.name + "\n"
text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €"
return text
Las pruebas
OK test_company_from_500
OK test_company_under_500
OK test_gold
OK test_regular
Pruebas: 4, pasaron: 4, fallaron: 0
Aquí no hay interfaz: cada clase simplemente tiene un método discount, 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.

@dataclass(frozen=True)
class Customer:
name: str
street: str
city: str
postcode: str
def invoice(customer, items, customer_type):
subtotal = items_total(items)
discount = customer_type.discount(subtotal)
text = "Factura: " + customer.name + "\n"
text = text + customer.street + ", " + customer.postcode + " " + customer.city + "\n"
text = text + f"Importe: {subtotal:.2f} €\n"
text = text + f"Descuento: {discount:.2f} €\n"
text = text + f"Total con IVA: {(subtotal - discount) * 1.20:.2f} €"
return text
Paso a paso, en palabras
  1. La función antes del primer paso: 15 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 items_total, la cadena de if a discount_for. En su lugar queda una línea para cada uno.
  4. Inline: after_discount 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, y la función llama a customer_type.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:
def discount(self, subtotal):
return subtotal * 0.10

Una empresa pide café por 33 €:

invoice(Customer("Panadería Amanecer S.L.", "Avenida del Puerto 80", "Valencia", "46001"), COFFEE, 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 test_company_under_500
OK test_company_from_500
OK test_gold
OK test_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 una función address_line(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 ·