Testing & Code Quality

Testing a Singleton Dependency Through Storage Injection

Use a storage protocol and recording double to verify order values, reject invalid input before writes, and preserve storage failures without a database.

2 min read
Cover image for Testing a Singleton Dependency Through Storage Injection

The obstacle is not merely having one instance; it is letting the caller select a concrete storage implementation directly. Express the storage role as a protocol and inject an implementation to test service decisions without a network.

Mechanism and design choice

Receiving an object externally does not automatically make it replaceable. A parameter of a concrete Database type still constrains the double. Here OrderStore provides a small contract and the service accepts any OrderStore. A singleton can also be injected if it satisfies that contract. Primary source

Reproduce the experiment

The complete example defines the order, errors, protocol, service, and recording store. It checks the exact order passed on success, rejects an empty ID before writing, and verifies that a storage failure reaches the caller. The double combines a recording spy with configurable stub behavior.

This standalone example was run on 2026-10-23 with Apple Swift 6.4 targeting arm64 macOS, in Swift 6 language mode. It needs no network or additional packages.

Download the complete Swift file

Run the command in the download directory. Preconditions stop execution if a check fails. Do not use -Ounchecked: it removes these checks.

swift -swift-version 6 singleton-testing-dependency-injection.swift
struct Order: Equatable { let id: String }
enum SaveError: Error { case invalidID, unavailable }

protocol OrderStore {
    func insert(_ order: Order) throws
}

struct OrderService {
    let store: any OrderStore
    func save(_ order: Order) throws {
        guard !order.id.isEmpty else { throw SaveError.invalidID }
        try store.insert(order)
    }
}

final class RecordingStore: OrderStore {
    var calls: [Order] = []
    var shouldFail = false
    func insert(_ order: Order) throws {
        calls.append(order)
        if shouldFail { throw SaveError.unavailable }
    }
}

let store = RecordingStore()
let service = OrderService(store: store)
let order = Order(id: "order-42")
try service.save(order)
precondition(store.calls == [order])
do {
    try service.save(Order(id: ""))
    preconditionFailure("invalid input was accepted")
} catch SaveError.invalidID {}
precondition(store.calls.count == 1)
store.shouldFail = true
do {
    try service.save(order)
    preconditionFailure("storage failure was swallowed")
} catch SaveError.unavailable {}
precondition(store.calls.count == 2)
print("saved=order-42 invalid-input-writes=0 storage-error=propagated")

Recorded output and interpretation

saved=order-42 invalid-input-writes=0 storage-error=propagated

The successful request contains order-42. An empty ID adds no storage call, and the storage error is not swallowed. The checks cover the passed value and two failure paths rather than only counting calls.

Scope and limitations

A protocol does not verify real database transactions, uniqueness constraints, or network behavior. Those need integration tests. Not every dependency needs an abstraction, and injecting global shared state does not by itself isolate tests.

What to check next

Start with one external I/O boundary and a small contract. Check that production storage and the double give errors the same meaning, then choose the real implementation where the app is assembled. Separate policy changes from behavior-preserving refactoring.

Related experiment

Sources and verification