A complete guide to unit testing in iOS — testing ViewModels, Use Cases, and the Network layer using XCTest, Mocks, and async/await.
A unit test verifies that one small piece of logic works correctly in complete isolation — no real network, no real database, no UI.
Without tests: With tests:
"I think it works" ❓ "I know it works" ✅
Manual testing every time Automated — runs in seconds
Fear of changing code Confidence to refactor freely
A Mock is a fake version of a dependency used only in tests.
// Real — hits the network
class UserRepository: UserRepositoryProtocol {
func fetchUsers() async throws -> [User] {
// real API call...
}
}
// Mock — returns fake data instantly
class MockUserRepository: UserRepositoryProtocol {
var usersToReturn: [User] = []
var errorToThrow: Error? = nil
func fetchUsers() async throws -> [User] {
if let error = errorToThrow { throw error }
return usersToReturn // instant, predictable, no network
}
}Every test follows the Arrange → Act → Assert pattern:
func test_loadUsers_onSuccess_populatesUsersArray() async {
// ARRANGE — set up the scenario
mockRepository.usersToReturn = [
User(id: 1, name: "Ali", email: "ali@test.com")
]
// ACT — run the function under test
await sut.loadUsers()
// ASSERT — verify the outcome
XCTAssertEqual(sut.users.count, 1)
XCTAssertNil(sut.errorMessage)
XCTAssertFalse(sut.isLoading)
}| Test | Verifies |
|---|---|
test_execute_withValidUsers_returnsSortedUsers |
Business rule: sorted A→Z |
test_execute_withEmptyEmailUser_filtersThemOut |
Business rule: no empty emails |
test_execute_withEmptyList_returnsEmptyArray |
Edge case: empty result |
test_execute_whenRepositoryThrows_propagatesError |
Error propagation |
test_execute_callsRepositoryExactlyOnce |
No duplicate calls |
test_execute_withAllEmptyEmails_returnsEmptyArray |
All filtered out |
| Test | Verifies |
|---|---|
test_loadUsers_onSuccess_populatesUsersArray |
Happy path |
test_loadUsers_onSuccess_clearsErrorMessage |
Error cleared on retry |
test_loadUsers_withEmptyResult_hasEmptyUsersArray |
Empty state |
test_loadUsers_isLoadingFalse_afterCompletion |
Loading resets |
test_loadUsers_onNoInternet_setsErrorMessage |
Network error UI |
test_loadUsers_onUnauthorized_setsCorrectError |
401 error message |
test_loadUsers_isLoadingFalse_evenAfterError |
Loading resets on failure |
| Test | Verifies |
|---|---|
test_request_withValidJSON_decodesCorrectly |
JSON decoding |
test_request_withUsersListJSON_decodesAllUsers |
Array decoding |
test_request_withEmptyArray_returnsEmptyList |
Empty array |
test_request_withInvalidJSON_throwsDecodingError |
Malformed JSON |
test_request_whenUnauthorized_throwsUnauthorizedError |
401 error |
test_request_isCalledExactlyOnce |
Call count tracking |
iOSUnitTesting/
├── Source/ ← Production code
│ ├── Models/
│ │ └── User.swift
│ ├── Network/
│ │ ├── NetworkError.swift
│ │ └── NetworkClient.swift
│ ├── Repository/
│ │ ├── UserRepositoryProtocol.swift
│ │ └── UserRepository.swift
│ ├── UseCases/
│ │ └── FetchUsersUseCase.swift
│ └── ViewModel/
│ └── UsersViewModel.swift
│
└── Tests/ ← Test code
├── Mocks/
│ ├── MockUserRepository.swift ← Fake repository
│ └── MockNetworkClient.swift ← Fake network + test data
├── UseCaseTests/
│ └── FetchUsersUseCaseTests.swift
├── ViewModelTests/
│ └── UsersViewModelTests.swift
└── NetworkTests/
└── NetworkClientTests.swift
Xcode → Product → Test (⌘ + U)
All tests should pass with green checkmarks ✅
| Principle | Description |
|---|---|
| Test in isolation | Each test focuses on one class only |
| No real network | Mocks replace all external dependencies |
| AAA Pattern | Arrange → Act → Assert, every test |
| One assertion per test | Clear failure messages |
| Protocol-based DI | Makes mocking possible |
- iOS 17.0+
- Xcode 15.0+
- Swift 5.9+