🎯 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