Extract a protocol from URLSession.shared so a view controller becomes testable
ProfileViewController.loadProfile(from:) calls URLSession.shared.data(from:) directly, so a unit test would hit the real network. Refactor for testability: define a protocol, make the controller depend on it, and write a fake for tests.
Requirements:
- Define a
DataFetchingprotocol exposing the one method the controller uses. - Inject a
DataFetchinginto the controller, defaulting toURLSession.sharedin production. - Write a
FakeFetcherconforming toDataFetchingthat returns canned data.
final class ProfileViewController {
func loadProfile(from url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
Write the implementation.
Define a DataFetching protocol with the method the controller needs, make URLSession conform, and inject a DataFetching, defaulting to .shared. Tests pass a FakeFetcher returning canned data or an error, so no network is touched.
- ✗Trying to subclass or override
URLSessioninstead of hiding it behind a protocol - ✗Reassigning the global
sharedsingleton rather than injecting the dependency - ✗Leaving the controller reaching for
URLSession.sharedinternally after the refactor
- →Why is protocol injection cleaner than subclassing
URLSession? - →How does defaulting the init parameter keep production call sites unchanged?
The protocol hides the concrete session; production defaults to the real one, tests pass a fake.
protocol DataFetching {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: DataFetching {} // already has this exact method
final class ProfileViewController {
private let fetcher: DataFetching
init(fetcher: DataFetching = URLSession.shared) { self.fetcher = fetcher }
func loadProfile(from url: URL) async throws -> Data {
let (data, _) = try await fetcher.data(from: url)
return data
}
}
// In tests:
struct FakeFetcher: DataFetching {
var result: Result<Data, Error>
func data(from url: URL) async throws -> (Data, URLResponse) {
(try result.get(), URLResponse())
}
}
Production call sites need no change — the default parameter keeps URLSession.shared. The test constructs ProfileViewController(fetcher: FakeFetcher(result: .success(json))), so loadProfile runs against canned bytes with no network, and an error case is just .failure(...).