Exercise: a messy class, cleaned up step by step
In short: A refactoring exercise: a scooter rental class is cleaned up in five small steps (names, magic numbers, money, query apart from command, plans) while the tests stay green.
Problem
The scooters nobody dares to touch
A company that rents out e-scooters bills every ride with the RideMgr class. It has worked for three years. The calc method works out the price from the customer's plan (pay as you go, student or monthly pass), drops the unlock fee from the fourth ride of the day, adds a night surcharge, adds up the revenue and sends a receipt:
class RideMgr: def __init__(self): self.c = {} self.s = 0 self.out = []
# Price: €1 to unlock and €0.15 per minute. # At night the price goes up by 50%. def calc(self, to, t, m, f): self.c[to] = self.c.get(to, 0) + 1 p = 0 if t == "payg": p = 1.0 + m * 0.2 elif t == "student": p = (1.0 + m * 0.2) * 0.8 elif t == "monthly": if m > 45: p = (m - 45) * 0.2 if self.c[to] > 3 and t != "monthly": p = p - 1.0 if f: p = p + 0.5 self.s = self.s + p self.out.append(f"{to}: {m} min, €{p:.2f}") return pNext month two changes arrive:
- a business plan: no unlock fee, €0.18 per minute, no discount for the fourth ride;
- the price before the end: the app shows what the ride will cost while it is still going.
The second one cannot be done with this class. Here is what happens if the app calls calc to show the price, and then again to take the money:
rides = RideMgr()shown = rides.calc("ana@example.com", "payg", 10, False)print(f"Price before the end: €{shown:.2f}")charged = rides.calc("ana@example.com", "payg", 10, False)print(f"Charged: €{charged:.2f}")print(f"Receipts: {len(rides.out)}")print(f"Revenue: €{rides.s:.2f}")Price before the end: €3.00Charged: €3.00Receipts: 2Revenue: €6.00One ride, two receipts and €6 of revenue. calc sounds like a question, but it counts, adds up and sends.
There is no new idea in this lesson. It is an exercise: the class goes through the tools of the module one by one, in small steps, and you run the tests after each step.
See it
Step 0: the safety net
First the net, as in the lesson on tests. Nine tests pin what the class does today: the price for each plan, the night, the fourth ride, the receipt and the revenue.
import unittest
from rides import RideMgr
EMAIL = "ana@example.com"
def money(amount): return f"{amount:.2f}"
class RideMgrTest(unittest.TestCase): def setUp(self): self.rides = RideMgr()
def ride(self, plan, minutes, night): return money(self.rides.calc(EMAIL, plan, minutes, night))
def test_pay_as_you_go_ride(self): self.assertEqual(self.ride("payg", 10, False), "3.00")
def test_student_ride(self): self.assertEqual(self.ride("student", 10, False), "2.40")
def test_monthly_within_free_minutes(self): self.assertEqual(self.ride("monthly", 30, False), "0.00")
def test_monthly_over_free_minutes(self): self.assertEqual(self.ride("monthly", 60, False), "3.00")
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, True), "3.50")
def test_fourth_ride_unlock_is_free(self): self.ride("payg", 10, False) self.ride("payg", 10, False) self.ride("payg", 10, False) self.assertEqual(self.ride("payg", 10, False), "2.00")
def test_student_fourth_ride(self): # As it is today: the discount is the full €1, not €0.80. self.ride("student", 10, False) self.ride("student", 10, False) self.ride("student", 10, False) self.assertEqual(self.ride("student", 10, False), "1.40")
def test_receipt(self): self.ride("payg", 10, False) self.assertEqual(self.rides.out, ["ana@example.com: 10 min, €3.00"])
def test_revenue(self): self.ride("payg", 10, False) self.ride("student", 10, False) self.assertEqual(money(self.rides.s), "5.40")PASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0The tests call the class only through the ride helper. When the shape of the class changes in the next steps, the helper and the calls will change, and the expected values never.
One of the tests is odd. A student pays 80% of the price, so their unlock costs €0.80. On the fourth ride, though, the class takes off the full €1, and the student pays €1.40 instead of €1.60. Is it a bug or a gift? You do not decide, and you do not decide now. The test pins what happens today, with a comment, and the question goes to the product owner. While you clean up, the behavior does not change, even when it looks wrong.
More precisely, if you are curious
The amounts are compared as text with two decimals ("2.40"), the way the customer sees them. Comparing numbers would trip over the small binary errors of float:
print((1.0 + 10 * 0.2) * 0.8)2.4000000000000004And in step 3 the type of the amounts will change. The text with two decimals is the same before and after.
See it
Step 1: the names and the flag
t, m, f, p, c and s become words (names). The most important name is the method's: it does not calculate, it finishes the ride. The honest name is finish_ride, and now the side effect shows in the name. The class becomes RideBilling.
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = 0 self.receipts = []
# Price: €1 to unlock and €0.15 per minute. # At night the price goes up by 50%. def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = 0 if plan == "payg": price = 1.0 + minutes * 0.2 elif plan == "student": price = (1.0 + minutes * 0.2) * 0.8 elif plan == "monthly": if minutes > 45: price = (minutes - 45) * 0.2 if self.rides_today[email] > 3 and plan != "monthly": price = price - 1.0 if night: price = price + 0.5 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return priceThe flag is left (functions). true in the call says nothing. Night is data about the ride, not a choice of a different job, so it does not become two functions. The call has to say what it means:
night becomes a keyword-only argument (after *) with a default value: day has no argument, and night is night=True.def ride(self, plan, minutes, night): return money(self.rides.calc(EMAIL, plan, minutes, night))
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, True), "3.50")def ride(self, plan, minutes, *, night=False): return money(self.billing.finish_ride(EMAIL, plan, minutes, night=night))
def test_night_surcharge(self): self.assertEqual(self.ride("payg", 10, night=True), "3.50")PASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0self.s) everywhere, so here the tests tell you more than in Java or C#.See it
Step 2: the numbers and the comment that lies
1.0, 0.2, 0.8, 45, 3 and 0.5 become constants with names (magic number → constant).
The comment above the method says €0.15 per minute and a 50% night surcharge. The code charges €0.20 and adds €0.50. Which is true? The tests pinned what the code does, and customers have paid by it for years. The comment lies (comments) and is deleted. The constants say the same thing and cannot go stale on their own.
UNLOCK_FEE = 1.00PER_MINUTE = 0.20STUDENT_SHARE = 0.80MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = 0.50
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = 0 self.receipts = []
def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = 0 if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today[email] > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return pricePASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0The tests are not touched: neither the name nor the calls have changed.
The constants are at module level, above the class: that way any other code in the file sees them, and in step 5 so do the plans.Try it
Find the bug
A colleague does the same step, but trusts the comment and keeps it:
Pick the line with the bug.
The price per minute comes from the comment: €0.15 instead of €0.20. So the refactoring quietly lowers the price of every ride. Eight of the nine tests fail; only the monthly pass up to 45 minutes passes, where the minutes are not paid. The comment should have been deleted, and the number taken from the code that the tests already guard.
More precisely, if you are curious
FAIL test_fourth_ride_unlock_is_freeFAIL test_monthly_over_free_minutesFAIL test_night_surchargeFAIL test_pay_as_you_go_rideFAIL test_receiptFAIL test_revenueFAIL test_student_fourth_rideFAIL test_student_ridePASS test_monthly_within_free_minutesTests: 9, passed: 1, failed: 8See it
Step 3: the money
Money in a float is primitive obsession (smells). Today it gives no wrong cent in any test, because every amount is rounded only when it is printed. That is why the step is a refactoring: the output is the same. But the revenue is a sum of thousands of such amounts, and that is where the small errors pile up.
The numbers becomeDecimal, created from a string. The formulas stay as they are, because Decimal works with + and *, and with whole numbers.from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def finish_ride(self, email, plan, minutes, *, night=False): self.rides_today[email] = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today[email] > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return pricef"{amount:.2f}" works with Decimal too.def money(amount): return f"{amount:.2f}"PASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0Try it
Fill in
The price per minute in the language's type for money:
PER_MINUTE = Decimal()Decimal("0.20"). From a float (Decimal(0.20)) the error of the binary form is already inside.See it
Step 4: the question apart from the command
Now the change that was waiting: the price before the end has to be a question that changes nothing (command or question). The calculation is extracted into a price method (Extract Method from the refactoring lesson). finish_ride stays a command: it asks for the price, then counts, adds up and sends.
A colleague does it like this. The counter moves after the price, because a ride counts once it has finished:
What do the nine tests say?
def price(self, email, plan, minutes, *, night=False): price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if self.rides_today.get(email, 0) > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return priceIn the old calc the counter went up before the check, so "more than 3" meant "this is the fourth ride". Now price asks before counting, and the counter is one lower. The fourth ride passes for the third and pays the unlock fee. The other tests pass, because they never reach a fourth ride.
More precisely, if you are curious
FAIL test_fourth_ride_unlock_is_free: '3.00' != '2.00'- 3.00? ^+ 2.00? ^
FAIL test_student_fourth_ride: '2.40' != '1.40'- 2.40? ^+ 1.40? ^
PASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_rideTests: 9, passed: 7, failed: 2See it
Step 4, second try
The red points at the last step. You do not look for the bug in the whole class: you undo the step and do it again. The order of the side effects was a hidden part of the behavior, and only the tests knew about it.
price works out which ride of the day this will be:
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if ride_of_day > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return pricePASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0The tests are not touched again: they call finish_ride, and it does the same as before.
The whole class after step 4
from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = Decimal("0") if plan == "payg": price = UNLOCK_FEE + minutes * PER_MINUTE elif plan == "student": price = (UNLOCK_FEE + minutes * PER_MINUTE) * STUDENT_SHARE elif plan == "monthly": if minutes > MONTHLY_FREE_MINUTES: price = (minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE if ride_of_day > PAID_UNLOCKS_PER_DAY and plan != "monthly": price = price - UNLOCK_FEE if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return priceSee it
Step 5: the plans
What is left is the chain of if on the text of the plan (long if chain). Every new plan is a new branch, and "monthly" is checked in two places. Each plan becomes a class that knows its price and its discount for the fourth ride. This is Replace Conditional with Polymorphism from the refactoring lesson:
class PayAsYouGo: def price(self, minutes): return UNLOCK_FEE + minutes * PER_MINUTE
def loyalty_discount(self): return UNLOCK_FEE
class Student: def price(self, minutes): return PayAsYouGo().price(minutes) * STUDENT_SHARE
def loyalty_discount(self): # As it is today: the full €1, even though a student pays €0.80 to unlock. return UNLOCK_FEE
class Monthly: def price(self, minutes): return max(0, minutes - MONTHLY_FREE_MINUTES) * PER_MINUTE
def loyalty_discount(self): return Decimal("0")The check for the monthly pass disappears: it simply returns a discount of 0. The student's odd behavior now sits on one line, with a comment that says why it is so (the good comment from the lesson on comments).
from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipts = []
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = plan.price(minutes) if ride_of_day > PAID_UNLOCKS_PER_DAY: price = price - plan.loyalty_discount() if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipts.append(f"{email}: {minutes} min, €{price:.2f}") return priceIn the tests the calls pass an object instead of text:
self.assertEqual(self.ride("payg", 10, night=True), "3.50")self.assertEqual(self.ride(PayAsYouGo(), 10, night=True), "3.50")PASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0This is Strategy from the patterns module: the rule is a separate object, and billing only asks.
See it
The two changes now
The business plan is a new class. The billing class is not touched (open for extension):
BUSINESS_PER_MINUTE = Decimal("0.18")
class Business: def price(self, minutes): return minutes * BUSINESS_PER_MINUTE
def loyalty_discount(self): return Decimal("0")The price before the end is the price question. However many times you call it, nothing is counted and no receipt goes out:
billing = RideBilling()payg = PayAsYouGo()billing.finish_ride("ana@example.com", payg, 10)billing.finish_ride("ana@example.com", payg, 5)billing.finish_ride("ana@example.com", payg, 12, night=True)print(f"Price before the end: €{billing.price("ana@example.com", payg, 8):.2f}")print(f"Price before the end: €{billing.price("ana@example.com", payg, 8):.2f}")print(f"Charged: €{billing.finish_ride("ana@example.com", payg, 8):.2f}")print(f"Company, 12 min: €{billing.finish_ride("office@example.com", Business(), 12):.2f}")print(f"Receipts: {len(billing.receipts)}")Price before the end: €1.60Price before the end: €1.60Charged: €1.60Company, 12 min: €2.16Receipts: 5Five receipts: three rides before the preview, the fourth one and the company's. The two previews leave no trace, and they show exactly what is charged afterwards.
The idea
Before and after
| Smell | Step | Lesson |
|---|---|---|
| nothing guards the behavior | 0: tests that pin today's behavior | the safety net |
one-letter names, calc that does not say it sends |
1: Rename | names |
true in the call |
1: an argument that says what it means | functions |
| magic numbers | 2: constants with names | names |
| a comment that lies | 2: deleted | comments |
| money in a float | 3: a type for money | smells |
| a question with a hidden side effect, a long method | 4: Extract Method, command and question | functions, refactoring |
a chain of if on the plan as text |
5: Replace Conditional with Polymorphism | smells, refactoring, SOLID |
The class got longer, not shorter. But every rule is in one place and has a name, and each change from the start is one class.
The day of rides, run with the class from before step 1 and with the class after step 5, prints the same thing line by line. Here both programs run on every check of the site.
rides = 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 receipt in rides.out: print(receipt)print(f"Revenue: €{rides.s:.2f}")billing = RideBilling()payg = PayAsYouGo()billing.finish_ride("ana@example.com", payg, 10)billing.finish_ride("ivan@example.com", Student(), 25)billing.finish_ride("ana@example.com", payg, 5)billing.finish_ride("maria@example.com", Monthly(), 60, night=True)billing.finish_ride("ana@example.com", payg, 12, night=True)billing.finish_ride("ana@example.com", payg, 8)
for receipt in billing.receipts: print(receipt)print(f"Revenue: €{billing.revenue:.2f}")ana@example.com: 10 min, €3.00ivan@example.com: 25 min, €4.80ana@example.com: 5 min, €2.00maria@example.com: 60 min, €3.50ana@example.com: 12 min, €3.90ana@example.com: 8 min, €1.60Revenue: €18.80The idea
Where to stop
You stop when the changes that are coming become easy. Here the two changes are one class and one line. That is why some steps are deliberately not done:
- the receipt in its own class. The text of the receipt is a second reason for the class to change (one reason to change). Today it does not change. As soon as marketing starts editing it every month, it becomes a separate class (task 1);
- sending behind an interface. The receipts are a list, not a real email. When the real email arrives, the test must not send it, and that is when the abstraction comes (depending on an abstraction);
- the student's behavior. That is a change in behavior, not a cleanup. It is done separately, with its own test, when the product decides.
And the rule from the refactoring lesson: either you change the shape or the behavior, never both in one step.
Exercise
Carry on yourself
Solve the tasks in your editor, starting from the class after step 5. After each step, run the nine tests.
1. The receipt in its own class. Move the text of the receipt and the list of sent receipts into a ReceiptBook class. The billing class only tells it "send". The tests are not touched.
One possible solution
class ReceiptBook: def __init__(self): self.sent = []
def send(self, email, minutes, price): self.sent.append(f"{email}: {minutes} min, €{price:.2f}")from decimal import Decimal
UNLOCK_FEE = Decimal("1.00")PER_MINUTE = Decimal("0.20")STUDENT_SHARE = Decimal("0.80")MONTHLY_FREE_MINUTES = 45PAID_UNLOCKS_PER_DAY = 3NIGHT_SURCHARGE = Decimal("0.50")
class RideBilling: def __init__(self): self.rides_today = {} self.revenue = Decimal("0") self.receipt_book = ReceiptBook()
def price(self, email, plan, minutes, *, night=False): ride_of_day = self.rides_today.get(email, 0) + 1 price = plan.price(minutes) if ride_of_day > PAID_UNLOCKS_PER_DAY: price = price - plan.loyalty_discount() if night: price = price + NIGHT_SURCHARGE return price
def finish_ride(self, email, plan, minutes, *, night=False): price = self.price(email, plan, minutes, night=night) self.rides_today[email] = self.rides_today.get(email, 0) + 1 self.revenue = self.revenue + price self.receipt_book.send(email, minutes, price) return price
@property def receipts(self): return self.receipt_book.sentPASS test_fourth_ride_unlock_is_freePASS test_monthly_over_free_minutesPASS test_monthly_within_free_minutesPASS test_night_surchargePASS test_pay_as_you_go_ridePASS test_receiptPASS test_revenuePASS test_student_fourth_ridePASS test_student_rideTests: 9, passed: 9, failed: 0The program for the day from the "Before and after" card prints the same output with this class.
2. The plan from text. The plan comes from the database as text ("payg"). Write the table that turns it into an object. What did the old class do with an unknown plan? Pin it with a test on the old class before you decide what the new one should do.
3. Night from the hour. The app no longer knows when it is night; it passes the start time. The rule "from 22:00 to 6:00" has to live in one place. Write the tests for 21:59, 22:00, 5:59 and 6:00 first.
4. The student. The product has decided: on the fourth ride a student gets €0.80, not €1. That is a change in behavior: first change the expected value in the test and see it red, then change one line in the plan.
Test