Ejercicio: una clase desordenada, limpiada paso a paso
En resumen: Un ejercicio de refactoring: una clase de alquiler de patinetes se limpia en cinco pasos pequeños (nombres, números mágicos, dinero, consulta separada del comando, planes) mientras los tests siguen en verde.
Problema
Los patinetes que nadie se atreve a tocar
Una empresa que alquila patinetes eléctricos cobra cada viaje con la clase RideMgr. Funciona desde hace tres años. El método calc calcula el precio según el plan del cliente (pago por uso, estudiante o abono mensual), quita la tarifa de desbloqueo a partir del cuarto viaje del día, añade un recargo nocturno, suma los ingresos y envía un recibo:
export class RideMgr { constructor() { this.c = new Map(); this.s = 0; this.out = []; }
// El precio: 1 € por desbloquear y 0,15 € por minuto. // De noche el precio sube un 50 %. calc(to, t, m, f) { this.c.set(to, (this.c.get(to) ?? 0) + 1); let p = 0; if (t === "payg") { p = 1 + m * 0.2; } else if (t === "student") { p = (1 + m * 0.2) * 0.8; } else if (t === "monthly") { if (m > 45) { p = (m - 45) * 0.2; } } if (this.c.get(to) > 3 && t !== "monthly") { p = p - 1; } if (f) { p = p + 0.5; } this.s = this.s + p; this.out.push(`${to}: ${m} min, ${p.toFixed(2)} €`); return p; }}El mes que viene llegan dos cambios:
- un plan de empresa: sin tarifa de desbloqueo, 0,18 € por minuto, sin descuento en el cuarto viaje;
- el precio antes del final: la app muestra lo que costará el viaje mientras todavía está en curso.
El segundo no se puede hacer con esta clase. Esto es lo que pasa si la app llama a calc para mostrar el precio, y luego otra vez para cobrar:
const rides = new RideMgr();const shown = rides.calc("ana@example.com", "payg", 10, false);console.log(`Precio antes del final: ${shown.toFixed(2)} €`);const charged = rides.calc("ana@example.com", "payg", 10, false);console.log(`Cobrado: ${charged.toFixed(2)} €`);console.log(`Recibos: ${rides.out.length}`);console.log(`Ingresos: ${rides.s.toFixed(2)} €`);Precio antes del final: 3.00 €Cobrado: 3.00 €Recibos: 2Ingresos: 6.00 €Un viaje, dos recibos y 6 € de ingresos. calc suena a pregunta, pero cuenta, suma y envía.
En esta lección no hay ninguna idea nueva. Es un ejercicio: la clase pasa por las herramientas del módulo una por una, en pasos pequeños, y después de cada paso ejecutas los tests.
Míralo
Paso 0: la red de seguridad
Primero la red, como en la lección sobre tests. Nueve tests fijan lo que la clase hace hoy: el precio de cada plan, la noche, el cuarto viaje, el recibo y los ingresos.
import { test, beforeEach } from "node:test";import assert from "node:assert/strict";import { RideMgr } from "./rides.js";
const EMAIL = "ana@example.com";let rides;
beforeEach(() => { rides = new RideMgr();});
function ride(plan, minutes, night) { return money(rides.calc(EMAIL, plan, minutes, night));}
function money(amount) { return amount.toFixed(2);}
test("por minuto", () => { assert.equal(ride("payg", 10, false), "3.00");});
test("estudiante", () => { assert.equal(ride("student", 10, false), "2.40");});
test("abono mensual hasta 45 min", () => { assert.equal(ride("monthly", 30, false), "0.00");});
test("abono mensual más de 45 min", () => { assert.equal(ride("monthly", 60, false), "3.00");});
test("de noche", () => { assert.equal(ride("payg", 10, true), "3.50");});
test("cuarto viaje sin cargo de desbloqueo", () => { ride("payg", 10, false); ride("payg", 10, false); ride("payg", 10, false); assert.equal(ride("payg", 10, false), "2.00");});
test("estudiante en el cuarto viaje (así es hoy)", () => { // Así es hoy: el descuento es el 1 € entero, no 0,80 €. ride("student", 10, false); ride("student", 10, false); ride("student", 10, false); assert.equal(ride("student", 10, false), "1.40");});
test("el recibo", () => { ride("payg", 10, false); assert.deepEqual(rides.out, ["ana@example.com: 10 min, 3.00 €"]);});
test("los ingresos", () => { ride("payg", 10, false); ride("student", 10, false); assert.equal(money(rides.s), "5.40");});OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0Los tests llaman a la clase solo a través del auxiliar ride. Cuando la forma de la clase cambie en los próximos pasos, cambiarán el auxiliar y las llamadas, y nunca los valores esperados.
Uno de los tests es raro. Un estudiante paga el 80 % del precio, así que desbloquear le cuesta 0,80 €. En el cuarto viaje, sin embargo, la clase quita el 1 € entero, y el estudiante paga 1,40 € en vez de 1,60 €. ¿Es un error o un regalo? No lo decides tú, y no lo decides ahora. El test fija lo que pasa hoy, con un comentario, y la pregunta va al product owner. Mientras limpias, el comportamiento no cambia, aunque parezca equivocado.
Más precisamente, para curiosos
Los importes se comparan como texto con dos decimales ("2.40"), tal como los ve el cliente. Comparar números tropezaría con los pequeños errores binarios de number:
console.log((1 + 10 * 0.2) * 0.8);2.4000000000000004Y en el paso 3 cambiará el tipo de los importes. El texto con dos decimales es el mismo antes y después.
Míralo
Paso 1: los nombres y la bandera
t, m, f, p, c y s pasan a ser palabras (nombres). El nombre más importante es el del método: no calcula, termina el viaje. El nombre honesto es finishRide, y ahora el efecto secundario se ve en el nombre. La clase pasa a llamarse RideBilling.
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receipts = []; }
// El precio: 1 € por desbloquear y 0,15 € por minuto. // De noche el precio sube un 50 %. finishRide(email, plan, minutes, { night = false } = {}) { this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); let price = 0; if (plan === "payg") { price = 1 + minutes * 0.2; } else if (plan === "student") { price = (1 + minutes * 0.2) * 0.8; } else if (plan === "monthly") { if (minutes > 45) { price = (minutes - 45) * 0.2; } } if (this.ridesToday.get(email) > 3 && plan !== "monthly") { price = price - 1; } if (night) { price = price + 0.5; } this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${price.toFixed(2)} €`); return price; }}La bandera se queda por ahora (funciones). Un true en la llamada no dice nada. La noche es un dato del viaje, no la elección de otro trabajo, así que no se convierte en dos funciones. La llamada tiene que decir lo que significa:
{ night: true }, y el día va sin él.function ride(plan, minutes, night) { return money(rides.calc(EMAIL, plan, minutes, night));}
test("de noche", () => { assert.equal(ride("payg", 10, true), "3.50");});function ride(plan, minutes, options) { return money(billing.finishRide(EMAIL, plan, minutes, options));}
test("de noche", () => { assert.equal(ride("payg", 10, { night: true }), "3.50");});OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0this.s) el editor no siempre sabe qué objeto es cuál, así que aquí los tests te dicen más que en Java o C#.Míralo
Paso 2: los números y el comentario que miente
1.0, 0.2, 0.8, 45, 3 y 0.5 pasan a ser constantes con nombre (número mágico → constante).
El comentario sobre el método dice 0,15 € por minuto y un 50 % de recargo nocturno. El código cobra 0,20 € y añade 0,50 €. ¿Qué es verdad? Los tests fijaron lo que hace el código, y los clientes llevan años pagando según él. El comentario miente (comentarios) y se borra. Las constantes dicen lo mismo y no pueden quedarse anticuadas por sí solas.
const UNLOCK_FEE = 1;const PER_MINUTE = 0.2;const STUDENT_SHARE = 0.8;const MONTHLY_FREE_MINUTES = 45;const PAID_UNLOCKS_PER_DAY = 3;const NIGHT_SURCHARGE = 0.5;
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receipts = []; }
finishRide(email, plan, minutes, { night = false } = {}) { this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); let price = 0; if (plan === "payg") { price = UNLOCK_FEE + minutes * PER_MINUTE; } else if (plan === "student") { price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE; } else if (plan === "monthly") { if (minutes > MONTHLY_FREE_MINUTES) { price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE; } } if (this.ridesToday.get(email) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") { price = price - UNLOCK_FEE; } if (night) { price = price + NIGHT_SURCHARGE; } this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${price.toFixed(2)} €`); return price; }}OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0Los tests no se tocan: ni el nombre ni las llamadas han cambiado.
Las constantes están a nivel de módulo, encima de la clase: así las ve cualquier otro código del archivo, y en el paso 5 también los planes.Pruébalo
Encuentra el error
Un compañero hace el mismo paso, pero se fía del comentario y lo deja:
Elige la línea con el error.
El precio por minuto sale del comentario: 0,15 € en vez de 0,20 €. Así el refactoring baja en silencio el precio de cada viaje. Ocho de los nueve tests fallan; solo pasa el abono mensual de hasta 45 minutos, donde los minutos no se pagan. El comentario había que borrarlo, y el número había que tomarlo del código que los tests ya vigilan.
Más precisamente, para curiosos
FALLA abono mensual más de 45 minFALLA cuarto viaje sin cargo de desbloqueoFALLA de nocheFALLA el reciboFALLA estudianteFALLA estudiante en el cuarto viaje (así es hoy)FALLA los ingresosFALLA por minutoOK abono mensual hasta 45 minPruebas: 9, pasaron: 1, fallaron: 8Míralo
Paso 3: el dinero
El dinero en un número fraccionario es primitive obsession (code smells). Hoy no da un céntimo mal en ningún test, porque cada importe se redondea solo al imprimirlo. Por eso el paso es un refactoring: la salida es la misma. Pero los ingresos son una suma de miles de importes así, y ahí es donde se acumulan los errores pequeños.
JavaScript no tiene un tipo decimal, así que el dinero pasa a ser céntimos enteros. Los nombres dicen la unidad (_CENTS), el precio de estudiante es el 80 por ciento de los céntimos, redondeado con Math.round, y la función euros convierte los céntimos en euros solo al imprimir.const UNLOCK_FEE_CENTS = 100;const PER_MINUTE_CENTS = 20;const STUDENT_PERCENT = 80;const MONTHLY_FREE_MINUTES = 45;const PAID_UNLOCKS_PER_DAY = 3;const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) { return (cents / 100).toFixed(2);}
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receipts = []; }
finishRide(email, plan, minutes, { night = false } = {}) { this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); let price = 0; if (plan === "payg") { price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS; } else if (plan === "student") { price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100); } else if (plan === "monthly") { if (minutes > MONTHLY_FREE_MINUTES) { price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS; } } if (this.ridesToday.get(email) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") { price = price - UNLOCK_FEE_CENTS; } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${euros(price)} €`); return price; }}function money(cents) { return (cents / 100).toFixed(2);}OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0Pruébalo
Completa
El precio por minuto en el tipo para dinero del lenguaje:
const PER_MINUTE_CENTS = ;Míralo
Paso 4: la consulta separada del comando
Ahora el cambio que esperaba: el precio antes del final tiene que ser una consulta que no cambia nada (comando o consulta). El cálculo se extrae a un método price (Extract Method de la lección de refactoring). finishRide sigue siendo un comando: pide el precio, y luego cuenta, suma y envía.
Un compañero lo hace así. El contador se mueve detrás del precio, porque un viaje cuenta cuando ha terminado:
¿Qué dicen los nueve tests?
price(email, plan, minutes, { night = false } = {}) { let price = 0; if (plan === "payg") { price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS; } else if (plan === "student") { price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100); } else if (plan === "monthly") { if (minutes > MONTHLY_FREE_MINUTES) { price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS; } } if ((this.ridesToday.get(email) ?? 0) > PAID_UNLOCKS_PER_DAY && plan !== "monthly") { price = price - UNLOCK_FEE_CENTS; } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } return price;}
finishRide(email, plan, minutes, { night = false } = {}) { const price = this.price(email, plan, minutes, { night }); this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${euros(price)} €`); return price;}En el antiguo calc el contador subía antes de la comprobación, así que "más de 3" significaba "este es el cuarto viaje". Ahora price pregunta antes de contar, y el contador es uno menos. El cuarto viaje pasa por el tercero y paga la tarifa de desbloqueo. Los demás tests pasan, porque nunca llegan a un cuarto viaje.
Más precisamente, para curiosos
FALLA cuarto viaje sin cargo de desbloqueo: 3.00 !== 2.00FALLA estudiante en el cuarto viaje (así es hoy): 2.40 !== 1.40OK abono mensual hasta 45 minOK abono mensual más de 45 minOK de nocheOK el reciboOK estudianteOK los ingresosOK por minutoPruebas: 9, pasaron: 7, fallaron: 2Míralo
Paso 4, segundo intento
El rojo apunta al último paso. No buscas el error en toda la clase: deshaces el paso y lo repites. El orden de los efectos secundarios era una parte oculta del comportamiento, y solo los tests lo sabían.
price calcula qué viaje del día será este:
price(email, plan, minutes, { night = false } = {}) { const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1; let price = 0; if (plan === "payg") { price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS; } else if (plan === "student") { price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100); } else if (plan === "monthly") { if (minutes > MONTHLY_FREE_MINUTES) { price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS; } } if (rideOfDay > PAID_UNLOCKS_PER_DAY && plan !== "monthly") { price = price - UNLOCK_FEE_CENTS; } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } return price;}
finishRide(email, plan, minutes, { night = false } = {}) { const price = this.price(email, plan, minutes, { night }); this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${euros(price)} €`); return price;}OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0Los tests no se vuelven a tocar: llaman a finishRide, y este hace lo mismo que antes.
La clase entera después del paso 4
const UNLOCK_FEE_CENTS = 100;const PER_MINUTE_CENTS = 20;const STUDENT_PERCENT = 80;const MONTHLY_FREE_MINUTES = 45;const PAID_UNLOCKS_PER_DAY = 3;const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) { return (cents / 100).toFixed(2);}
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receipts = []; }
price(email, plan, minutes, { night = false } = {}) { const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1; let price = 0; if (plan === "payg") { price = UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS; } else if (plan === "student") { price = Math.round(((UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS) * STUDENT_PERCENT) / 100); } else if (plan === "monthly") { if (minutes > MONTHLY_FREE_MINUTES) { price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS; } } if (rideOfDay > PAID_UNLOCKS_PER_DAY && plan !== "monthly") { price = price - UNLOCK_FEE_CENTS; } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } return price; }
finishRide(email, plan, minutes, { night = false } = {}) { const price = this.price(email, plan, minutes, { night }); this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${euros(price)} €`); return price; }}Míralo
Paso 5: los planes
Lo que queda es la cadena de if sobre el texto del plan (cadena larga de if). Cada plan nuevo es una rama nueva, y "monthly" se comprueba en dos sitios. Cada plan pasa a ser una clase que conoce su precio y su descuento del cuarto viaje. Esto es Replace Conditional with Polymorphism de la lección de refactoring:
export class PayAsYouGo { price(minutes) { return UNLOCK_FEE_CENTS + minutes * PER_MINUTE_CENTS; }
loyaltyDiscount() { return UNLOCK_FEE_CENTS; }}
export class Student { price(minutes) { return Math.round((new PayAsYouGo().price(minutes) * STUDENT_PERCENT) / 100); }
loyaltyDiscount() { // Así es hoy: el 1 € entero, aunque desbloquear le cuesta 0,80 € a un estudiante. return UNLOCK_FEE_CENTS; }}
export class Monthly { price(minutes) { return Math.max(0, minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE_CENTS; }
loyaltyDiscount() { return 0; }}La comprobación del abono mensual desaparece: simplemente devuelve un descuento de 0. El comportamiento raro del estudiante queda ahora en una línea, con un comentario que dice por qué es así (el buen comentario de la lección sobre comentarios).
const UNLOCK_FEE_CENTS = 100;const PER_MINUTE_CENTS = 20;const STUDENT_PERCENT = 80;const MONTHLY_FREE_MINUTES = 45;const PAID_UNLOCKS_PER_DAY = 3;const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) { return (cents / 100).toFixed(2);}
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receipts = []; }
price(email, plan, minutes, { night = false } = {}) { const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1; let price = plan.price(minutes); if (rideOfDay > PAID_UNLOCKS_PER_DAY) { price = price - plan.loyaltyDiscount(); } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } return price; }
finishRide(email, plan, minutes, { night = false } = {}) { const price = this.price(email, plan, minutes, { night }); this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); this.revenue = this.revenue + price; this.receipts.push(`${email}: ${minutes} min, ${euros(price)} €`); return price; }}En los tests las llamadas pasan un objeto en vez de texto:
assert.equal(ride("payg", 10, { night: true }), "3.50");assert.equal(ride(new PayAsYouGo(), 10, { night: true }), "3.50");OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0Esto es Strategy del módulo de patrones de diseño: la regla es un objeto aparte, y el cobro solo pregunta.
Míralo
Los dos cambios ahora
El plan de empresa es una clase nueva. La clase de cobro no se toca (abierto a la extensión):
const BUSINESS_PER_MINUTE_CENTS = 18;
export class Business { price(minutes) { return minutes * BUSINESS_PER_MINUTE_CENTS; }
loyaltyDiscount() { return 0; }}El precio antes del final es la consulta price. Las veces que la llames, no se cuenta nada y no sale ningún recibo:
const billing = new RideBilling();const payg = new PayAsYouGo();billing.finishRide("ana@example.com", payg, 10);billing.finishRide("ana@example.com", payg, 5);billing.finishRide("ana@example.com", payg, 12, { night: true });console.log(`Precio antes del final: ${(billing.price("ana@example.com", payg, 8) / 100).toFixed(2)} €`);console.log(`Precio antes del final: ${(billing.price("ana@example.com", payg, 8) / 100).toFixed(2)} €`);console.log(`Cobrado: ${(billing.finishRide("ana@example.com", payg, 8) / 100).toFixed(2)} €`);console.log(`Empresa, 12 min: ${(billing.finishRide("office@example.com", new Business(), 12) / 100).toFixed(2)} €`);console.log(`Recibos: ${billing.receipts.length}`);Precio antes del final: 1.60 €Precio antes del final: 1.60 €Cobrado: 1.60 €Empresa, 12 min: 2.16 €Recibos: 5Cinco recibos: tres viajes antes de la vista previa, el cuarto y el de la empresa. Las dos vistas previas no dejan rastro, y muestran exactamente lo que se cobra después.
La idea
Antes y después
| Smell | Paso | Lección |
|---|---|---|
| nada protege el comportamiento | 0: tests que fijan el comportamiento de hoy | la red de seguridad |
nombres de una letra, calc que no dice que envía |
1: Rename | nombres |
true en la llamada |
1: un argumento que dice lo que significa | funciones |
| números mágicos | 2: constantes con nombre | nombres |
| un comentario que miente | 2: borrado | comentarios |
| dinero en un número fraccionario | 3: un tipo para dinero | code smells |
| una consulta con un efecto secundario oculto, un método largo | 4: Extract Method, comando y consulta | funciones, refactoring |
una cadena de if sobre el plan como texto |
5: Replace Conditional with Polymorphism | code smells, refactoring, SOLID |
La clase se hizo más larga, no más corta. Pero cada regla está en un solo lugar y tiene nombre, y cada cambio del principio es una clase.
El día de viajes, ejecutado con la clase de antes del paso 1 y con la clase de después del paso 5, imprime lo mismo línea por línea. Aquí los dos programas se ejecutan en cada comprobación del sitio.
const rides = new RideMgr();rides.calc("ana@example.com", "payg", 10, false);rides.calc("ivan@example.com", "student", 25, false);rides.calc("ana@example.com", "payg", 5, false);rides.calc("maria@example.com", "monthly", 60, true);rides.calc("ana@example.com", "payg", 12, true);rides.calc("ana@example.com", "payg", 8, false);
for (const receipt of rides.out) { console.log(receipt);}console.log(`Ingresos: ${rides.s.toFixed(2)} €`);const billing = new RideBilling();const payg = new PayAsYouGo();billing.finishRide("ana@example.com", payg, 10);billing.finishRide("ivan@example.com", new Student(), 25);billing.finishRide("ana@example.com", payg, 5);billing.finishRide("maria@example.com", new Monthly(), 60, { night: true });billing.finishRide("ana@example.com", payg, 12, { night: true });billing.finishRide("ana@example.com", payg, 8);
for (const receipt of billing.receipts) { console.log(receipt);}console.log(`Ingresos: ${(billing.revenue / 100).toFixed(2)} €`);ana@example.com: 10 min, 3.00 €ivan@example.com: 25 min, 4.80 €ana@example.com: 5 min, 2.00 €maria@example.com: 60 min, 3.50 €ana@example.com: 12 min, 3.90 €ana@example.com: 8 min, 1.60 €Ingresos: 18.80 €La idea
Dónde parar
Te detienes cuando los cambios que vienen se vuelven fáciles. Aquí los dos cambios son una clase y una línea. Por eso algunos pasos se dejan sin hacer a propósito:
- el recibo en su propia clase. El texto del recibo es una segunda razón para cambiar la clase (una sola razón para cambiar). Hoy no cambia. En cuanto marketing empiece a editarlo cada mes, pasa a ser una clase aparte (tarea 1);
- el envío detrás de una interfaz. Los recibos son una lista, no un correo de verdad. Cuando llegue el correo real, el test no debe enviarlo, y entonces llega la abstracción (depender de una abstracción);
- el comportamiento del estudiante. Es un cambio de comportamiento, no una limpieza. Se hace aparte, con su propio test, cuando el producto decida.
Y la regla de la lección de refactoring: cambias la forma o el comportamiento, nunca los dos en un mismo paso.
Ejercicio
Sigue tú solo
Resuelve las tareas en tu editor, partiendo de la clase después del paso 5. Después de cada paso, ejecuta los nueve tests.
1. El recibo en su propia clase. Mueve el texto del recibo y la lista de recibos enviados a una clase ReceiptBook. La clase de cobro solo le dice "send". Los tests no se tocan.
Una solución posible
class ReceiptBook { constructor() { this.sent = []; }
send(email, minutes, price) { this.sent.push(`${email}: ${minutes} min, ${euros(price)} €`); }}const UNLOCK_FEE_CENTS = 100;const PER_MINUTE_CENTS = 20;const STUDENT_PERCENT = 80;const MONTHLY_FREE_MINUTES = 45;const PAID_UNLOCKS_PER_DAY = 3;const NIGHT_SURCHARGE_CENTS = 50;
function euros(cents) { return (cents / 100).toFixed(2);}
export class RideBilling { constructor() { this.ridesToday = new Map(); this.revenue = 0; this.receiptBook = new ReceiptBook(); }
price(email, plan, minutes, { night = false } = {}) { const rideOfDay = (this.ridesToday.get(email) ?? 0) + 1; let price = plan.price(minutes); if (rideOfDay > PAID_UNLOCKS_PER_DAY) { price = price - plan.loyaltyDiscount(); } if (night) { price = price + NIGHT_SURCHARGE_CENTS; } return price; }
finishRide(email, plan, minutes, { night = false } = {}) { const price = this.price(email, plan, minutes, { night }); this.ridesToday.set(email, (this.ridesToday.get(email) ?? 0) + 1); this.revenue = this.revenue + price; this.receiptBook.send(email, minutes, price); return price; }
get receipts() { return this.receiptBook.sent; }}OK abono mensual hasta 45 minOK abono mensual más de 45 minOK cuarto viaje sin cargo de desbloqueoOK de nocheOK el reciboOK estudianteOK estudiante en el cuarto viaje (así es hoy)OK los ingresosOK por minutoPruebas: 9, pasaron: 9, fallaron: 0El programa del día de la tarjeta "Antes y después" imprime la misma salida con esta clase.
2. El plan desde texto. El plan llega de la base de datos como texto ("payg"). Escribe la tabla que lo convierte en un objeto. ¿Qué hacía la clase vieja con un plan desconocido? Fíjalo con un test sobre la clase vieja antes de decidir qué debe hacer la nueva.
3. La noche desde la hora. La app ya no sabe cuándo es de noche; pasa la hora de inicio. La regla "de las 22:00 a las 6:00" tiene que vivir en un solo lugar. Escribe primero los tests para las 21:59, 22:00, 5:59 y 6:00.
4. El estudiante. El producto ha decidido: en el cuarto viaje un estudiante recibe 0,80 €, no 1 €. Es un cambio de comportamiento: primero cambia el valor esperado en el test y míralo en rojo, y luego cambia una línea del plan.
Test