Question 2

Designing for testability

You inherit a feature that's hard to test. How do you refactor or redesign it to make it testable without over-engineering?

Answer outline

Start by naming what makes it hard: hidden singletons, layers that reach into each other, or one method that does five things. Then find the smallest seam, the one spot where you can swap a dependency or observe a result, and assert one meaningful outcome there.

Prefer small targeted changes over a big-bang rewrite. Each step should leave you with one place that returns a result a test can observe, instead of a web of side effects.

Introduce a protocol only where you need substitution. A thin protocol around URLSession, NSCache, or your analytics client is usually enough. Abstract factories on every type are the over-engineering the question warns about.

Principles

  • Inject dependencies through the initializer instead of reaching for globals.
  • Separate pure logic from UI, because a function moved into a plain struct or enum is trivial to unit test.
  • Prefer value types for inputs and outputs so tests can build and compare them directly.
  • Make side effects explicit by returning a result or exposing a callback the test can capture.

A small protocol wraps the dependency that talks to the outside world, so the test passes a stub without touching networking, disk, or global state:

Protocol dependency injection
protocol UserFetching {
    func fetchUser(id: String) async throws -> User
}

final class ProfileViewModel {
    private let fetcher: UserFetching

    init(fetcher: UserFetching) {
        self.fetcher = fetcher
    }

    func loadName(id: String) async throws -> String {
        let user = try await fetcher.fetchUser(id: id)
        return user.name
    }
}

struct StubUserFetcher: UserFetching {
    func fetchUser(id: String) async throws -> User {
        User(id: id, name: "Ada")
    }
}

func testLoadNameUsesInjectedFetcher() async throws {
    let sut = ProfileViewModel(fetcher: StubUserFetcher())

    let name = try await sut.loadName(id: "1")

    XCTAssertEqual(name, "Ada")
}

A pure function is the easiest unit test target, because the same input always produces the same output and there's nothing to mock:

Pure function unit test
enum PriceFormatter {
    static func displayPrice(cents: Int, currency: String = "$") -> String {
        let dollars = Double(cents) / 100
        return "\(currency)\(String(format: "%.2f", dollars))"
    }
}

func testDisplayPriceFormatsCents() {
    XCTAssertEqual(PriceFormatter.displayPrice(cents: 1299), "$12.99")
    XCTAssertEqual(PriceFormatter.displayPrice(cents: 0), "$0.00")
}