Card 08/ 09
Practice: Removing a Singleton From a Payment Service
The service calls Database.instance() inside every method, and its tests need a real database.
A payment service that reaches its database through a singleton cannot be tested without that database, and this exercise asks you to remove the singleton while keeping one database connection. Here is the service as it stands.
from typing import Optional
class Database:
_instance: Optional["Database"] = None
@classmethod
def instance(cls) -> "Database":
if cls._instance is None:
print("connecting to the production database")
cls._instance = cls()
return cls._instance
def save_payment(self, order_id: str, amount: int) -> None:
print(f"INSERT payment {order_id} {amount}")
def balance(self, customer: str) -> int:
return 120
class PaymentService:
def charge(self, customer: str, order_id: str, amount: int) -> bool:
if Database.instance().balance(customer) < amount:
return False
Database.instance().save_payment(order_id, amount)
return True
print(PaymentService().charge("ada", "A-1042", 80))connecting to the production database
INSERT payment A-1042 80
True- Say why a test of
chargewith a low balance would need the production database. - Change
PaymentServiceso that it receives what it needs, and leaveDatabasewith no class-level instance. - Write two tests, a low balance and an enough balance, that run without connecting to anything.
- Show that the application still creates exactly one database connection.
Show a solution
from typing import Protocol
class PaymentStore(Protocol):
def save_payment(self, order_id: str, amount: int) -> None: ...
def balance(self, customer: str) -> int: ...
class Database:
def __init__(self) -> None:
print("connecting to the production database")
def save_payment(self, order_id: str, amount: int) -> None:
print(f"INSERT payment {order_id} {amount}")
def balance(self, customer: str) -> int:
return 120
class PaymentService:
def __init__(self, store: PaymentStore) -> None:
self._store = store
def charge(self, customer: str, order_id: str, amount: int) -> bool:
if self._store.balance(customer) < amount:
return False
self._store.save_payment(order_id, amount)
return True
class FakeStore:
def __init__(self, balance: int) -> None:
self._balance = balance
self.saved: list[tuple[str, int]] = []
def save_payment(self, order_id: str, amount: int) -> None:
self.saved.append((order_id, amount))
def balance(self, customer: str) -> int:
return self._balance
poor = FakeStore(balance=50)
print("test, low balance:", PaymentService(poor).charge("ada", "A-1", 80), poor.saved)
rich = FakeStore(balance=500)
print("test, enough: ", PaymentService(rich).charge("ada", "A-2", 80), rich.saved)
database = Database()
service = PaymentService(database)
print("application:", service.charge("ada", "A-1042", 80))test, low balance: False []
test, enough: True [('A-2', 80)]
connecting to the production database
INSERT payment A-1042 80
application: Truecharge called Database.instance(), which connects to production on first use, so any test of it connected too. In the solution, PaymentService receives a PaymentStore in its constructor, a Protocol that names the two methods the service uses. The tests pass a FakeStore with the balance they need and then check what it saved, and "connecting to the production database" does not appear until the application creates its one Database.
The program still has one database connection, made once and passed to the service. The singleton's first promise, one instance, still holds, and only its second, the global point of access that made testing hard, has been removed.
← → move through sections and cards