Zum Inhalt
wannacode

Refactoring in kleinen Schritten

Kurz gesagt: Refactoring ändert die Struktur von Code, ohne sein Verhalten zu ändern, in kleinen benannten Schritten wie Rename und Extract Method, mit grünen Tests nach jedem.

Problem

Die Rechnung, die niemand anfassen will

Der Online-Shop druckt Rechnungen mit der Funktion inv. Sie läuft seit zwei Jahren:

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 = `Rechnung: ${n}\n`;
s = s + `${street}, ${zip} ${city}\n`;
s = s + `Summe: ${t.toFixed(2)} €\n`;
s = s + `Rabatt: ${d.toFixed(2)} €\n`;
s = s + `Gesamt inkl. MwSt.: ${(afterDiscount(t, d) * 1.2).toFixed(2)} €`;
return s;
}
function afterDiscount(t, d) {
return t - d;
}

Was ist t? Und d? Sechs Parameter hintereinander, vier davon Text: Wer Stadt und Postleitzahl vertauscht, bekommt eine Rechnung mit „Hamburg 20095“ an der falschen Stelle, und der Compiler sagt nichts. Die Kette von if über die Kundenart wächst weiter: Nächsten Monat kommen Partner mit 3 %. Und afterDiscount macht eine einzige Subtraktion, zwingt dich aber, woanders hinzuspringen, um sie zu sehen.

Du willst aufräumen. Aber die Rechnungen gehen an die Buchhaltung, und ein Fehler darin ist ein Fehler in den Steuern.

Die Idee

Refactoring: Die Form ändert sich, das Verhalten nicht

Refactoring ist eine Änderung an der Form des Codes, bei der das Verhalten gleich bleibt: Dieselben Eingaben ergeben dieselben Ausgaben. Es ist kein „Neuschreiben“. Es ist auch kein „ich habe nebenbei noch einen Fehler behoben“. Das sind andere Änderungen, die getrennt gemacht werden.

Es geschieht in kleinen benannten Schritten. Jeder Schritt ist so klein, dass er kaum schiefgehen kann, und nach jedem führst du die Tests aus (das Sicherheitsnetz aus der vorherigen Lektion). Die Namen stammen aus dem Katalog von Martin Fowler und sind in jeder Sprache und jeder IDE gleich: Rename, Extract Method, Inline, Introduce Parameter Object, Replace Conditional with Polymorphism.

Zuerst kommt das Netz. Vier Tests halten die vier Arten von Rechnung fest, die der Shop heute druckt: ein normaler Kunde, ein Goldkunde, eine Firma über und unter 500 €.

import { test } from "node:test";
import assert from "node:assert/strict";
import { inv } from "./invoice.js";
const coffee = [{ name: "Kaffeebohnen", price: 12.5, qty: 2 }, { name: "Tasse", price: 8, qty: 1 }];
const moreCoffee = [{ name: "Kaffeebohnen", price: 12.5, qty: 4 }];
const flour = [{ name: "Mehl 25 kg", price: 30, qty: 20 }];
test("normaler Kunde", () => {
assert.equal(
inv("Maria Becker", "Lindenstraße 5", "Hamburg", "20095", coffee, "regular"),
"Rechnung: Maria Becker\nLindenstraße 5, 20095 Hamburg\nSumme: 33.00 €\nRabatt: 0.00 €\nGesamt inkl. MwSt.: 39.60 €",
);
});
test("Goldkunde", () => {
assert.equal(
inv("Jonas Keller", "Bahnhofstraße 12", "München", "80331", moreCoffee, "gold"),
"Rechnung: Jonas Keller\nBahnhofstraße 12, 80331 München\nSumme: 50.00 €\nRabatt: 2.50 €\nGesamt inkl. MwSt.: 57.00 €",
);
});
test("Firma ab 500 €", () => {
assert.equal(
inv("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109", flour, "company"),
"Rechnung: Bäckerei Morgenrot GmbH\nHauptstraße 80, 04109 Leipzig\nSumme: 600.00 €\nRabatt: 60.00 €\nGesamt inkl. MwSt.: 648.00 €",
);
});
test("Firma unter 500 €", () => {
assert.equal(
inv("Bäckerei Morgenrot GmbH", "Hauptstraße 80", "Leipzig", "04109", coffee, "company"),
"Rechnung: Bäckerei Morgenrot GmbH\nHauptstraße 80, 04109 Leipzig\nSumme: 33.00 €\nRabatt: 0.00 €\nGesamt inkl. MwSt.: 39.60 €",
);
});
Ergebnis
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Sieh es dir an

Schritt 1: Rename

Ein-Buchstaben-Namen werden zu Wörtern: t wird subtotal, d wird discount, s wird text, inv wird invoice. zip wird zu postcode, und type wird zu 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 = `Rechnung: ${name}\n`;
text = text + `${street}, ${postcode} ${city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${(afterDiscount(subtotal, discount) * 1.2).toFixed(2)} €`;
return text;
}
Die Tests
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Rename ändert auch jede Stelle, die die Funktion aufruft, die Tests eingeschlossen. Die erwarteten Rechnungen werden nicht angefasst.

In VS Code ist es F2. Bei einer lokalen Variable ist es sicher. Bei der Property eines Objekts (customer.postcode) weiß der Editor nicht, welches Objekt welches ist: obj[key] oder ein Objekt, das als JSON hereinkam, sind keine Verbindungen, die er sehen kann. Deshalb sagen dir in JavaScript nach einem Rename die Tests mehr als in Java oder C#.

Sieh es dir an

Schritt 2: Extract Method

Die Funktion macht zwei getrennte Aufgaben, die mit dem Rechnungstext nichts zu tun haben: die Summe der Positionen und den Rabatt. Jede wandert in eine eigene Funktion mit einem Namen, der sagt, was sie zurückgibt. Das ist Extract Method:

export function invoice(name, street, city, postcode, items, customerType) {
const subtotal = itemsTotal(items);
const discount = discountFor(customerType, subtotal);
let text = `Rechnung: ${name}\n`;
text = text + `${street}, ${postcode} ${city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${(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;
}
Die Tests
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Jetzt liest sich invoice von oben nach unten wie eine Liste: wie viel es ist, wie hoch der Rabatt ist, wie der Text lautet. Die Einzelheiten sind einen Sprung entfernt, nur wenn du sie brauchst.

In VS Code: Zeilen markieren, Strg+. drücken und „Extract to function in module scope“ wählen.

Die IDE schaut, welche Variablen in die markierten Zeilen hineingehen und welche herauskommen, und bildet daraus die Parameter und den Rückgabewert. Wenn du den Schritt von Hand machst, geht genau hier etwas schief.

Probier es aus

Sage die Ausgabe voraus

Jemand macht Extract Method von Hand. Der Rabatt wandert in eine Funktion, die ihn in ihrem eigenen Parameter speichert, statt ihn zurückzugeben:

export function invoice(name, street, city, postcode, items, customerType) {
const subtotal = itemsTotal(items);
const discount = 0;
applyDiscount(customerType, subtotal, discount);
let text = `Rechnung: ${name}\n`;
text = text + `${street}, ${postcode} ${city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${(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;
}
}
}
Antwort
Genauer, für Neugierige
Ergebnis
FEHLERFirma ab 500 €
FEHLERGoldkunde
OK Firma unter 500 €
OK normaler Kunde
Tests: 4, bestanden: 2, fehlgeschlagen: 2

discount in applyDiscount ist eine Kopie des Wertes. Der neue Wert bleibt dort drin und verschwindet, wenn die Funktion endet, und draußen ist discount weiterhin 0. Wo der Rabatt ohnehin 0 ist (ein normaler Kunde, eine Firma unter 500 €), laufen die Tests durch. Deshalb hat das Netz für jeden Zweig des if einen Test. Der extrahierte Code muss den Wert zurückgeben, so wie es die IDE macht.

Sieh es dir an

Schritt 3: Inline

Das Gegenteil von Extract Method. afterDiscount macht eine Subtraktion, und sein Name sagt nicht mehr als subtotal - discount. Es wird an einer einzigen Stelle benutzt. Der Rumpf wandert dorthin, wo der Aufruf steht, und die Funktion verschwindet. Das ist 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 = `Rechnung: ${name}\n`;
text = text + `${street}, ${postcode} ${city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${((subtotal - discount) * 1.2).toFixed(2)} €`;
return text;
}
Die Tests
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Extract Method und Inline widersprechen sich nicht. Du extrahierst, wenn der Name etwas erklärt, was der Code nicht sagt (itemsTotal). Du setzt es zurück, wenn der Name nur ein Sprung ist (afterDiscount). In VS Code ist es bei einer Variable Strg+. und „Inline variable“; eine Funktion wird von Hand inline gesetzt.

Sieh es dir an

Schritt 4: Introduce Parameter Object

Der Name, die Straße, die Stadt und die Postleitzahl reisen immer zusammen, und doch sind es vier Parameter hintereinander. Eine Vertauschung fällt nicht auf:

invoice("Maria Becker", "Lindenstraße 5", "20095", "Hamburg", coffee, "regular")
Ausgabe
Rechnung: Maria Becker
Lindenstraße 5, Hamburg 20095
Summe: 33.00 €
Rabatt: 0.00 €
Gesamt inkl. MwSt.: 39.60 €

Die vier werden zu einem Objekt: dem Kunden. Das ist Introduce Parameter Object.

In JavaScript brauchst du keine Klasse: Der Kunde ist ein einfaches Objekt mit Feldnamen, { name, street, city, postcode }.
export function invoice(customer, items, customerType) {
const subtotal = itemsTotal(items);
const discount = discountFor(customerType, subtotal);
let text = `Rechnung: ${customer.name}\n`;
text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${((subtotal - discount) * 1.2).toFixed(2)} €`;
return text;
}
Die Tests
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Die Aufrufe in den Tests ändern sich mit der Form, und die erwarteten Rechnungen bleiben gleich. Hier ist ein Aufruf durch die Schritte:

inv("Jonas Keller", "Bahnhofstraße 12", "München", "80331", moreCoffee, "gold")
invoice("Jonas Keller", "Bahnhofstraße 12", "München", "80331", moreCoffee, "gold")
invoice({ name: "Jonas Keller", street: "Bahnhofstraße 12", city: "München", postcode: "80331" }, moreCoffee, "gold")
invoice({ name: "Jonas Keller", street: "Bahnhofstraße 12", city: "München", postcode: "80331" }, moreCoffee, new Gold())
In JavaScript hat das Objekt Feldnamen, und es gibt keine Stellen zum Vertauschen: { city: "Hamburg", postcode: "20095" } ist dasselbe wie { postcode: "20095", city: "Hamburg" }.

Sieh es dir an

Schritt 5: Replace Conditional with Polymorphism

Übrig bleibt die Kette von if über die Kundenart in discountFor. Jede neue Art ist hier ein neuer Zweig, und morgen auch an anderen Stellen (die E-Mail, der Bericht). Jede Art wird zu einer Klasse mit einer Methode discount und kennt ihren eigenen Rabatt:

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 = `Rechnung: ${customer.name}\n`;
text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${((subtotal - discount) * 1.2).toFixed(2)} €`;
return text;
}
Die Tests
OK Firma ab 500 €
OK Firma unter 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 4, fehlgeschlagen: 0
Hier gibt es kein Interface: Jede Klasse hat einfach eine Methode discount, und die Rechnung ruft sie auf (Duck Typing). Wenn die Regel eine einzige Berechnung ist, genügt eine Funktion in einer Tabelle, wie in der Lektion über Strategy.

Das ist Strategy aus dem Modul über Entwurfsmuster: Die Regel ist ein eigenes Objekt, und die Rechnung fragt nur. Wenn sich die Kundenart ändert, während das Programm läuft (nach der zehnten Bestellung wird er zum Goldkunden), führt derselbe Schritt zu State. Der Text "gold" aus der Datenbank wird an einer Stelle zu einem Objekt, am Eingang, mit einer Tabelle wie in der Lektion über Strategy.

export function invoice(customer, items, customerType) {
const subtotal = itemsTotal(items);
const discount = customerType.discount(subtotal);
let text = `Rechnung: ${customer.name}\n`;
text = text + `${customer.street}, ${customer.postcode} ${customer.city}\n`;
text = text + `Summe: ${subtotal.toFixed(2)} €\n`;
text = text + `Rabatt: ${discount.toFixed(2)} €\n`;
text = text + `Gesamt inkl. MwSt.: ${((subtotal - discount) * 1.2).toFixed(2)} €`;
return text;
}
Schritt für Schritt, in Worten
  1. Die Funktion vor dem ersten Schritt: 18 Zeilen in einem Block. Unten stehen die vier Tests und die Summen der vier Rechnungen.
  2. Rename: t wird zu subtotal, d wird zu discount, s wird zu text. Die Tests sind grün, die Summen bleiben gleich.
  3. Extract Method: Die Schleife wandert nach itemsTotal, die if-Kette nach discountFor. An ihrer Stelle bleibt je eine Zeile.
  4. Inline: afterDiscount hat nur abgezogen. Sein Rumpf kehrt in die Zeile zurück, und die Funktion verschwindet.
  5. Introduce Parameter Object: Name, Straße, Stadt und Postleitzahl werden zu einem Kunden.
  6. Replace Conditional with Polymorphism: Das if verschwindet. Jede Kundenart ist eine Klasse, und die Funktion ruft customerType.discount(subtotal) auf.
  7. Fünf Schritte. Die Form von invoice ist eine andere, aber die vier Tests und die vier Rechnungen sind dieselben wie am Anfang.

Das Programm, das die vier Rechnungen druckt, lief mit dem Code von vor dem ersten Schritt und mit dem Code nach dem fünften. Die Ausgabe ist dieselbe:

Ausgabe vorher und nachher
Rechnung: Maria Becker
Lindenstraße 5, 20095 Hamburg
Summe: 33.00 €
Rabatt: 0.00 €
Gesamt inkl. MwSt.: 39.60 €
Rechnung: Jonas Keller
Bahnhofstraße 12, 80331 München
Summe: 50.00 €
Rabatt: 2.50 €
Gesamt inkl. MwSt.: 57.00 €
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 600.00 €
Rabatt: 60.00 €
Gesamt inkl. MwSt.: 648.00 €
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 33.00 €
Rabatt: 0.00 €
Gesamt inkl. MwSt.: 39.60 €

Probier es aus

Finde den Fehler

Jemand schreibt die Klasse für eine Firma so:

export class Company {
discount(subtotal) {
return subtotal * 0.1;
}
}

Eine Firma bestellt Kaffee für 33 €:

invoice({ name: "Bäckerei Morgenrot GmbH", street: "Hauptstraße 80", city: "Leipzig", postcode: "04109" }, coffee, new Company())
Ausgabe
Rechnung: Bäckerei Morgenrot GmbH
Hauptstraße 80, 04109 Leipzig
Summe: 33.00 €
Rabatt: 3.30 €
Gesamt inkl. MwSt.: 35.64 €

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige
Die Tests
FEHLERFirma unter 500 €
OK Firma ab 500 €
OK Goldkunde
OK normaler Kunde
Tests: 4, bestanden: 3, fehlgeschlagen: 1

Die Idee

Wann du aufhörst

Refactoring hat kein Ende, wenn du ihm keins setzt. Du hörst auf, wenn der Code für die kommende Änderung klar genug ist. Die Aufgabe ist, Partner mit 3 % zu ergänzen? Schritt 5 macht daraus eine neue Klasse. Die Aufgabe ist eine andere? Vielleicht brauchst du Schritt 5 gar nicht.

Es lohnt sich nicht, wenn:

  • niemand den Code anfasst oder liest. Hässlicher Code, der funktioniert und sich nicht ändert, ist nicht dringend;
  • es zwei Varianten gibt und sie nicht wachsen. Ein if ist klarer als ein Interface und zwei Klassen;
  • unter dem Code kein Netz liegt. Dann ist der erste Schritt das Netz, nicht das Refactoring.

Und eine Regel: nicht mischen. Du änderst entweder die Form oder das Verhalten, nie beides in einem Schritt. Kent Beck nennt das „zwei Hüte“: Mit dem einen räumst du um und die Tests bleiben unberührt, mit dem anderen ergänzt du und schreibst einen neuen Test. Du wechselst den Hut mit Absicht.

Übung

Partner mit 3 %

Löse die Aufgabe in deinem Editor.

Partner. Der Shop ergänzt die Kundenart „Partner“ mit 3 % Rabatt auf jede Bestellung. Starte mit dem Code nach Schritt 5. Schreibe zuerst einen Test für einen Partner mit der Bestellung über 33 €, dann die Klasse Partner. Die anderen Klassen und invoice werden nicht angefasst.

Ausgabe
Rechnung: Maria Becker
Lindenstraße 5, 20095 Hamburg
Summe: 33.00 €
Rabatt: 0.99 €
Gesamt inkl. MwSt.: 38.41 €

Weitere Aufgaben

  • Extract Method für den Text. Extrahiere die Adresszeile in eine Funktion addressLine(customer). Führe nach dem Schritt die Tests aus.
  • Die andere Reihenfolge. Mach die fünf Schritte in anderer Reihenfolge: zuerst Introduce Parameter Object, dann Rename. Bestehen die Tests nach jedem? Welcher Schritt war früher einfacher?
  • Die Art aus der Datenbank. Die Kundenart kommt als Text herein ("gold"). Schreibe die Tabelle, die ihn in ein Objekt verwandelt, und entscheide, was mit einer unbekannten Art passiert. Der heutige Code gibt für eine unbekannte Art einen Rabatt von 0: Behalte das mit einem Test bei.

Test

Teste dich

1. Was davon ist Refactoring?
2. Nach Introduce Parameter Object lassen sich die Tests nicht mehr kompilieren, weil sie die Methode mit sechs Parametern aufrufen. Was tust du?
3. Wann ist Inline besser als Extract Method?
4. Die Kette von if über die Kundenart wird zu einem Interface und einer Klasse je Art. Wie heißt das im Modul über Entwurfsmuster?
5. Fünf Schritte, nach jedem Tests. Beim vierten schlägt ein Test fehl. Was heißt das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·