DEV Community

Vinayak G Hejib
Vinayak G Hejib

Posted on

The Essentials of Unit Testing in iOS - A Quick Guide

Unit testing is the backbone of robust, maintainable iOS apps. Think of it like having a safety net under a tightrope, it won’t stop you from writing risky code, but it will catch you if things go wrong. Writing reliable tests ensures your code behaves as expected, prevents regressions, and gives developers confidence when refactoring (without that dreaded “did I just break production?” moment).

In this post, we’ll take a practical (and slightly fun) tour of unit testing in iOS. We’ll cover:

  • TDD (Test-Driven Development)
  • Dependency Injection for testability
  • The world of Test Doubles : Dummy, Stub, Fake, Mock, Spy (yes, they sound like a crime thriller cast)
  • Testing strategies for legacy codebases with missing test coverage
  • Practical testing standards and best practices

And to make it concrete, we’ll walk through everything with a Login flow example, because if there’s one thing every app has, it’s a login screen (and if it fails, your users won’t stick around).


1. Why Unit Testing Matters in iOS

Unit tests verify individual components of your app in isolation. In iOS, this could be:

  • A ViewModel in SwiftUI or UIKit (MVVM)
  • A network layer that authenticates users
  • A utility or business logic class

Benefits:

  • Detect regressions early
  • Make refactoring safer
  • Enable Test-Driven Development (TDD)
  • Document expected behavior

By testing individual units, you ensure your app behaves as expected even as the codebase grows or changes.


2. Test-Driven Development (TDD)

TDD is a disciplined approach to building software:

  1. Write a failing test
  2. Write minimal code to make it pass
  3. Refactor for readability and efficiency

LoginViewModel Example:

struct User {
    let username: String
    let token: String
}

protocol AuthServiceProtocol {
    func login(username: String, password: String) -> User?
}

class LoginViewModel {
    private let authService: AuthServiceProtocol
    private(set) var loggedInUser: User?

    init(authService: AuthServiceProtocol) {
        self.authService = authService
    }

    func login(username: String, password: String) {
        loggedInUser = authService.login(username: username, password: password)
    }
}
Enter fullscreen mode Exit fullscreen mode

This setup allows TDD to guide your design while keeping the code testable and maintainable.


3. Test Doubles: Dummy, Stub, Fake, Mock, Spy

Test doubles allow you to isolate the unit under test from dependencies. Each type has a specific role:

Type Purpose Example in iOS
Dummy Passed but never used Placeholder AuthService to satisfy method signature
Stub Returns fixed responses AuthServiceStub returning predetermined login results
Fake Lightweight working implementation In-memory login service for testing
Mock Records method calls, allows verification Verify that login() is called exactly once
Spy Records calls and behavior Ensure delegate methods or callbacks are triggered

Examples with Login Flow

Dummy – used just to satisfy dependency injection, not actually used by the test.

class AuthServiceDummy: AuthServiceProtocol {
    func login(username: String, password: String) -> User? {
        return nil // Not used in this test
    }
}
// Example: Passing this to LoginViewModel ensures the constructor compiles, 
// even though we won’t call login().
Enter fullscreen mode Exit fullscreen mode

Stub – provides predefined outputs so tests can run deterministically.

class AuthServiceStub: AuthServiceProtocol {
    func login(username: String, password: String) -> User? {
        if username == "test" && password == "1234" {
            return User(username: username, token: "abc123")
        }
        return nil
    }
}
// Example: Ensures LoginViewModel always gets a known response for given inputs.
Enter fullscreen mode Exit fullscreen mode

Fake – a simplified but working implementation (e.g., in-memory user store).

class AuthServiceFake: AuthServiceProtocol {
    private var users: [String: String] = ["test": "1234"]

    func login(username: String, password: String) -> User? {
        guard let storedPassword = users[username], storedPassword == password else { return nil }
        return User(username: username, token: "fakeToken123")
    }
}
// Example: Acts like a real auth service without network calls.
Enter fullscreen mode Exit fullscreen mode

Mock – verifies that specific methods are invoked.

class AuthServiceMock: AuthServiceProtocol {
    var loginCalled = false

    func login(username: String, password: String) -> User? {
        loginCalled = true
        return nil
    }
}
// Example: Lets us check if LoginViewModel actually triggered login().
Enter fullscreen mode Exit fullscreen mode

Spy – tracks how many times or with what data a method is called.

class AuthServiceSpy: AuthServiceProtocol {
    private(set) var loginCallCount = 0

    func login(username: String, password: String) -> User? {
        loginCallCount += 1
        return User(username: username, token: "spyToken")
    }
}
// Example: Useful for verifying multiple login attempts are handled.
Enter fullscreen mode Exit fullscreen mode

4. Dependency Injection (DI)

Hard-coded dependencies make testing difficult. DI allows injecting stubs, fakes, or mocks:


let stubService = AuthServiceStub()
let viewModel = LoginViewModel(authService: stubService)
Enter fullscreen mode Exit fullscreen mode

This makes your tests deterministic and independent of network or database services.


5. Legacy Code and Incremental Testing

Testing legacy apps requires careful planning:

  1. Identify critical flows (e.g., login success/failure)
  2. Introduce protocols and DI to decouple code
  3. Write unit tests incrementally
  4. Use test doubles to isolate untestable parts
  5. Focus on core logic over 100% coverage initially

Refactoring Example:

protocol AuthFetcherProtocol {
    func login(username: String, password: String) -> User?
}

class LegacyLoginManager {
    private let fetcher: AuthFetcherProtocol

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

    func login(username: String, password: String) -> User? {
        fetcher.login(username: username, password: password)
    }
}

class AuthFetcherStub: AuthFetcherProtocol {
    func login(username: String, password: String) -> User? {
        return User(username: username, token: "legacyToken")
    }
}
Enter fullscreen mode Exit fullscreen mode

Test:

func testLegacyManagerLogin() {
    let stub = AuthFetcherStub()
    let manager = LegacyLoginManager(fetcher: stub)
    XCTAssertEqual(manager.login(username: "any", password: "any")?.token, "legacyToken")
}
Enter fullscreen mode Exit fullscreen mode

6. Testing Standards and Best Practices

For effective unit testing in iOS:

  • AAA Pattern (Arrange-Act-Assert): Structure tests clearly.
  • Keep tests independent: No shared state between tests.
  • Name tests descriptively: testLoginSucceedsWithValidCredentials > testLogin1.
  • Test behavior, not implementation: Verify outcomes, not internal details.
  • Use coverage as a guide, not a goal: Focus on meaningful tests.
  • Fast feedback: Unit tests should run in seconds.

7. Efficient Testing Strategies

  • Start with small units: ViewModels, services, utilities
  • Use DI and test doubles to avoid network/db dependencies
  • Separate functional vs. unit tests: Logic vs. UI interactions
  • Leverage XCTest features: XCTAssert, XCTestExpectation, measure
  • Incrementally cover legacy code: Test core flows first

Key Takeaways

  • Unit testing ensures maintainable, bug-free apps
  • TDD, DI, and test doubles make testing fast and isolated
  • Use Dummy, Stub, Fake, Mock, and Spy for different testing scenarios
  • Legacy code can be incrementally tested and refactored
  • Follow standards and best practices for clarity and reliability

Top comments (1)

Collapse
 
chethucodes profile image
Chethan

Well written content regarding Unit Testing. An Insightful blueprint.