The architecture in this course exists to be testable. Write fast unit tests for view models and services with XCTest and fakes, and cover critical flows with XCUITest UI tests.
A healthy iOS test suite is mostly fast unit tests (view models, services, mappers — run without the UI), fewer integration tests, and a small number of slow end-to-end UI tests on the simulator. The MVVM, protocol, and DI structure you built is exactly what makes the bulk of your logic unit-testable without launching the app — the payoff of the architecture.
/\ UI tests (XCUITest, on the simulator) — few, slow
/ \ verify critical flows end-to-end
/----\ integration tests — some (against a real store/service)
/ \
/--------\ UNIT tests (no UI) — MANY, fast, cheap
view models, services, mappers, formatting
Unit tests run in milliseconds; UI tests take seconds + can be flaky. The
MVVM + protocol + DI structure makes most logic unit-testable WITHOUT the UI.Because a view model depends on a protocol, a test constructs it with a fake implementation, drives it, and asserts on its state — no network, no simulator. XCTest is the framework; the newer Swift Testing offers a more modern syntax. Either way, this fast, deterministic test is only possible because you inverted the dependency.
import XCTest
// a fake service — controlled, no network
struct FakeUserService: UserService {
func fetchUser(_ id: Int) async throws -> User { User(id: id, name: "Ada") }
}
final class ProfileViewModelTests: XCTestCase {
func testLoadPopulatesUser() async {
let vm = ProfileViewModel(service: FakeUserService()) // inject the fake
await vm.load(id: 1)
XCTAssertEqual(vm.user?.name, "Ada") // assert on state
}
}
// inject a protocol -> substitute a fake -> fast, deterministic tests.Good tests cover more than the happy path: assert that an offline or server error produces the right UI state, and that async work resolves as expected. Because your view model translates outcomes into an explicit state enum, you can drive each branch by having the fake succeed or throw. Testing the failure paths is where reliability is won.
struct FailingService: UserService {
func fetchUser(_ id: Int) async throws -> User { throw NetworkError.offline }
}
final class ProfileErrorTests: XCTestCase {
func testLoadSetsErrorState() async {
let vm = ProfileViewModel(service: FailingService())
await vm.load(id: 1)
// the view model should reflect failure, not crash or hang:
if case .failed(let msg) = vm.state { XCTAssertFalse(msg.isEmpty) }
else { XCTFail("expected .failed") }
}
}
// test the OFFLINE + SERVER paths, not just success. That's where bugs hide.For the few end-to-end checks, XCUITest launches the app in the simulator, finds elements by accessibility identifiers, performs taps and text entry, and asserts on what appears. These are slower and more brittle, so keep them focused on critical flows (sign-in, checkout). Good accessibility identifiers — the same ones that help VoiceOver — make UI tests reliable.
import XCTest
final class LoginUITests: XCTestCase {
func testLoginFlow() {
let app = XCUIApplication()
app.launch()
app.textFields["emailField"].tap() // find by accessibility id
app.textFields["emailField"].typeText("ada@x.com")
app.secureTextFields["passwordField"].typeText("secret")
app.buttons["loginButton"].tap()
XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 2))
}
}
// keep UI tests FEW + focused on critical flows (they're slow + brittle).
// stable accessibility identifiers make them reliable.