Card 07/ 09
Singletons as Hidden Global State Between Tests
One test changes the shared configuration, and the next test fails for no visible reason.
A singleton is a global variable with a class around it: state stored in it by one piece of code is seen by every other, including the next test. A test that changes a setting on the singleton leaves it changed, and a later test that assumed the default fails, while the same test passes on its own. This card traces that failure, then fixes it by keeping one instance but passing it in.
from typing import Optional
class Config:
_instance: Optional["Config"] = None
def __init__(self) -> None:
self.currency = "GBP"
@classmethod
def instance(cls) -> "Config":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def price_label(amount: int) -> str:
return f"{amount} {Config.instance().currency}"
def test_euro_prices() -> None:
Config.instance().currency = "EUR"
assert price_label(10) == "10 EUR"
def test_default_currency() -> None:
assert price_label(10) == "10 GBP", price_label(10)
def run(tests) -> None:
Config._instance = None
for test in tests:
try:
test()
print(f" PASS {test.__name__}")
except AssertionError as error:
print(f" FAIL {test.__name__}: got {error}")
print("test_default_currency alone:")
run([test_default_currency])
print("both, in file order:")
run([test_euro_prices, test_default_currency])test_default_currency alone:
PASS test_default_currency
both, in file order:
PASS test_euro_prices
FAIL test_default_currency: got 10 EUR| Step | Config.instance().currency | Result |
|---|---|---|
run resets _instance to None | not yet created | no test has run |
test_euro_prices sets the currency | EUR | PASS: 10 EUR |
test_default_currency expects the default | still EUR | FAIL: got 10 EUR |
Run alone, test_default_currency passes, because the instance is created fresh with GBP. Run after the euro test, it fails, because the singleton outlives each test and carries EUR into the next one. Nothing in either test's code mentions the other, which is why the failure looks random: it depends on which tests run, and in what order.
The usual first fix is a reset between tests, the line in run that empties _instance. It works until someone forgets it, and it is a test reaching into the private state of production code. The lasting fix keeps one instance in the application but stops reaching it through the class: each function receives the configuration as a parameter, which is dependency injection.
class Config:
def __init__(self, currency: str = "GBP") -> None:
self.currency = currency
def price_label(amount: int, config: Config) -> str:
return f"{amount} {config.currency}"
def test_euro_prices() -> None:
assert price_label(10, Config(currency="EUR")) == "10 EUR"
def test_default_currency() -> None:
assert price_label(10, Config()) == "10 GBP"
for test in [test_euro_prices, test_default_currency]:
test()
print(f"PASS {test.__name__}")
app_config = Config()
print("the application still makes one:", price_label(25, app_config))PASS test_euro_prices
PASS test_default_currency
the application still makes one: 25 GBPThe application still creates exactly one Config, at start-up, and passes it to the code that needs it. What has gone is the global access point. Each test now builds the configuration it needs, so no test can see another's, and price_label's signature finally says what it depends on.
← → move through sections and cards