Sitemap

Dependency Injection in SwiftUI: Building Testable Apps

8 min readFeb 17, 2026

--

Part 41 of 100 in the SwiftUI Zero to Expert Series

Press enter or click to view image in full size

The test was simple: verify that logging in saves the user token.

But my ViewModel created its own KeychainService internally.

I couldn’t intercept it, couldn’t verify what was saved, couldn’t even run the test without touching the real keychain.

Every test was polluting device storage with test data.

“Just mock it,” a colleague said. “I can’t,” I replied. “The dependency is created inside the class.

I have no way to inject a mock.”

That’s when I learned what dependency injection actually means.

It’s not a framework or a pattern from enterprise Java.

It’s simpler: instead of creating dependencies internally, you receive them from outside.

The class doesn’t know if it’s getting a real keychain or a fake one.

It just uses whatever it’s given.

That small change — passing dependencies in instead of creating them — transformed my codebase.

Tests became trivial.

Swapping implementations became one line.

Previews worked without network calls.

Let me show you how.

Why Dependency Injection?

Look at code without DI:

import SwiftUI

// Tightly coupled - hard to test
class UserViewModel: ObservableObject {
private let api = APIService() // Created internally
private let storage = UserDefaults.standard

func fetchUser() async {
// Can't test without real API calls
}
}

How do you test fetchUser() without making real network calls? You can't.

The APIService is created inside the class.

You’d have to mock URLSession globally, configure test servers, or just skip the test entirely.

Now with DI:

import SwiftUI

// Loosely coupled - easy to test
class UserViewModel: ObservableObject {
private let api: APIServiceProtocol
private let storage: StorageProtocol

init(api: APIServiceProtocol, storage: StorageProtocol) {
self.api = api
self.storage = storage
}
}

In production, pass real implementations.

In tests, pass mocks.

In previews, pass stubs that return static data.

The ViewModel doesn’t know the difference.

Protocol-Based DI

Define Protocols

The first step is defining what your dependencies do, not how they do it:

import SwiftUI

protocol NetworkServiceProtocol {
func fetch<T: Decodable>(from url: URL) async throws -> T
}

protocol StorageServiceProtocol {
func save<T: Encodable>(_ value: T, forKey key: String) throws
func load<T: Decodable>(forKey key: String) throws -> T?
func delete(forKey key: String)
}

protocol AuthServiceProtocol {
func login(email: String, password: String) async throws -> User
func logout() async
var currentUser: User? { get }
}

These protocols define contracts.

Anything that conforms can be used interchangeably.

Implement Concrete Types

import SwiftUI

class NetworkService: NetworkServiceProtocol {
private let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

func fetch<T: Decodable>(from url: URL) async throws -> T {
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
}
}

class StorageService: StorageServiceProtocol {
private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

func save<T: Encodable>(_ value: T, forKey key: String) throws {
let data = try JSONEncoder().encode(value)
defaults.set(data, forKey: key)
}

func load<T: Decodable>(forKey key: String) throws -> T? {
guard let data = defaults.data(forKey: key) else { return nil }
return try JSONDecoder().decode(T.self, from: data)
}

func delete(forKey key: String) {
defaults.removeObject(forKey: key)
}
}

The real implementations do real work.

Notice NetworkService also accepts URLSession through its initializer—turtles all the way down.

Mock Implementations for Testing

import SwiftUI


class MockNetworkService: NetworkServiceProtocol {
var mockResponse: Any?
var shouldFail = false
var error: Error = NSError(domain: "MockError", code: 0)

func fetch<T: Decodable>(from url: URL) async throws -> T {
if shouldFail {
throw error
}
guard let response = mockResponse as? T else {
throw NSError(domain: "MockError", code: 1)
}
return response
}
}

class MockStorageService: StorageServiceProtocol {
var storage: [String: Data] = [:]

func save<T: Encodable>(_ value: T, forKey key: String) throws {
storage[key] = try JSONEncoder().encode(value)
}

func load<T: Decodable>(forKey key: String) throws -> T? {
guard let data = storage[key] else { return nil }
return try JSONDecoder().decode(T.self, from: data)
}

func delete(forKey key: String) {
storage.removeValue(forKey: key)
}
}

Mocks let you control everything.

Want to test error handling? Set shouldFail = true.

Want to verify what was saved? Check storage[key].

No real network calls, no real persistence.

Dependency Container

For apps with many dependencies, a container keeps things organized:

Simple Container

import SwiftUI

class DependencyContainer {
static let shared = DependencyContainer()

lazy var networkService: NetworkServiceProtocol = NetworkService()
lazy var storageService: StorageServiceProtocol = StorageService()
lazy var authService: AuthServiceProtocol = AuthService(
network: networkService,
storage: storageService
)

// For testing
static func mock() -> DependencyContainer {
let container = DependencyContainer()
container.networkService = MockNetworkService()
container.storageService = MockStorageService()
return container
}
}

lazy ensures services are created only when first accessed.

The mock() factory creates a container with test doubles.

Type-Safe Container

For compile-time safety, use a key-based approach:

import SwiftUI

protocol DependencyKey {
associatedtype Value
static var defaultValue: Value { get }
}

class Dependencies {
private var storage: [ObjectIdentifier: Any] = [:]

subscript<K: DependencyKey>(key: K.Type) -> K.Value {
get {
storage[ObjectIdentifier(key)] as? K.Value ?? K.defaultValue
}
set {
storage[ObjectIdentifier(key)] = newValue
}
}

static var current = Dependencies()
}

// Define keys
enum NetworkServiceKey: DependencyKey {
static var defaultValue: NetworkServiceProtocol = NetworkService()
}

enum StorageServiceKey: DependencyKey {
static var defaultValue: StorageServiceProtocol = StorageService()
}

// Property wrapper for access
@propertyWrapper
struct Dependency<Value> {
private let keyPath: WritableKeyPath<Dependencies, Value>

init(_ keyPath: WritableKeyPath<Dependencies, Value>) {
self.keyPath = keyPath
}

var wrappedValue: Value {
Dependencies.current[keyPath: keyPath]
}
}

// Extension for convenient access
extension Dependencies {
var networkService: NetworkServiceProtocol {
get { self[NetworkServiceKey.self] }
set { self[NetworkServiceKey.self] = newValue }
}

var storageService: StorageServiceProtocol {
get { self[StorageServiceKey.self] }
set { self[StorageServiceKey.self] = newValue }
}
}

This pattern mirrors SwiftUI’s environment system.

Default values exist, but you can override them for testing.

Environment-Based DI

SwiftUI’s environment is perfect for dependency injection:

Using SwiftUI Environment

import SwiftUI

// Environment key
struct NetworkServiceKey: EnvironmentKey {
static let defaultValue: NetworkServiceProtocol = NetworkService()
}

struct StorageServiceKey: EnvironmentKey {
static let defaultValue: StorageServiceProtocol = StorageService()
}

extension EnvironmentValues {
var networkService: NetworkServiceProtocol {
get { self[NetworkServiceKey.self] }
set { self[NetworkServiceKey.self] = newValue }
}

var storageService: StorageServiceProtocol {
get { self[StorageServiceKey.self] }
set { self[StorageServiceKey.self] = newValue }
}
}

// Usage in views
struct UserProfileView: View {
@Environment(\.networkService) var networkService
@State private var user: User?

var body: some View {
Group {
if let user = user {
Text(user.name)
} else {
ProgressView()
}
}
.task {
await loadUser()
}
}

func loadUser() async {
do {
user = try await networkService.fetch(from: URL(string: "...")!)
} catch {
print("Error: \(error)")
}
}
}

// Inject in app
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.networkService, NetworkService())
.environment(\.storageService, StorageService())
}
}
}

// Override for previews/tests
#Preview {
UserProfileView()
.environment(\.networkService, MockNetworkService())
}

The view doesn’t know where its networkService comes from.

In production, it’s real.

In previews, it’s mocked.

Same code, different behavior.

ViewModel Injection

Constructor Injection

The classic approach — pass dependencies through the initializer:

import SwiftUI

@Observable
class ProductListViewModel {
private let productService: ProductServiceProtocol
private let cartService: CartServiceProtocol

var products: [Product] = []
var isLoading = false
var error: Error?

init(
productService: ProductServiceProtocol,
cartService: CartServiceProtocol
) {
self.productService = productService
self.cartService = cartService
}

func loadProducts() async {
isLoading = true
defer { isLoading = false }

do {
products = try await productService.fetchAll()
} catch {
self.error = error
}
}

func addToCart(_ product: Product) {
cartService.add(product)
}
}

// View
struct ProductListView: View {
@State private var viewModel: ProductListViewModel

init(
productService: ProductServiceProtocol = ProductService(),
cartService: CartServiceProtocol = CartService.shared
) {
_viewModel = State(initialValue: ProductListViewModel(
productService: productService,
cartService: cartService
))
}

var body: some View {
List(viewModel.products) { product in
ProductRow(product: product) {
viewModel.addToCart(product)
}
}
.task {
await viewModel.loadProducts()
}
}
}

Default values in the view’s initializer mean you can create it simply (ProductListView()) or inject test dependencies (ProductListView(productService: mock)).

Factory Pattern

For complex dependency graphs, factories encapsulate creation:

import SwiftUI

protocol ViewModelFactory {
func makeProductListViewModel() -> ProductListViewModel
func makeProductDetailViewModel(product: Product) -> ProductDetailViewModel
func makeCartViewModel() -> CartViewModel
}

class DefaultViewModelFactory: ViewModelFactory {
private let networkService: NetworkServiceProtocol
private let storageService: StorageServiceProtocol
private let cartService: CartServiceProtocol

init(
networkService: NetworkServiceProtocol = NetworkService(),
storageService: StorageServiceProtocol = StorageService(),
cartService: CartServiceProtocol = CartService()
) {
self.networkService = networkService
self.storageService = storageService
self.cartService = cartService
}

func makeProductListViewModel() -> ProductListViewModel {
ProductListViewModel(
productService: ProductService(network: networkService),
cartService: cartService
)
}

func makeProductDetailViewModel(product: Product) -> ProductDetailViewModel {
ProductDetailViewModel(
product: product,
productService: ProductService(network: networkService),
cartService: cartService
)
}

func makeCartViewModel() -> CartViewModel {
CartViewModel(cartService: cartService)
}
}

// Environment key for factory
struct ViewModelFactoryKey: EnvironmentKey {
static let defaultValue: ViewModelFactory = DefaultViewModelFactory()
}

extension EnvironmentValues {
var viewModelFactory: ViewModelFactory {
get { self[ViewModelFactoryKey.self] }
set { self[ViewModelFactoryKey.self] = newValue }
}
}

// Usage
struct ProductListScreen: View {
@Environment(\.viewModelFactory) var factory
@State private var viewModel: ProductListViewModel?

var body: some View {
Group {
if let viewModel = viewModel {
ProductListContent(viewModel: viewModel)
} else {
ProgressView()
}
}
.onAppear {
viewModel = factory.makeProductListViewModel()
}
}
}

The factory knows how to wire dependencies.

Views just ask for what they need.

Testing with DI

Now for the payoff — tests that are fast, isolated, and reliable:

import SwiftUI

class ProductListViewModelTests: XCTestCase {
var mockProductService: MockProductService!
var mockCartService: MockCartService!
var viewModel: ProductListViewModel!

override func setUp() {
mockProductService = MockProductService()
mockCartService = MockCartService()
viewModel = ProductListViewModel(
productService: mockProductService,
cartService: mockCartService
)
}

func testLoadProducts_Success() async {
// Arrange
let expectedProducts = [
Product(id: "1", name: "Product 1", price: 10),
Product(id: "2", name: "Product 2", price: 20)
]
mockProductService.productsToReturn = expectedProducts

// Act
await viewModel.loadProducts()

// Assert
XCTAssertEqual(viewModel.products, expectedProducts)
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.error)
}

func testLoadProducts_Failure() async {
// Arrange
mockProductService.shouldFail = true

// Act
await viewModel.loadProducts()

// Assert
XCTAssertTrue(viewModel.products.isEmpty)
XCTAssertNotNil(viewModel.error)
}

func testAddToCart() {
// Arrange
let product = Product(id: "1", name: "Test", price: 10)

// Act
viewModel.addToCart(product)

// Assert
XCTAssertTrue(mockCartService.addedProducts.contains(where: { $0.id == product.id }))
}
}

No network calls.

No file system access.

No shared state between tests.

Each test controls exactly what the mock returns.

What I Know Now That I Wish I’d Known Then

Protocols are the key. Without protocols, you can’t swap implementations. Define them first, implement later.

Default values make adoption gradual. If your initializer has defaults, existing code keeps working. You inject only when you need to.

The environment is your friend. SwiftUI’s environment system is dependency injection built into the framework. Use it.

Factories simplify complex graphs. When services depend on other services, a factory prevents tangled initializer chains.

The Gotchas That Caught Me

Protocol witnesses can’t be stored. You can’t put a NetworkServiceProtocol in an array without type erasure. Use concrete wrapper types or any NetworkServiceProtocol in Swift 5.7+.

Singletons fight DI. If a dependency is a singleton (Service.shared), you can't inject alternatives. Prefer injected instances over static access.

Over-abstracting hurts. Not everything needs a protocol. If there’s only one implementation and you’ll never mock it, skip the abstraction.

Memory management with closures. Injected dependencies in closures need [weak self] or you'll create retain cycles.

Dependency Injection enables:

  • Testability through mock implementations
  • Flexibility to swap implementations
  • Decoupling between components
  • Configurability for different environments

That untestable ViewModel I started with? Now I can test it in milliseconds with complete control over every dependency.

The keychain test that polluted device storage? It uses an in-memory mock that resets between tests.

DI isn’t about frameworks or complex patterns.

It’s about a simple principle: receive dependencies from outside instead of creating them inside.

That small change makes everything else possible.

In the next article, we’ll explore ViewModel patterns — structuring the layer between your views and your business logic.

Found this helpful? Share it!

Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀

--

--