🎯 Difficulty: Intermediate to Advanced

In this speed coding session, we write unit tests for a Swift ViewModel built with the MVVM architecture and powered by Combine and @MainActor concurrency.

You’ll see how to:

  • ✅ Write precise and meaningful unit tests using XCTest
  • 🔍 Test async @MainActor functions with Combine’s @Published properties
  • 🧪 Mock dependencies like ChinesePhraseSaver, Loader, and Deleter
  • 🔄 Validate onLoad() and onTapFavorite() logic
  • ⚠️ Handle error states with shouldShowErrorAlert
  • 🧼 Ensure correctness using behavior-driven test naming

We aim for properly crafted unit tests that naturally lead to high code coverage, making your codebase more robust and regression-proof.

📦 Topics Covered:

  • MVVM architecture in Swift
  • Writing testable ViewModels
  • Dependency injection
  • Async/await testing with Combine
  • Mocking and verifying collaborator interactions

🧠 Best for:

Intermediate to advanced iOS developers familiar with Swift, XCTest, and modern concurrency.

💡 Don’t forget to like, subscribe, and turn on notifications for more real-time iOS engineering content!


📄 Source Code:

🔸 CellDetailViewViewModel.swift

import Combine
import Shared
@MainActor
final class CellDetailViewViewModel: ObservableObject {
private(set) var phrase: PhraseEntity
private let chinesePhraseSaver: any ChinesePhraseSaver
private let chinesePhraseLoader: any ChinesePhraseLoader
private let chinesePhraseDeleter: any ChinesePhraseDeleter
@Published var isFavorite = false
@Published var shouldShowErrorAlert = false
init(
phrase: PhraseEntity,
chinesePhraseSaver: some ChinesePhraseSaver,
chinesePhraseLoader: some ChinesePhraseLoader,
chinesePhraseDeleter: some ChinesePhraseDeleter
) {
self.phrase = phrase
self.chinesePhraseSaver = chinesePhraseSaver
self.chinesePhraseLoader = chinesePhraseLoader
self.chinesePhraseDeleter = chinesePhraseDeleter
}
func onLoad() async {
isFavorite = await isFavoriteAlready(for: phrase)
}
private func isFavoriteAlready(for phrase: PhraseEntity) async -> Bool {
do {
_ = try await chinesePhraseLoader.loadSavedPhrase(phrase: phrase)
return true
} catch {
return false
}
}
func onTapFavorite() async {
let isFavoriteAlready = await isFavoriteAlready(for: phrase)
if isFavoriteAlready {
do {
try await chinesePhraseDeleter.delete(phrase: phrase)
await refreshIsFavorite()
} catch {
shouldShowErrorAlert = true
}
} else {
do {
try await chinesePhraseSaver.save(phrase)
await refreshIsFavorite()
} catch {
shouldShowErrorAlert = true
}
}
}
private func refreshIsFavorite() async {
await onLoad()
}
}

🔸 CellDetailViewViewModelTests.swift

import XCTest
@testable import HuiShuo
import Shared
final class CellDetailViewViewModelTests: XCTestCase {
@MainActor
func testInit_doesNotRequestCollaborators() {
let (_, saver, loader, deleter) = makeSUT(phraseToLoad: .niHao)
XCTAssertTrue(saver.invocations.isEmpty)
XCTAssertTrue(loader.invocations.isEmpty)
XCTAssertTrue(deleter.invocations.isEmpty)
}
@MainActor
func testOnLoad_requestloadsSavedPhrase() async {
let phraseToLoad: PhraseEntity = .niHao
let (sut, saver, loader, deleter) = makeSUT(phraseToLoad: phraseToLoad)
await sut.onLoad()
XCTAssertEqual(loader.invocations, [ .loadSavedPhrase(phrase: phraseToLoad) ])
XCTAssertTrue(saver.invocations.isEmpty)
XCTAssertTrue(deleter.invocations.isEmpty)
}
@MainActor
func testOnLoadTwice_requestloadsSavedPhraseTwice() async {
let phraseToLoad: PhraseEntity = .niHao
let (sut, _, loader, _) = makeSUT(phraseToLoad: phraseToLoad)
await sut.onLoad()
await sut.onLoad()
XCTAssertEqual(loader.invocations, [
.loadSavedPhrase(phrase: phraseToLoad),
.loadSavedPhrase(phrase: phraseToLoad)
])
}
@MainActor
func testOnLoad_whenLoadedWithError_markIsFavoriteToFalse() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let anyNSError = NSError(domain: "any-error", code: 1)
let (sut, _, _, _) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .failure(anyNSError))
)
var receivedIsFavorite: Bool?
let exp = expectation(description: "wait for subscription")
let cancellable = sut.$isFavorite
.dropFirst()
.sink { isFavorite in
receivedIsFavorite = isFavorite
exp.fulfill()
}
XCTAssertEqual(sut.isFavorite, false)
// Act
await sut.onLoad()
await fulfillment(of: [exp], timeout: 0.1)
// Assert
XCTAssertEqual(receivedIsFavorite, false)
cancellable.cancel()
}
@MainActor
func testOnLoad_whenLoadedSuccessfully_markIsFavoriteToTrue() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let (sut, _, _, _) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .success(phraseToLoad))
)
var receivedIsFavorite: Bool?
let exp = expectation(description: "wait for subscription")
let cancellable = sut.$isFavorite
.dropFirst()
.sink { isFavorite in
receivedIsFavorite = isFavorite
exp.fulfill()
}
XCTAssertEqual(sut.isFavorite, false)
// Act
await sut.onLoad()
await fulfillment(of: [exp], timeout: 0.1)
// Assert
XCTAssertEqual(receivedIsFavorite, true)
cancellable.cancel()
}
@MainActor
func testOnTapFavorite_loadsIsFavorite() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let anyNSError = NSError(domain: "any-error", code: 1)
let (sut, _, loader, _) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .failure(anyNSError))
)
// Act
await sut.onTapFavorite()
// Assert
XCTAssertEqual(loader.invocations, [ .loadSavedPhrase(phrase: .niHao) ])
}
@MainActor
func testOnTapFavorite_whenAlreadyFavorite_requestDeletion() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let phraseToDelete = phraseToLoad
let (sut, saver, _, deleter) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .success(phraseToLoad))
)
await sut.onTapFavorite()
XCTAssertEqual(deleter.invocations, [ .delete(phrase: phraseToDelete) ])
XCTAssertEqual(saver.invocations, [])
}
@MainActor
func testOnTapFavorite_whenAlreadyFavorite_showsErrorWhenUnFavorite() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let anyNSError = NSError(domain: "any-error", code: 1)
let (sut, _, _, _) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .success(phraseToLoad)),
deleter: MockChinesePhraseDeleter(deletePhraseResult: .failure(anyNSError))
)
var receivedShouldShowErrorAlert: Bool?
let exp = expectation(description: "wait for subscription")
let cancellable = sut.$shouldShowErrorAlert
.dropFirst()
.sink { shouldShowErrorAlert in
receivedShouldShowErrorAlert = shouldShowErrorAlert
exp.fulfill()
}
// Act
await sut.onTapFavorite()
await fulfillment(of: [exp], timeout: 0.1)
// Assert
XCTAssertEqual(receivedShouldShowErrorAlert, true)
cancellable.cancel()
}
@MainActor
func testOnTapFavorite_whenAlreadyFavorite_refreshIsFavoriteAfterDeletion() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let phraseToDelete = phraseToLoad
let (sut, _, loader, deleter) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .success(phraseToLoad)),
deleter: MockChinesePhraseDeleter(deletePhraseResult: .success(()))
)
// Act
await sut.onTapFavorite()
// Assert
XCTAssertEqual(deleter.invocations, [ .delete(phrase: phraseToDelete) ])
XCTAssertEqual(loader.invocations, [
.loadSavedPhrase(phrase: phraseToLoad), // check is favorite
.loadSavedPhrase(phrase: phraseToLoad) // refreshIsFavorite
])
}
@MainActor
func testOnTapFavorite_whenIsNotAlreadyFavorite_requestSaveForFavorite() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let phraseToSave = phraseToLoad
let anyNSError = NSError(domain: "any-error", code: 1)
let (sut, saver, _, deleter) = makeSUT(
phraseToLoad: phraseToLoad,
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .failure(anyNSError))
)
await sut.onTapFavorite()
XCTAssertEqual(deleter.invocations, [])
XCTAssertEqual(saver.invocations, [ .save(phrase: phraseToSave) ])
}
@MainActor
func testOnTapFavorite_whenIsNotAlreadyFavorite_showsErrorWhenFavorite() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let phraseToFavorite = phraseToLoad
let anyNSError = NSError(domain: "some error", code: 1)
let (sut, saver, _, _) = makeSUT(
phraseToLoad: phraseToLoad,
saver: MockChinesePhraseSaver(savePhraseResult: .failure(anyNSError)),
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .failure(anyNSError))
)
var receivedShouldShowErrorAlert: Bool?
let exp = expectation(description: "wait for subscription")
let cancellable = sut.$shouldShowErrorAlert
.dropFirst()
.sink { shouldShowErrorAlert in
receivedShouldShowErrorAlert = shouldShowErrorAlert
exp.fulfill()
}
// Act
await sut.onTapFavorite()
await fulfillment(of: [exp], timeout: 0.1)
// Assert
XCTAssertEqual(saver.invocations, [ .save(phrase: phraseToFavorite) ])
XCTAssertEqual(receivedShouldShowErrorAlert, true)
cancellable.cancel()
}
@MainActor
func testOnTapFavorite_whenIsNotAlreadyFavorite_refreshIsFavoriteAfterSaving() async {
// Arrange
let phraseToLoad: PhraseEntity = .niHao
let phraseToFavorite = phraseToLoad
let anyNSError = NSError(domain: "not favorite yet", code: 1)
let (sut, saver, loader, _) = makeSUT(
phraseToLoad: phraseToLoad,
saver: MockChinesePhraseSaver(savePhraseResult: .success(phraseToFavorite)),
loader: MockChinesePhraseLoader(loadSavedPhraseResult: .failure(anyNSError))
)
// Act
await sut.onTapFavorite()
// Assert
XCTAssertEqual(saver.invocations, [ .save(phrase: phraseToFavorite) ])
XCTAssertEqual(loader.invocations, [
.loadSavedPhrase(phrase: phraseToLoad), // check is favorite
.loadSavedPhrase(phrase: phraseToLoad) // refreshIsFavorite
])
}
// MARK: – Helpers
@MainActor
private func makeSUT(
phraseToLoad: PhraseEntity = .niHao,
saver: MockChinesePhraseSaver = MockChinesePhraseSaver(),
loader: MockChinesePhraseLoader = MockChinesePhraseLoader(),
deleter: MockChinesePhraseDeleter = MockChinesePhraseDeleter(),
file: StaticString = #filePath,
line: UInt = #line
) -> (
sut: CellDetailViewViewModel,
saver: MockChinesePhraseSaver,
loader: MockChinesePhraseLoader,
deleter: MockChinesePhraseDeleter
) {
let sut = CellDetailViewViewModel(
phrase: phraseToLoad,
chinesePhraseSaver: saver,
chinesePhraseLoader: loader,
chinesePhraseDeleter: deleter
)
trackForMemoryLeak(for: sut, file: file, line: line)
trackForMemoryLeak(for: saver, file: file, line: line)
trackForMemoryLeak(for: loader, file: file, line: line)
trackForMemoryLeak(for: deleter, file: file, line: line)
return (sut, saver, loader, deleter)
}
}

🔖 Hashtags:

#iOSDevelopment #Swift #MVVM #XCTest #UnitTesting #CombineFramework #SwiftConcurrency #TDD #SwiftUnitTests #SpeedCoding #iOSEngineer #SoftwareTesting #CleanArchitecture #iOSDev #TestDrivenDevelopment #arifinfrds

Leave a comment