learn()
{ start }
build++
const tutorial

Modern iOS App Architecture - Part 3: Testing & Best Practices

Master testing strategies, dependency injection patterns, and production-ready best practices for modern iOS applications.

Cuppa Team6 min read
iosswifttestingbest-practicesdependency-injection

In Part 3, we cover testing strategies, advanced dependency injection, and production-ready best practices.

Testing Strategies

1. Unit Testing ViewModels

@testable import MyApp
import XCTest

final class ProfileViewModelTests: XCTestCase {
    var sut: ProfileViewModel!
    var mockRepository: MockUserRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = ProfileViewModel(repository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    func testLoadProfile_Success() async throws {
        // Given
        let expectedProfile = UserProfile.mock
        mockRepository.profileToReturn = expectedProfile

        // When
        await sut.loadProfile()

        // Then
        XCTAssertEqual(sut.profile, expectedProfile)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }

    func testLoadProfile_Failure() async throws {
        // Given
        mockRepository.shouldFail = true

        // When
        await sut.loadProfile()

        // Then
        XCTAssertNil(sut.profile)
        XCTAssertNotNil(sut.error)
    }
}

2. Mock Dependencies

final class MockUserRepository: UserRepository {
    var profileToReturn: UserProfile?
    var shouldFail = false
    var updateCalled = false

    func getUserProfile() async throws -> UserProfile {
        if shouldFail {
            throw ProfileError.fetchFailed
        }
        guard let profile = profileToReturn else {
            throw ProfileError.notFound
        }
        return profile
    }

    func updateProfile(_ updates: ProfileUpdates) async throws {
        updateCalled = true
        if shouldFail {
            throw ProfileError.updateFailed
        }
    }
}

extension UserProfile {
    static var mock: UserProfile {
        UserProfile(
            id: "test-id",
            email: "test@example.com",
            name: "Test User",
            avatarURL: nil,
            createdAt: Date()
        )
    }
}

3. Testing Async/Await

func testAsyncOperation() async throws {
    let result = try await sut.performAsyncOperation()
    XCTAssertEqual(result, expectedValue)
}

func testConcurrentOperations() async throws {
    await withTaskGroup(of: Void.self) { group in
        group.addTask {
            await self.sut.operation1()
        }
        group.addTask {
            await self.sut.operation2()
        }
    }

    XCTAssertTrue(sut.bothCompleted)
}

Dependency Injection

1. Protocol-Based DI

protocol APIClient {
    func request<T: Decodable>(_ endpoint: String) async throws -> T
}

final class URLSessionAPIClient: APIClient {
    func request<T: Decodable>(_ endpoint: String) async throws -> T {
        // Implementation
    }
}

// Easy to inject mocks in tests
final class MockAPIClient: APIClient {
    var responseToReturn: Any?

    func request<T: Decodable>(_ endpoint: String) async throws -> T {
        guard let response = responseToReturn as? T else {
            throw APIError.invalidResponse
        }
        return response
    }
}

2. Environment-Based DI

private struct APIClientKey: EnvironmentKey {
    static let defaultValue: APIClient = URLSessionAPIClient()
}

extension EnvironmentValues {
    var apiClient: APIClient {
        get { self[APIClientKey.self] }
        set { self[APIClientKey.self] = newValue }
    }
}

// Usage in Views
struct ContentView: View {
    @Environment(\.apiClient) private var apiClient

    var body: some View {
        // Use apiClient
    }
}

// Inject in tests
ContentView()
    .environment(\.apiClient, MockAPIClient())

3. Property Wrapper DI

@propertyWrapper
struct Injected<T> {
    private let keyPath: KeyPath<DIContainer, T>

    var wrappedValue: T {
        DIContainer.shared[keyPath: keyPath]
    }

    init(_ keyPath: KeyPath<DIContainer, T>) {
        self.keyPath = keyPath
    }
}

// Usage
final class ViewModel: ObservableObject {
    @Injected(\.userRepository) private var userRepository: UserRepository

    func loadData() async {
        let profile = try await userRepository.getUserProfile()
    }
}

Production Best Practices

1. Error Tracking

protocol ErrorReporter {
    func report(_ error: Error, context: [String: Any]?)
}

final class CrashlyticsErrorReporter: ErrorReporter {
    func report(_ error: Error, context: [String: Any]?) {
        // Send to Crashlytics/Sentry
    }
}

@MainActor
final class ViewModel: ObservableObject {
    @Injected(\.errorReporter) private var errorReporter: ErrorReporter

    func loadData() async {
        do {
            try await fetchData()
        } catch {
            errorReporter.report(error, context: [
                "screen": "ProfileView",
                "userId": currentUserId
            ])
            self.error = error
        }
    }
}

2. Analytics

protocol AnalyticsTracker {
    func track(_ event: String, properties: [String: Any]?)
    func screen(_ name: String)
}

extension View {
    func trackScreen(_ name: String) -> some View {
        onAppear {
            DIContainer.shared.analytics.screen(name)
        }
    }
}

// Usage
ProfileView()
    .trackScreen("Profile")

3. Feature Flags

protocol FeatureFlags {
    func isEnabled(_ feature: Feature) -> Bool
}

enum Feature: String {
    case newOnboarding
    case darkMode
    case premiumFeatures
}

struct FeatureView: View {
    @Environment(\.featureFlags) private var featureFlags

    var body: some View {
        if featureFlags.isEnabled(.newOnboarding) {
            NewOnboardingView()
        } else {
            LegacyOnboardingView()
        }
    }
}

4. Logging

enum LogLevel {
    case debug, info, warning, error
}

protocol Logger {
    func log(_ message: String, level: LogLevel, file: String, line: Int)
}

extension Logger {
    func debug(_ message: String, file: String = #file, line: Int = #line) {
        log(message, level: .debug, file: file, line: line)
    }

    func error(_ message: String, file: String = #file, line: Int = #line) {
        log(message, level: .error, file: file, line: line)
    }
}

Performance Monitoring

1. Task Profiling

func profileTask<T>(_ name: String, task: () async throws -> T) async rethrows -> T {
    let start = Date()
    defer {
        let duration = Date().timeIntervalSince(start)
        logger.debug("Task '\(name)' took \(duration)s")
    }
    return try await task()
}

// Usage
let data = await profileTask("fetchUserData") {
    try await repository.fetchUserData()
}

2. Memory Management

@MainActor
final class ViewModel: ObservableObject {
    private var cancellables = Set<AnyCancellable>()

    deinit {
        cancellables.removeAll()
    }

    func cleanup() {
        cancellables.removeAll()
        // Clean up resources
    }
}

Real-World Example

Complete Feature Implementation

// 1. Domain Model
struct Article: Identifiable {
    let id: String
    let title: String
    let content: String
    let publishedAt: Date
}

// 2. Repository Protocol
protocol ArticleRepository {
    func getArticles() async throws -> [Article]
    func getArticle(id: String) async throws -> Article
}

// 3. Repository Implementation
final class ArticleRepositoryImpl: ArticleRepository {
    private let apiClient: APIClient
    private let cache: Cache

    init(apiClient: APIClient, cache: Cache) {
        self.apiClient = apiClient
        self.cache = cache
    }

    func getArticles() async throws -> [Article] {
        if let cached: [Article] = cache.get("articles") {
            return cached
        }

        let articles: [Article] = try await apiClient.request("/articles")
        cache.set(articles, forKey: "articles")
        return articles
    }

    func getArticle(id: String) async throws -> Article {
        try await apiClient.request("/articles/\(id)")
    }
}

// 4. ViewModel
@MainActor
final class ArticleListViewModel: ObservableObject {
    @Published private(set) var articles: [Article] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?

    @Injected(\.articleRepository) private var repository: ArticleRepository
    @Injected(\.analytics) private var analytics: AnalyticsTracker

    func loadArticles() async {
        isLoading = true
        error = nil

        do {
            articles = try await repository.getArticles()
            analytics.track("articles_loaded", properties: [
                "count": articles.count
            ])
        } catch {
            self.error = error
            analytics.track("articles_load_failed", properties: [
                "error": error.localizedDescription
            ])
        }

        isLoading = false
    }
}

// 5. SwiftUI View
struct ArticleListView: View {
    @StateObject private var viewModel = ArticleListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else if let error = viewModel.error {
                    ErrorView(error: error) {
                        Task { await viewModel.loadArticles() }
                    }
                } else {
                    List(viewModel.articles) { article in
                        ArticleRow(article: article)
                    }
                }
            }
            .navigationTitle("Articles")
            .refreshable {
                await viewModel.loadArticles()
            }
        }
        .task {
            await viewModel.loadArticles()
        }
        .trackScreen("ArticleList")
    }
}

// 6. Unit Test
final class ArticleListViewModelTests: XCTestCase {
    func testLoadArticles_Success() async throws {
        let mockRepo = MockArticleRepository()
        mockRepo.articlesToReturn = [.mock]

        let sut = ArticleListViewModel(repository: mockRepo)

        await sut.loadArticles()

        XCTAssertEqual(sut.articles.count, 1)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }
}

Key Takeaways

  1. Test First: Write unit tests for business logic
  2. Use Protocols: Makes code testable and flexible
  3. Dependency Injection: Centralize dependencies
  4. Error Handling: Track and report errors properly
  5. Performance: Monitor and optimize critical paths
  6. Analytics: Track user behavior and errors
  7. Feature Flags: Enable safe rollouts

Additional Resources

Series Conclusion

This three-part series covered:

  • Part 1: Foundation & Layered Architecture
  • Part 2: State Management & Navigation
  • Part 3: Testing & Production Best Practices

You now have a solid foundation for building scalable, maintainable iOS applications!