Lesson 5 - Guided Project: Testing Ledgerly with BDD
Welcome to the Guided Project
This module has taught you four things in sequence: how to write clean, well-named code; how to back that code with real unit tests; how BDD reframes tests as a shared conversation about behavior; and how Gherkin gives that conversation a precise, executable syntax. This project puts all four together on one real Ledgerly feature, instead of practicing each skill in isolation.
The feature is a subscription pause. A freelancer using Ledgerly can pause a subscription’s billing for one or two months, and Ledgerly must resume billing automatically once the pause period ends, with no manual step required. You will build this feature four times over, in four different ways: first as a small, clean implementation; second as a suite of real unit tests that run and pass; third as a Gherkin feature file that states the same rules in plain sentences a non-engineer can read; and fourth as a short reflection on what that third layer actually buys the team.
By the end of this project, you will be able to:
- Write a small, single-purpose class that implements a real business rule cleanly
- Write and run real
unittesttests that check both normal behavior and rejected input - Translate acceptance criteria into a Gherkin feature file using Background, Scenario, and Scenario Outline
- Read a Scenario Outline’s Examples table as a compact way to cover several related cases at once
- Explain concretely what a Gherkin specification adds beyond a passing unit test suite
Stage 1: Write a Clean Implementation
A beta customer asks for a pause button. Some months they have no invoices to send, so paying Ledgerly’s own subscription fee feels wasteful. The team agrees on two concrete rules: a pause can last one or two months, never longer, and billing must resume automatically on the right date, without anyone remembering to flip a switch.
Clean code, from earlier in this module, means each function and class does one job and states that job in its name. Here, that means separating three responsibilities: representing a subscription’s state, deciding whether a pause request is valid, and deciding whether billing should run on a given day. Mixing all three into one long function would work, but it would be harder to name, harder to test, and harder to change later.
The date arithmetic needs its own small function too, because “two months from January 1” is not simply “60 days from January 1.” Python’s standard library has no built-in “add months to a date” operation, so add_months below builds one using the calendar module, capping the day of the month so, for example, adding a month to January 31st does not overflow into March.
import calendar
from dataclasses import dataclass
from datetime import date
MIN_PAUSE_MONTHS = 1
MAX_PAUSE_MONTHS = 2
def add_months(start: date, months: int) -> date:
"""Return the date `months` after `start`, keeping the day when possible."""
month_index = start.month - 1 + months
year = start.year + month_index // 12
month = month_index % 12 + 1
last_day_of_month = calendar.monthrange(year, month)[1]
day = min(start.day, last_day_of_month)
return date(year, month, day)
@dataclass
class Subscription:
id: str
customer_name: str
monthly_fee: float
is_paused: bool = False
resumes_on: date | None = None
class SubscriptionPauseManager:
"""Pauses a subscription's billing and resumes it automatically on time.
This class only knows about the Subscription dataclass above. It has no
knowledge of PaymentGateway or how an actual billing charge runs, which
keeps it easy to test without touching real payment code.
"""
def pause(self, subscription: Subscription, pause_months: int, today: date) -> None:
"""Pause billing for `pause_months` months, starting today.
Raises ValueError if pause_months falls outside the 1-2 month range
Ledgerly allows.
"""
if not (MIN_PAUSE_MONTHS <= pause_months <= MAX_PAUSE_MONTHS):
raise ValueError(
f"pause_months must be between {MIN_PAUSE_MONTHS} and "
f"{MAX_PAUSE_MONTHS}, got {pause_months}"
)
subscription.is_paused = True
subscription.resumes_on = add_months(today, pause_months)
def is_billable_on(self, subscription: Subscription, today: date) -> bool:
"""Return whether billing should run for this subscription today.
Checks for an overdue auto-resume first, so a caller never has to
remember to call a separate "resume" method themselves.
"""
self._auto_resume_if_due(subscription, today)
return not subscription.is_paused
def _auto_resume_if_due(self, subscription: Subscription, today: date) -> None:
"""Resume a paused subscription once its resume date has arrived."""
if subscription.is_paused and today >= subscription.resumes_on:
subscription.is_paused = False
subscription.resumes_on = None
priya_subscription = Subscription(id="SUB-2001", customer_name="Priya Shah", monthly_fee=29.00)
manager = SubscriptionPauseManager()
manager.pause(priya_subscription, pause_months=2, today=date(2026, 1, 1))
print(f"Billable on 2026-01-01: {manager.is_billable_on(priya_subscription, date(2026, 1, 1))}")
print(f"Billable on 2026-02-15: {manager.is_billable_on(priya_subscription, date(2026, 2, 15))}")
print(f"Billable on 2026-03-01: {manager.is_billable_on(priya_subscription, date(2026, 3, 1))}")Billable on 2026-01-01: False
Billable on 2026-02-15: False
Billable on 2026-03-01: TrueNotice what each piece is named after: add_months only does date math, pause only records a pause request, is_billable_on only answers a yes-or-no question, and _auto_resume_if_due only decides whether a resume should happen right now. None of them do two jobs, so each one is short enough to read in a few seconds and easy to name honestly.
Passing an out-of-range pause length is rejected the same way, with a clear message instead of a silently wrong result:
import calendar
from dataclasses import dataclass
from datetime import date
MIN_PAUSE_MONTHS = 1
MAX_PAUSE_MONTHS = 2
def add_months(start: date, months: int) -> date:
month_index = start.month - 1 + months
year = start.year + month_index // 12
month = month_index % 12 + 1
last_day_of_month = calendar.monthrange(year, month)[1]
day = min(start.day, last_day_of_month)
return date(year, month, day)
@dataclass
class Subscription:
id: str
customer_name: str
monthly_fee: float
is_paused: bool = False
resumes_on: date | None = None
class SubscriptionPauseManager:
def pause(self, subscription: Subscription, pause_months: int, today: date) -> None:
if not (MIN_PAUSE_MONTHS <= pause_months <= MAX_PAUSE_MONTHS):
raise ValueError(
f"pause_months must be between {MIN_PAUSE_MONTHS} and "
f"{MAX_PAUSE_MONTHS}, got {pause_months}"
)
subscription.is_paused = True
subscription.resumes_on = add_months(today, pause_months)
marcus_subscription = Subscription(id="SUB-2002", customer_name="Marcus Webb", monthly_fee=49.00)
manager = SubscriptionPauseManager()
try:
manager.pause(marcus_subscription, pause_months=3, today=date(2026, 1, 1))
except ValueError as e:
print(f"ValueError: {e}")ValueError: pause_months must be between 1 and 2, got 3Stage 2: Write Real Unit Tests
The print statements above prove the code runs, but they do not prove it stays correct after the next change. A unit test, from earlier in this module, checks one specific behavior automatically and fails loudly if that behavior ever breaks. Python’s built-in unittest module needs no extra install, so it is a reasonable default for a small team like Ledgerly’s.
Five tests below check the rules from Stage 1: pausing stops billing immediately, a paused subscription stays paused right up to its resume date, it becomes billable again exactly on the resume date, and an invalid pause length of zero or three months is rejected. Each test name states exactly what it checks, so a failing test tells you what broke without opening the test body first.
import calendar
import sys
import unittest
from dataclasses import dataclass
from datetime import date
MIN_PAUSE_MONTHS = 1
MAX_PAUSE_MONTHS = 2
def add_months(start: date, months: int) -> date:
month_index = start.month - 1 + months
year = start.year + month_index // 12
month = month_index % 12 + 1
last_day_of_month = calendar.monthrange(year, month)[1]
day = min(start.day, last_day_of_month)
return date(year, month, day)
@dataclass
class Subscription:
id: str
customer_name: str
monthly_fee: float
is_paused: bool = False
resumes_on: date | None = None
class SubscriptionPauseManager:
def pause(self, subscription: Subscription, pause_months: int, today: date) -> None:
if not (MIN_PAUSE_MONTHS <= pause_months <= MAX_PAUSE_MONTHS):
raise ValueError(
f"pause_months must be between {MIN_PAUSE_MONTHS} and "
f"{MAX_PAUSE_MONTHS}, got {pause_months}"
)
subscription.is_paused = True
subscription.resumes_on = add_months(today, pause_months)
def is_billable_on(self, subscription: Subscription, today: date) -> bool:
self._auto_resume_if_due(subscription, today)
return not subscription.is_paused
def _auto_resume_if_due(self, subscription: Subscription, today: date) -> None:
if subscription.is_paused and today >= subscription.resumes_on:
subscription.is_paused = False
subscription.resumes_on = None
class TestSubscriptionPauseManager(unittest.TestCase):
def setUp(self):
self.manager = SubscriptionPauseManager()
self.subscription = Subscription(
id="SUB-3001", customer_name="Test Customer", monthly_fee=29.00
)
def test_pausing_stops_billing_the_same_day(self):
today = date(2026, 1, 1)
self.manager.pause(self.subscription, pause_months=2, today=today)
self.assertFalse(self.manager.is_billable_on(self.subscription, today))
def test_subscription_stays_paused_one_day_before_resume_date(self):
self.manager.pause(self.subscription, pause_months=1, today=date(2026, 1, 1))
day_before_resume = date(2026, 1, 31)
self.assertFalse(self.manager.is_billable_on(self.subscription, day_before_resume))
def test_subscription_auto_resumes_on_the_resume_date(self):
self.manager.pause(self.subscription, pause_months=2, today=date(2026, 1, 1))
self.assertFalse(self.manager.is_billable_on(self.subscription, date(2026, 2, 28)))
self.assertTrue(self.manager.is_billable_on(self.subscription, date(2026, 3, 1)))
def test_pause_rejects_more_than_two_months(self):
with self.assertRaises(ValueError):
self.manager.pause(self.subscription, pause_months=3, today=date(2026, 1, 1))
def test_pause_rejects_zero_months(self):
with self.assertRaises(ValueError):
self.manager.pause(self.subscription, pause_months=0, today=date(2026, 1, 1))
if __name__ == "__main__":
runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=2)
unittest.main(testRunner=runner, argv=["ledgerly-tests"], exit=False)test_pause_rejects_more_than_two_months (__main__.TestSubscriptionPauseManager.test_pause_rejects_more_than_two_months) ... ok
test_pause_rejects_zero_months (__main__.TestSubscriptionPauseManager.test_pause_rejects_zero_months) ... ok
test_pausing_stops_billing_the_same_day (__main__.TestSubscriptionPauseManager.test_pausing_stops_billing_the_same_day) ... ok
test_subscription_auto_resumes_on_the_resume_date (__main__.TestSubscriptionPauseManager.test_subscription_auto_resumes_on_the_resume_date) ... ok
test_subscription_stays_paused_one_day_before_resume_date (__main__.TestSubscriptionPauseManager.test_subscription_stays_paused_one_day_before_resume_date) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OKAll five tests pass. test_subscription_auto_resumes_on_the_resume_date is the most important one: it checks the exact boundary, the last day still paused and the first day billable again, which is exactly where an off-by-one mistake would hide.
Why setUp matters here
setUp runs before every single test method and builds a fresh SubscriptionPauseManager and Subscription each time. Without it, one test’s changes to a shared subscription object could leak into the next test, making a later failure depend on test order instead of on the code being tested.
Stage 3: Specify the Same Behavior in Gherkin
The unit tests above are correct and they run in under a second, but a product owner or a beta customer cannot read Python and confirm the rules are the ones they asked for. BDD, from earlier in this module, closes that gap by writing the same behavior as plain sentences that both an engineer and a non-engineer can read and agree on.
A Background runs before every scenario in a file, so shared setup, like “a subscription exists,” does not have to repeat in every scenario. A Scenario describes one concrete example of the feature working. A Scenario Outline describes a pattern that repeats with different values, paired with an Examples table that fills in each variation, so one outline can cover both the one-month and two-month pause without duplicating every step.
Feature: Subscription pause and resume
As a freelancer using Ledgerly, I want to pause billing on a subscription
for one or two months, so that I do not pay for a service during a slow
month, and so that billing starts again on its own without my involvement.
Background:
Given a subscription for "Priya Shah" with a monthly fee of $29.00
And the subscription is active on "2026-01-01"
Scenario: Pausing a subscription stops billing immediately
When Priya pauses the subscription for 2 months on "2026-01-01"
Then the subscription is not billable on "2026-01-01"
And the subscription is not billable on "2026-02-15"
Scenario Outline: A paused subscription resumes automatically after its pause period
When Priya pauses the subscription for <pause_months> months on "2026-01-01"
Then the subscription is not billable on "<day_before_resume>"
And the subscription is billable on "<resume_date>"
Examples:
| pause_months | day_before_resume | resume_date |
| 1 | 2026-01-31 | 2026-02-01 |
| 2 | 2026-02-28 | 2026-03-01 |
Scenario: A pause request beyond the maximum allowed length is rejected
When Priya tries to pause the subscription for 3 months on "2026-01-01"
Then Ledgerly rejects the request with an error mentioning "1 and 2"
And the subscription is billable on "2026-01-01"The Scenario Outline’s two Examples rows are exactly the two boundary cases the Stage 2 unit test checked in Python: pausing one month lands the resume date on February 1st, and pausing two months lands it on March 1st. Reading the table takes seconds and needs no Python knowledge at all, yet it states the same boundary a beta customer would actually notice: the last paused day and the first billable day again.
Stage 4: Reflect on What BDD Adds
Stage 2 and Stage 3 check the same rule. It would be reasonable to ask whether writing the Gherkin file was worth the extra effort, given that the unit tests already pass. The honest answer is that the Gherkin layer adds three things the unit tests alone do not, and none of them is “more correctness.”
First, it adds a shared vocabulary. A beta customer cannot read assertFalse(self.manager.is_billable_on(...)), but they can read “Then the subscription is not billable on 2026-02-28” and immediately say whether that matches what they expected when they asked for a pause button. That conversation catches misunderstandings before code is written, not after.
Second, it adds living documentation that stays next to the requirement, not buried inside test internals. Six months from now, a new engineer reading subscription_pause.feature learns the exact business rule, one or two months, auto-resume on the boundary date, without reading a single line of the SubscriptionPauseManager implementation.
Third, it makes the acceptance criteria themselves reviewable by someone who is not an engineer. The unit tests prove the code matches whatever the engineer believed the rule was. The Gherkin file lets the product owner confirm that belief was correct in the first place, which a passing test suite can never do on its own, because a test suite can only be internally consistent, not externally correct.
What Gherkin does not replace is the unit tests’ precision. test_pause_rejects_zero_months checks a specific edge case, zero months, that the Gherkin file above does not cover at all. The two layers work together: Gherkin scenarios describe the handful of cases a human needs to agree on, and unit tests fill in every edge case an engineer needs to guard against, including ones no customer would ever think to ask about.
Practice Exercises
Exercise 1: Add a manual resume test
Write a new unittest test method, test_manual_resume_before_pause_period_ends, that checks what happens if Ledgerly later adds a resume_now method letting a customer end their pause early. Assume resume_now(subscription) sets is_paused to False and resumes_on to None immediately, regardless of the original resume date.
Hint
Pause a subscription for 2 months starting January 1st, call resume_now, then assert is_billable_on returns True on January 2nd, a date that would still have been inside the original pause window. The point of the test is proving the manual override works even before the automatic resume date arrives.
Exercise 2: Extend the Gherkin feature with a Background variation
Ledgerly wants a second Background-driven scenario for a subscription that starts already paused from a previous action, rather than pausing it inside the scenario itself. Sketch the Background and one Scenario for this case, following the syntax used in Stage 3.
Hint
A reasonable Background: Given a subscription for "Marcus Webb" with a monthly fee of $49.00 followed by And the subscription was paused for 1 month on "2026-01-01". A Scenario built on it needs no When step for pausing at all, since the Background already establishes the paused state; it can go straight to Then the subscription is not billable on "2026-01-15".
Exercise 3: Justify a third Examples row
A customer asks whether Ledgerly could someday support a 6-week pause instead of only whole months. Explain, in two or three sentences, why the current add_months-based design would need to change before the Scenario Outline’s Examples table could add a row for it.
Hint
add_months only understands whole calendar months, so it has no way to represent “6 weeks” as an input. Supporting it would mean either adding a separate week-based helper function or reworking pause to accept a general duration, and only after that change would a new Examples row with a 6-week case make sense to add.
Summary
This guided project built one real Ledgerly feature four times over. Stage 1 wrote a small, clean SubscriptionPauseManager, splitting date math, pause validation, and the billable-or-not decision into separate, honestly named pieces. Stage 2 backed that implementation with five real unittest tests, all passing, including the exact boundary between a subscription’s last paused day and its first billable day again. Stage 3 wrote the same rules as a Gherkin feature file, using a Background, a Scenario, and a Scenario Outline with an Examples table covering both the one-month and two-month pause. Stage 4 made explicit that the Gherkin layer’s value is not more correctness, it is a shared vocabulary, living documentation, and a way for a non-engineer to confirm the rule itself is right.
Key Concepts
- Single-purpose class design —
add_months,pause,is_billable_on, and_auto_resume_if_dueeach do exactly one job, making each easy to name and to test. - Boundary testing — checking the day right before and right after a resume date is where off-by-one bugs actually hide.
unittest.TestCaseandsetUp— a fresh test fixture built before every test method, so tests never depend on run order.- Background, Scenario, and Scenario Outline — Gherkin structures for shared setup, one concrete example, and a repeating pattern filled in by an Examples table.
- BDD’s real payoff — a shared vocabulary and living documentation that let a non-engineer confirm a rule is correct, which a passing test suite cannot do by itself.
Why This Matters
Every feature you ship eventually needs someone other than its author to trust that it works, whether that person is a teammate reviewing a pull request, a product owner signing off before launch, or a future engineer maintaining the code a year later. Clean code, unit tests, and Gherkin scenarios each build a different kind of trust: clean code makes the implementation easy to read, unit tests make it provably correct against specific cases, and Gherkin scenarios make the intended behavior legible to people who never open the source file at all. Building all three for one feature, as you just did for Ledgerly’s subscription pause, is the habit that keeps a small team’s software trustworthy as it grows past the point where any one person remembers every rule by heart.
Next Steps
Module 4: Delivery & Operations
Start the next module: version control, CI/CD, and security best practices.
Back to Course Overview
Review the full Software Engineering Fundamentals course.
Continue Building Your Skills
You have now built, tested, and specified one real Ledgerly feature from three different angles, closing out Module 3. The habits behind that work, naming things honestly, writing tests that check real boundaries, and stating behavior in language a non-engineer can verify, are what keep a growing codebase trustworthy instead of merely functional. Next, in Module 4, you move from writing quality code to shipping it: version control workflows, continuous integration and deployment, and the security practices that protect Ledgerly once it is running in production.