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:
- Write a failing test
- Write minimal code to make it pass
- 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)
}
}
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().
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.
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.
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().
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.
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)
This makes your tests deterministic and independent of network or database services.
5. Legacy Code and Incremental Testing
Testing legacy apps requires careful planning:
- Identify critical flows (e.g., login success/failure)
- Introduce protocols and DI to decouple code
- Write unit tests incrementally
- Use test doubles to isolate untestable parts
- 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")
}
}
Test:
func testLegacyManagerLogin() {
let stub = AuthFetcherStub()
let manager = LegacyLoginManager(fetcher: stub)
XCTAssertEqual(manager.login(username: "any", password: "any")?.token, "legacyToken")
}
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)
Well written content regarding Unit Testing. An Insightful blueprint.