Architecture With Nico

Practical software architecture and design for iOS engineers.

Testing Architecture, Not Implementation Details

Tests can protect architecture, but they can also make architecture harder to change.

The difference is what the tests care about.

If tests care about behavior and boundaries, they give you confidence. If tests care about every private step, every collaborator call, and every incidental state transition, they become another reason the code is hard to change.

Good architecture should make testing easier. Good tests should make architecture easier to evolve.

#Test the Promise, Not the Plumbing

Every type makes a promise.

A use case might promise that placing an order charges payment and stores the order. A ViewModel might promise that products are rendered as loading, loaded, or failed state. A repository might promise that cached data is returned when the network is unavailable.

Those promises are worth testing.

The plumbing is less important.

func test_didLoadProducts_showsLoadedProducts() {
    let viewModel = ProductsViewModel()

    viewModel.didLoadProducts([.keyboard])

    XCTAssertEqual(viewModel.state, .loaded([.keyboard]))
}

This test does not care which repository, use case, or adapter triggered the output. It cares about the behavior the ViewModel offers to the view.

That gives you room to refactor.

#Avoid Testing the Dependency Graph Everywhere

When using dependency injection, it is tempting to assert every collaborator interaction.

XCTAssertEqual(repository.getProductsCallCount, 1)
XCTAssertEqual(analytics.trackCallCount, 1)
XCTAssertEqual(cache.saveCallCount, 1)

Sometimes this is exactly right. If analytics tracking is a requirement, test it. If saving to cache is the observable promise of the use case, test it.

But if every test asserts every collaborator call, implementation details become public through tests. A refactor from direct analytics to an analytics decorator should not break unrelated product-loading tests.

Architecture is easier to change when tests are clear about which boundary they are protecting.

#Contract Tests for Abstractions

Protocols are useful when multiple implementations must behave the same way.

That shared behavior deserves tests.

final class ProductsRepositoryContractTests: XCTestCase {
    func assertRepositoryReturnsSavedProducts(
        makeRepository: () -> ProductsRepository
    ) async throws {
        let repository = makeRepository()

        try await repository.save([.keyboard])
        let products = try await repository.getProducts()

        XCTAssertEqual(products, [.keyboard])
    }
}

Now an in-memory repository, a database repository, and a network-backed repository can share the same behavioral expectations where appropriate.

This is more valuable than testing that each implementation happens to call a specific helper internally.

#Test Decorators by Their Added Behavior

Decorators are a great place to write focused tests because each decorator should add one behavior.

func test_getProducts_tracksAnalyticsOnSuccess() async throws {
    let analytics = AnalyticsTrackerSpy()
    let provider = AnalyticsProductsProviderDecorator(
        decoratee: ProductsProviderStub(result: .success([.keyboard])),
        analytics: analytics
    )

    _ = try await provider.getProducts()

    XCTAssertEqual(analytics.trackedEvents, ["products_loaded"])
}

The test does not need to prove that the decoratee works. The decoratee has its own tests. This test proves that the decorator adds analytics.

That is the beauty of small composable objects: tests can stay small too.

This connects with Practical Polymorphism in Swift, where decorators let us add behavior without editing existing code.

#Test Composition Sparingly

Should you test the composition root?

Sometimes, yes. But be careful.

The composition root wires concrete objects together. A test that asserts the exact type of every object can become brittle quickly.

This is often enough:

func test_compositionRootBuildsProductsFeature() {
    let root = CompositionRoot()

    let view = root.makeProductsView()

    XCTAssertNotNil(view)
}

That test is not very deep, but it catches obvious wiring failures. For more confidence, integration tests or preview smoke tests may be better than asserting the private object graph.

If your composition root is complex enough that it needs many tests, that may be a design signal. Split feature factories out of the root so app-level composition chooses concrete dependencies while feature-level construction stays close to the feature.

#Do Not Mock What You Do Not Own

Mocking system frameworks and third-party SDKs directly can make tests fragile.

Instead, wrap external dependencies behind small interfaces you own.

protocol AnalyticsTracking {
    func track(_ event: String)
}

Your app depends on AnalyticsTracking. The concrete implementation can call Firebase, another SDK, or nothing at all in tests.

This is not about mocking everything. It is about putting a boundary around things that are outside your control.

#Prefer Fakes for State, Spies for Effects

A fake has working behavior.

final class InMemoryProductsRepository: ProductsRepository {
    private var products: [Product] = []

    func save(_ products: [Product]) {
        self.products = products
    }

    func getProducts() -> [Product] {
        products
    }
}

A spy records interactions.

final class AnalyticsTrackerSpy: AnalyticsTracking {
    private(set) var trackedEvents: [String] = []

    func track(_ event: String) {
        trackedEvents.append(event)
    }
}

Use fakes when behavior matters. Use spies when effects matter. Avoid giant mocks that try to be both a fake system and a call recorder for every method.

#A Test Should Explain the Boundary

Good test names describe architectural intent:

  • test_checkout_savesOrderAfterSuccessfulPayment
  • test_productsViewModel_mapsUseCaseFailureToReadableMessage
  • test_cachedRepository_returnsCachedProductsWhenNetworkFails

Weak test names describe implementation:

  • test_execute_callsRepository
  • test_viewModel_setsStateThreeTimes
  • test_methodInvokesHelper

The first group tells us what behavior matters. The second group mostly tells us how the current implementation happens to work.

#What You Should Remember

Architecture tests should protect useful boundaries, not freeze plumbing.

Test promises. Test contracts. Test decorators by their added behavior. Test composition lightly. Wrap external systems behind interfaces you own.

The best tests give you confidence to change the code. If tests make every refactor feel dangerous, they may be testing the wrong thing.