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 Team•6 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
- Test First: Write unit tests for business logic
- Use Protocols: Makes code testable and flexible
- Dependency Injection: Centralize dependencies
- Error Handling: Track and report errors properly
- Performance: Monitor and optimize critical paths
- Analytics: Track user behavior and errors
- 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!