See how you can wrap any singleton behind a protocol to make it injectable and your code fully testable 💯

The blog post shows how to deal with URLSession.shared usage.

The same strategy can be applied to all other singletons in your code!

The problem

  • Service uses URLSession.shared directly.
  • Tight coupling makes unit testing impossible without real network calls.
struct PostsAPISerivce {
    func fetchPosts() async throws -> [Post] {
        let url = URL(string: ".../posts")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Post].self, from: data)
    }
}

In the sections, you’ll get step-by-step guide of making this service testable. These same steps can be applied to all other singletons found in your code.

Step 1: Inspect the used API

  • CMD-click on data(from: url) to view the documentation
/// Convenience method to load data using a URL, creates 
/// and resumes a URLSessionDataTask internally.
///
/// - Parameter url: The URL for which to load data.
/// - Parameter delegate: Task-specific delegate.
/// - Returns: Data and response. 
public func data(
    from url: URL,
    delegate: (any URLSessionTaskDelegate)? = nil
) async throws -> (Data, URLResponse)

Step 2: Define a protocol

  • Create URLSessionProtocol
  • Copy the function signature from the documentation into your protocol definition.
  • Remove default arguments (not allowed in protocol).
protocol URLSessionProtocol {
    func data(
        from url: URL,
        delegate: (any URLSessionTaskDelegate)?
    ) async throws -> (Data, URLResponse)
}

Step 3: Conform URLSession to URLSessionProtocol

  • Add the following extension to make URLSession conforms to your protocol
extension URLSession: URLSessionProtocol {}

Step 4: Inject Dependecy

  • Refactor the service and inject URLSessionProtocol into it.
struct PostsAPISerivce {
    private let urlSession: URLSessionProtocol

    init(urlSession: URLSessionProtocol) {
        self.urlSession = urlSession
    }

    func fetchPosts() async throws -> [Post] {
        let url = URL(string: ".../posts")!
        let (data, _) = try await urlSession.data(from: url, delegate: nil)
        return try JSONDecoder().decode([Post].self, from: data)
    }
}

Now a Spy or Mock conforming to URLSessionProtocol can be created and injected into PostsAPIService to simulate API responses.

Summary

Benefits:

  • No real API calls in tests ✅
  • Spies and Mocks can be injected in tests to control API responses (successes & errors) ✅
  • Reusable for all other components requiring URLSession

Remember - These same steps can be applied to all other singletons found in your code.

PDF version ⤵️ Turning Singleton Usage into Testable Code


Thanks for reading. 📖

I hope you found it useful!

If you enjoy the topic don’t forget to follow me on one of my social media - LinkedIn, X, Mastodon, Bluesky or via RSS feed to keep up to speed. 🚀