От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!

cookies

Hi! This website uses cookies. By continuing to browse or by clicking “I agree”, you accept this use. For more information, please see our Privacy Policy

bg

Unit Testing iOS in 2026: How We Built a Rulebook with Swift Testing + Sourcery

author

Mykhailo Bontar

/

iOS Developer

12 min read

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 1 — Old SDK or new?

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

Step 7 — Generated by AI, but on rails

The checklist

Resources I recommend

nerdzlab

 

It was supposed to be easy

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.

ai fine tuning

Step 0 — The first instinct (and why it failed)

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.

Step 1 — Old SDK or new?

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.

THE OLD WAY

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)
    }
}

THE NEW WAY

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.

Step 2 — A best-practices anchor

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:

  • FIRST principles — Fast, Isolated, Repeatable, Self-validating, Timely. A test that takes seconds to run is too slow. A test that talks to disk isn’t isolated. A test that leaves the state behind isn’t repeatable. A test I have to babysit isn’t self-validating. Any of those, and the test is broken.
  • Test public methods only. Private methods are an implementation detail. Don’t expose them just to test them — exercise them through the public surface.
  • One Act, one Assert. When a test fails, it should be obvious which line failed. Multi-act tests are debugging traps you’ll regret in six months.
  • Naming: test_method_when_X_should_Y. Boring, predictable, scannable. In Swift Testing, you put this in the @Test(“…”) string, which is even nicer.
  • AAA / Given-When-Then. Three comment blocks, in that order. Every test. No exceptions.
  • A makeSUT() factory instead of a fat setUp(). Let each test pick the configuration it needs without polluting siblings.

That last bullet was the breakthrough — and it’s what kicked off the next rabbit hole.

Step 3 — The mock taxonomy I was missing

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.

Step 4 — Sourcery: I never wrote another mock

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.

INSTALL

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

RUN IT

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.

 

Step 5 — The TestFactory pattern

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:

  1. Instantiate the Sourcery-generated *Mock types.
  2. Configure defaults — empty Combine publishers, neutral *ReturnValues — so the SUT’s init() doesn’t blow up.
  3. Register them with the DI container.

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.

AI for Startups: Balancing innovation with trust & security in 2025

Step 6 — Where AI helps, where it doesn’t

Now that I had the rules, I could finally let the AI help — without it making things worse.

WHERE AI SHINES

  • Generating the empty TestFactory.swift shell once you give it a list of protocols
  • Producing @Test skeletons in AAA shape against a ViewModel you paste in
  • Filling out long model initialisers once you provide the type signature
  • Suggesting sensible nested @Suite groupings (Initialization, Search, Pagination)

WHERE AI FLATLINES

  • Realistic mock data. It hallucinates enum cases that don’t exist, invents fields, and mismatches optional vs non-optional. Every model initialiser needs review.
  • Stack-specific DI. It doesn’t know if you’re on NerdzInject vs Resolver vs Factory vs Swinject. It’ll write let mock = Mock() and shrug.
  • Choosing the right test double. It defaults to “Mock” for everything, generating verifications you don’t need and missing the ones you do.
  • Replacing Sourcery. Asked nicely, it will happily write you a 50-line hand-rolled mock that Sourcery generates for free in 0.2 seconds.

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.

Step 7 — Generated by AI, but on rails

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.

The checklist

Paste this into your team wiki. Every ViewModel test file in our codebase has to satisfy all ten:

  1. Lives in AppTests/<Module>/.
  2. Has a <Module>TestFactory.swift next to it.
  3. Uses import Testing (Swift Testing), not XCTest.
  4. Suite is @Suite(“Name”, .serialized) @MainActor if it touches the global DI container.
  5. Mocks are Sourcery-generated (*Mock from AutoMockable.generated.swift).
  6. The factory’s setupDependencies() registers *Mock types — never production types.
  7. Each @Test follows AAA / Arrange-Act-Assert with // MARK:-style comments.
  8. Async-init waits use Task.sleep(nanoseconds:) — TODO, replace with a publisher hook later.
  9. Asserts on Sourcery’s behavior-verification properties (*CallsCount, *ReceivedArguments).
  10. Mock data lives in the factory or a +MockInit.swift extension — never inline.

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.

What I’d tell myself a month ago

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.

Resources I recommend

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.

 

nerdzlab

 

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.

Summarize with AI