От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
Mykhailo Bontar
/
iOS Developer
12 min read
We’d just wrapped a project. The next step was obvious — and harder than it looked. A working SwiftUI app, zero test conventions, an AI that produced five thousand lines of inconsistent garbage on the first try, and an iOS community where unit testing barely gets discussed. Search for iOS unit testing best practices in 2026, and the picture is thin. You’ll find XCTest-era tutorials. A few scattered Medium posts. Almost nothing pulls Swift Testing together with Sourcery and a real DI setup into one coherent rulebook. This article is that rulebook.
Article content:
Step 0 — The first instinct (and why it failed)
Step 2 — A best-practices anchor
Step 3 — The mock taxonomy I was missing
Step 4 — Sourcery: I never wrote another mock
Step 5 — The TestFactory pattern
Step 6 — Where AI helps, where it doesn’t
We’d just shipped the last big feature on the app. A stack of SwiftUI ViewModels, a DI container (NerdzInject — our internal injection module, published as an open-source Swift package), Combine publishers, async/await repositories — a real codebase that worked. Now came the part you can’t ship without: tests.
I opened the test target, created an empty file, and stared at it for about twenty minutes.
This is the moment, I think, that 90% of iOS developers recognize. The cursor blinks. You have a working app with zero existing test conventions. The team has opinions but no rulebook. Every previous attempt — half-finished XCTestCase skeletons in old branches, abandoned mocks — tells you the same story: somebody tried, somebody gave up.
Where do you even start?
I went looking for guidance online first. Here’s what I found: the iOS testing corner of the internet is surprisingly thin. Backend ecosystems argue about testing philosophy in book-length detail; the iOS community mostly shrugs and moves on. Most tutorials still assume XCTest, treat mocks as hand-rolled boilerplate, and skip the harder questions entirely — what kind of test double to use, how to wire DI into your test target, how to scale to dozens of files without each one being a snowflake.
Of course, I asked the AI first. I’m not a rookie.
It generated something. It compiled. It even passed. I felt brief, intoxicating optimism.
Then I tried to extend it.
The second test required a different mock shape than the first. The third test used XCTestCase while the first two had used @Suite. The mock data was wrong — invented enum cases that didn’t exist (.beginner when the real one was .elementary), fields that weren’t on the model, optionals where there weren’t any. Every prompt I sent returned a different “best practice.” The pattern was: my AI was producing plausible-looking tests that had nothing in common with each other.
THE THESIS
AI is genuinely useful for tests — I’ll come back to where it shines — but it doesn’t know your stack, doesn’t know your DI container, doesn’t know your mocking convention because you don’t have one yet. And it absolutely will not invent that convention for you.
If you walk in with no opinion, you walk out with a giant pile of incoherent tests — different SDKs in different files, different mock conventions in different suites, and not a single shared rule between them.
I needed a rulebook. So I went looking.
The first rabbit hole was the SDK itself. Apple has two SDKs here. XCTest is the default in every iOS testing tutorial older than 2024. Swift Testing (import Testing) shipped with Xcode 16 at WWDC 2024 and is now the recommended path. Putting them side by side, the gap is wide enough that the choice makes itself.
| Old (XCTest) | New (Swift Testing) |
| class Foo: XCTestCase | @Suite struct Foo |
| setUp() / tearDown() | init() / deinit() |
| func testFoo() (prefix matters) | @Test(“Foo”) func foo() |
| XCTAssertEqual(a, b) | #expect(a == b) |
| no parametrization | .withArguments(…) |
| no tags | .tags(.smoke) |
| no native serialization | .serialized |
The same test, written both ways, makes the difference obvious.
import XCTest
@testable import App
final class LibraryListTests: XCTestCase {
var sut: LibraryListViewModel!
override func setUp() {
super.setUp()
sut = LibraryListViewModel()
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testInitialFilterIsAll() {
XCTAssertEqual(sut.selectedCategory, .all)
}
}
import Testing
@testable import App
@Suite("LibraryListViewModel — initialization")
@MainActor
struct LibraryListInitTests {
@Test("Initial filter is .all")
func initialFilterIsAll() {
let sut = LibraryListViewModel()
#expect(sut.selectedCategory == .all)
}
}
Less ceremony, sharper failure messages, async/await native, and the @Suite string lets you group tests by intent — Initialization, LibraryLoading, Search — instead of by alphabetised method names. Our project runs iOS 18.2 on Xcode 16.2. Nothing was holding us to the old framework.
Decision: every new test file uses Swift Testing. No backporting old XCTest classes — I just leave them where they are. New code, new framework.
Quick caveat: if your team is stuck on Xcode 15 or older, table flip and stay on XCTest until you can upgrade. Everything else in this article still applies.
Choosing the SDK gave me syntax. I still didn’t have principles.
What I needed was something that wasn’t tied to a specific tutorial — something durable, vendor-neutral, the kind of write-up that would still apply when SwiftUI 8 or whatever ships in three years. The best I found is Marcin Borek’s Unit Testing Best Practices in Swift. Bookmark it. Seriously.
I read it on a Saturday. The bits that immediately changed how I wrote my next test file:
That last bullet was the breakthrough — and it’s what kicked off the next rabbit hole.
Confession time. I’d been calling everything “a mock.”
The repo I swap in for tests? Mock. What returns fake data? Mock. The thing that records calls? Also mock. Everything mock.
That’s wrong, and the wrongness has consequences. Different test doubles are good at different things, and conflating them produces tests that are simultaneously over-engineered and under-asserting — verifying interactions that don’t matter, and missing the ones that do.
The article that fixed this for me is Matías Glessi’s Mock, Stub, Spy and other Test Doubles. Short, Swift-flavored, ten minutes well spent. Here’s the cheat sheet I built from it:
| Type | What it does | When to reach for it |
| Dummy | Fills a parameter slot. Crashes if used. | “Just need this constructor argument satisfied.” |
| Stub | Returns predefined values, no tracking. | “When getUsers() is called, return three.” |
| Spy | Stub + records call metadata you assert on. | “Did sendEmail get called? With what args?” |
| Mock | Spy + verifies expectations itself. | “Fail if sendEmail wasn’t called once.” |
| Fake | Real-but-simplified working implementation. | “In-memory database instead of real Core Data.” |
The deeper distinction underneath them is state vs behavior verification. Stubs, Fakes, and Spies do state verification — you let the code run, then assert on the resulting state (“the array has 3 users”). Mocks do behavior verification — you assert on the interactions themselves (“the repo’s getUsers was called once with searchText == “hello”“).
Martin Fowler’s quick-pick guide (Mocks Aren’t Stubs), which I literally taped to my monitor:
| Situation | Use this |
| Just need to fill a parameter | Dummy |
| Control inputs / responses | Stub |
| Verify something was called | Spy or Mock |
| Need working logic (simplified) | Fake |
| Verify complex interactions | Mock |
When I lined our codebase up against that table, the answer was clear: we’re a Mock shop. Every ViewModel has injected dependencies, and what we mostly care about is did the ViewModel ask the right thing. That’s behavior verification. That’s Mocks.
Which raised the next obvious question. Am I really going to hand-write a Mock class for every protocol? With call-tracking properties, last-argument tuples, return-value stubs, and per-call closure hooks?
No. Absolutely not.
The tool that changed everything is Sourcery.
I’d vaguely heard of it before — seen it mentioned in a Better Programming write-up — but never had a reason to try it. This time I did.
Sourcery is a Swift code generator. It scans your source, finds annotations, runs them through Stencil templates, and writes Swift files. One of its built-in templates is AutoMockable. Annotate a protocol with // sourcery: AutoMockable, run Sourcery, and you get a fully working mock conforming to that protocol — call counts, return values, argument captures, the lot. For free. Re-runnable on demand.
brew install sourcery
The trigger file in our project is 33 lines. Eight protocols, one annotation each, and I never write another mock:
@testable import App
// sourcery: AutoMockable
extension AppPlayerType { }
// sourcery: AutoMockable
extension UserRepositoryType { }
// sourcery: AutoMockable
extension LanguagesRepositoryType { }
// sourcery: AutoMockable
extension LibraryRepositoryType { }
// sourcery: AutoMockable
extension ListeningRepositoryType { }
// …8 protocols total
sourcery --sources App \
--templates Templates/AutoMockable.stencil \
--output AppTests/Mocks/
Result: one AutoMockable.generated.swift file. 1,745 lines. Eight *Mock classes, every protocol method instrumented. Regenerated on demand — protocol changes, regen, move on.
Here’s what Sourcery emits, by the numbers. I counted the suffixes in our actual generated file:
| Suffix | Count | What does it give you |
| *CallsCount | 231 | How many times the method was invoked — the heart of behavior verification |
| *Closure | 222 | Per-call side-effect hook — inject custom logic, e.g., fail on the second call |
| *ReceivedInvocations | 102 | Full history of every call’s arguments, in order |
| *Called | 77 | Convenience boolean — callsCount > 0 |
| *ReturnValue | 68 | What the stub returns when called |
| *ReceivedArguments | 32 | Last call’s argument tuple |
Concretely, when I want to assert “the repo’s getLibraryList method was called when the ViewModel initialised”, I write:
#expect(deps.libraryRepo.getLibraryList…CallsCount >= 1)
When I want to assert “and the search query was passed correctly”:
let lastArgs = deps.libraryRepo.getLibraryList…ReceivedArguments
#expect(lastArgs?.searchText == "hello")
That’s it. No hand-rolled mock. No AI hallucination. Type-safe, deterministic, regenerable.
The honest trade-off: the property names get monstrous. Sourcery generates them by mangling the full method signature into a unique identifier. The real *CallsCount for our paginated library fetch is 122 characters long:
getLibraryListLanguageStringTranslateLanguageStringPageSizeInt
OnlyPhrasesBoolOnlyWordsBoolNewListBoolLevelListItemLevelType
SearchTextStringPaginatedDataModelLibraryListItemModelCallsCount
Ugly. But it’s the cost of having Sourcery resolve the full type signature into something guaranteed-unique. Workarounds: typealias it inside the test file, or trim the underlying protocol method names. There’s no third option, and frankly, I’d take ugly names over hand-written mocks every day of the week.
I had everything now — SDK chosen, principles in hand, test doubles understood, mocks generated. What I still didn’t have was a shape for every test file — something repeatable enough that the seventh test file would feel exactly like the first.
Borek’s makeSUT() advice plus eight Sourcery-generated mocks per ViewModel pointed at one answer: a static factory enum, one per ViewModel, that does three things and only three things.
enum LibraryListTestFactory {
struct Dependencies {
let userRepo: UserRepositoryTypeMock
let libraryRepo: LibraryRepositoryTypeMock
let languagesRepo: LanguagesRepositoryTypeMock
let listeningRepo: ListeningRepositoryTypeMock
let connectivityManager: ConnectivityManagerTypeMock
}
@MainActor
static func setupDependencies() -> Dependencies {
let userRepo = UserRepositoryTypeMock()
let libraryRepo = LibraryRepositoryTypeMock()
let languagesRepo = LanguagesRepositoryTypeMock()
let listeningRepo = ListeningRepositoryTypeMock()
let connectivityManager = ConnectivityManagerTypeMock()
// 2. Sensible defaults so init() doesn't crash
userRepo.underlyingIsUserSignedInStatusChangedPublisher
= Empty().eraseToAnyPublisher()
languagesRepo.underlyingUpdateSelectedLanguagesPublisher
= Empty().eraseToAnyPublisher()
libraryRepo.getLibraryList…ReturnValue
= PaginatedDataModel(empty: ())
// 3. Register with the DI container
NerdzInject.shared.registerObject
(userRepo, for: UserRepositoryType.self)
NerdzInject.shared.registerObject
(libraryRepo, for: LibraryRepositoryType.self)
NerdzInject.shared.registerObject
(languagesRepo, for: LanguagesRepositoryType.self)
NerdzInject.shared.registerObject
(listeningRepo, for: ListeningRepositoryType.self)
NerdzInject.shared.registerObject
(connectivityManager, for: ConnectivityManagerType.self)
return Dependencies(
userRepo: userRepo,
libraryRepo: libraryRepo,
languagesRepo: languagesRepo,
listeningRepo: listeningRepo,
connectivityManager: connectivityManager
)
}
}
The factory’s three jobs:
The factory also owns mock data builders (createMockLibraryItems(), createMockLibraryItemViewModel()), so test files never inline 30-line model initialisers. Mock data lives in one place; tests stay short.
And a test file? It becomes a three-line poem:
@Test("ViewModel loads library on init when online")
func testLoadsLibraryOnInit() async throws {
// Arrange
let deps = LibraryListTestFactory.setupDependencies()
deps.languagesRepo.learningLanguage = LanguageModel(code: "es")
deps.languagesRepo.translateLanguage = LanguageModel(code: "en")
// Act
let sut = LibraryListViewModel()
try await Task.sleep(nanoseconds: 300_000_000)
// Assert
#expect(deps.libraryRepo.getLibraryList…CallsCount >= 1)
}
That’s the whole shape. Arrange-Act-Assert from the principles. Behavior verification through Sourcery’s CallsCount and ReceivedArguments. Setup work delegated to the factory. The seventh test file in our codebase looks structurally identical to the first, and that’s the point — predictability is what lets the AI fill in the rest later without breaking your conventions.

Now that I had the rules, I could finally let the AI help — without it making things worse.
The rule I follow now: AI for the scaffolding around the test. Sourcery for the mocks inside the test. Me for the assertions that matter.
Once the conventions existed, I let the AI loose again.
I dropped three things into context: the Sourcery output, the TestFactory pattern, and one example test file. Then I asked it to generate suites for the remaining ViewModels.
The result: ~5,000 lines of test code across six ViewModels, generated in one afternoon.
Not perfect. Every file needed a manual pass. Every block of mock data needed a sanity check. A few tests needed Task.sleep durations bumped to deflake on CI. But the shape was right because the conventions were right. The AI was no longer making it up — it was filling in a template.
I packaged that template as a Claude Code skill: a Markdown file containing the TestFactory shape, the Sourcery convention, the AAA template, and the checklist below. Now, when I add a new ViewModel, “write tests for this” produces a draft on rails. I review, tweak the mock data, and merge. This is the same pattern our team now uses across our entire development workflow — we wrote up the full playbook in How We Use AI Across Our Entire Development Workflow on the NERDZ LAB blog.
This is, I think, the lesson buried in the whole journey. AI is a power multiplier on top of human conventions. Skip the conventions, and it’s a chaos multiplier instead.
Paste this into your team wiki. Every ViewModel test file in our codebase has to satisfy all ten:
Run the suite once before merging. If a test relies on Task.sleep and your CI is slow, bump the duration before flaking the build.
If a colleague had handed me this article on day one, I’d have saved a weekend.
So, in order: pick the new SDK. Anchor on Borek’s principles. Learn the test-double taxonomy. Generate your mocks with Sourcery. Wrap it all in a TestFactory. And only then let the AI help.
The thing tests were supposed to give us was confidence — the freedom to refactor without praying. With this rulebook in place, six ViewModels covered, ~5,000 lines of test code generated and reviewed, we finally have it.
The next ViewModel goes in next week. This time it’ll take less than 1 hour.
The four articles that shaped this rulebook, in roughly the order I read them:
If you only read one, make it Fowler. The vocabulary alone changes how you write tests.
Got this far? Open a PR with one Swift Testing suite for the ViewModel that’s been bugging you most. The rules above will carry the rest.
Building something in iOS and wrestling with the same questions? This rulebook is part of how we work at NERDZ LAB — a product studio that’s shipped 50+ iOS apps, including the one this article is based on. If you want to see the kind of mobile work that comes out of conventions like these, browse our mobile case studies or look at our mobile development services.