Testing & Code Quality

Why Singletons Block Testing—and How Dependency Injection Solves It

Have you ever spent all night stuck because a Singleton made testing impossible?

4 min read
Cover image for Why Singletons Block Testing—and How Dependency Injection Solves It

Have you ever spent all night stuck because a Singleton made testing impossible?

The logic is fine, but adding tests keeps hitting the real DB and calling the real API.

Let me give you the conclusion first.

Because a Singleton is accessed directly in the code, you cannot swap it for a fake object during testing.

Dependency injection, by contrast, supplies required objects from outside, so you can replace them freely in tests.

Today, we will walk through the difference between the two with real code.


Why Do Singletons Block Testing?

A Singleton is an object that exists exactly once across the entire program.

It is convenient: you can retrieve it anywhere with just Database.shared.

That is where the problem begins.

When an object is retrieved directly inside a function, that function becomes tightly coupled to the real database.

There is no way to tell it, “Use a fake DB instead of the real one” during testing.

// Access the Singleton directly inside the function
func saveOrder(_ order: Order) {
    // Become tightly coupled to the real DB
    let db = Database.shared
    db.insert(order) // No way to block this in tests
}

To test this code, the real DB must be running.

If the DB is down, the test fails too.

The logic is not wrong, but the infrastructure makes the test turn red.


How Does Dependency Injection Work?

Dependency injection is less elaborate than it sounds.

Instead of retrieving an object inside a function, create it outside and pass it in.

You literally “inject” what is needed.

// DBReceive it from outside as a parameter
func saveOrder(_ order: Order, db: Database) {
    db.insert(order) // The caller decides which DBit is
}

That is a one-line difference, right?

We only changed what was retrieved directly with Database.shared to be received as a parameter.

Yet this small change completely transforms testing.

Use the real DB in production code and a fake DB in tests.

Only the injected object changes, but the outcome diverges like this
Only the injected object changes, but the outcome diverges like this

Receiving it through the initializer is also common.

class OrderService {
    private let db: Database
    // Receive and store the object from outside when creating it
    init(db: Database) { self.db = db }
}

Once received, it can be used anywhere in the class as self.db, which is much cleaner.


Swapping in a Fake Object for Tests

Now we get to the really interesting part.

Since the dependency now comes from outside, use a fake DB instead of the real one in tests (usually called a mock).

@Test func When the order is_saved, it is_DBpassed to_() {
    let fake = FakeDatabase() // Fake, not real
    saveOrder(Order(), db: fake)
    #expect(fake.savedCount == 1) // Check only that save was called
}

There is no need to start the real DB.

No network or external server is needed either. It finishes instantly in memory.

As a result, tests become faster and, above all, stable.

You can verify only the logic, regardless of external conditions.

After injecting a fake object, the test turned green in an instant
After injecting a fake object, the test turned green in an instant

Singleton vs. Dependency Injection: At a Glance

Here is their difference summarized in a table.

Category Direct Singleton use Dependency injection
Where the object comes from Retrieved directly inside the function Passed in from outside
Replacing the fake object Practically impossible Freely possible
Test speed Slow (real resources required) Fast (processed in memory)
Test stability Affected by external conditions Only the logic can be verified
Coupling High Low

Singletons themselves are not always bad.

They are convenient when exactly one instance is truly needed, such as for configuration values.

The problem is the habit of “retrieving them directly anywhere.” That ties the hands of your tests.


Frequently Asked Questions (Q&A)

Q. Do I absolutely need a DI library such as Swinject to use dependency injection?

No. Passing dependencies through parameters or an initializer, as in today’s code, is already dependency injection. A library only manages this automatically.

Q. Does it get messy when there are too many parameters?

Yes. That is why dependencies are usually received together through initializer injection or grouped by relationship. If the parameters keep increasing, it may also signal that the class is doing too much.

Inserting a fake DB instead of the real one—that is the key
Inserting a fake DB instead of the real one—that is the key

If your tests keep getting held back by real resources, change just one place in the direction discussed today.

Change code that retrieves objects directly so it receives them from outside.

That small habit makes testing enjoyable. Happy coding!

Continue reading