Card 07/ 09

GotchaDifficulty: Advanced2 min

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.

py
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])
OutputRan with python:3.12-slim (Python 3.12.13)
test_default_currency alone:
  PASS test_default_currency
both, in file order:
  PASS test_euro_prices
  FAIL test_default_currency: got 10 EUR
`price_label` reads the singleton, and `test_euro_prices` changes it.
The shared instance, test by test, when both run in file order
StepConfig.instance().currencyResult
run resets _instance to Nonenot yet createdno test has run
test_euro_prices sets the currencyEURPASS: 10 EUR
test_default_currency expects the defaultstill EURFAIL: 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.

py
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))
OutputRan with python:3.12-slim (Python 3.12.13)
PASS test_euro_prices
PASS test_default_currency
the application still makes one: 25 GBP
Each test makes the configuration it needs; the application still makes one.

The 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.