You pushed a feature on Friday night. It looked harmless. Maybe you changed a date formatter, swapped a persistence wrapper, or cleaned up a ViewModel that had grown ugly. Then Saturday morning brought the bug report: onboarding no longer saves state, a settings toggle resets itself, or a purchase flow breaks after a retry.
That's the moment most indie iOS developers start caring about unit testing in Swift.
Not because testing is academically correct. Because fixing regressions after release is miserable when you're the same person building features, answering support email, and trying to keep momentum. A good test suite gives you something much more valuable than ceremony. It gives you confidence to change code without wondering what else you just broke.
Table of Contents
- Why Unit Testing Is Your Indie Superpower
- Setting Up Your Swift Testing Environment
- Writing Your First Tests for Models and Logic
- Isolating Code with Mocks and Dependency Injection
- Testing Asynchronous Code and Combine Publishers
- A Pragmatic Approach to Testing SwiftUI Views
- Maintainable Tests and CI Integration
Why Unit Testing Is Your Indie Superpower
For a small team, every bug is expensive. There isn't a separate QA department catching regressions all day, and there usually isn't a second sprint reserved for cleanup. When one change breaks a different part of the app, the same developer who shipped the feature loses the weekend to debugging.
That's why I think of unit tests as insurance for momentum. They help you move fast twice. First when you write the feature, and again later when you refactor it. Without tests, every cleanup pass feels risky. With tests, renaming, extracting, and simplifying code stops feeling like gambling.

What tests actually buy you
A solid unit test suite does a few practical things for indie apps:
- It catches regressions early. You find out a pricing rule changed, a parser broke, or a login state no longer updates before App Store users tell you.
- It makes refactors realistic. Old code can stay messy for months because nobody wants to touch it. Tests lower that fear.
- It cuts support pain. If your app has subscriptions, onboarding, syncing, or local caching, small regressions hit real users fast.
- It protects business logic. UI can change every week. The logic behind entitlement checks, settings persistence, and feature flags needs to stay correct.
Practical rule: If a bug would hurt reviews, revenue, or trust, it deserves a unit test.
The biggest mindset shift is this. Tests are not a tax you pay after writing the app. They're part of how you keep shipping without turning your project into a pile of fragile code. For solo developers, that matters even more than it does on big teams, because nobody else is standing between your production app and a late-night mistake.
Setting Up Your Swift Testing Environment
Open an indie app after six months, fix one pricing bug, and three unrelated screens can break. A clean test setup keeps that kind of change contained. The goal here is simple: make it easy to add tests while you build, not after the app starts feeling fragile.
Start in Xcode with a dedicated Unit Test Target. Name it something obvious like YourAppTests. Keep the test files close enough to the production code that you can guess where a test lives without searching the project.
Add a test target first
For a small team, a boring structure beats a clever one.
- Model and logic tests should roughly mirror the code they cover.
- Mocks and test helpers should live in one shared folder inside the test target.
- Feature tests should stay grouped by screen, module, or service.
If part of the app lives in Swift Package Manager packages, put the tests in those packages. If everything is still in one app target, keep the folder structure light. Too much organization early on creates cleanup work later.
Choose XCTest or Swift Testing
Modern Swift gives you two real choices. XCTest is the long-running default in Apple projects. Swift Testing, introduced by Apple with Xcode 16, uses @Test and #expect() instead of XCTestCase subclasses and XCTAssert*, as described in Codecademy's overview of Swift testing models.
For indie developers, the practical question is not which framework looks newer. It is which one helps you keep shipping. Swift Testing works well for new code and reads cleaner in many cases. XCTest still has better familiarity, more examples in older codebases, and fewer surprises if your app already depends on it heavily.
A simple rule works well in real projects:
| Feature | XCTest | Swift Testing (@Test) |
Indie Dev Takeaway |
|---|---|---|---|
| Test style | Class-based with XCTestCase |
Macro-based with @Test |
Swift Testing feels lighter for new files |
| Assertions | XCTAssert* family |
#expect() |
Swift Testing is easier to scan quickly |
| Migration | Existing standard in many apps | Can live beside XCTest | Adopt it gradually |
| Boilerplate | More setup by default | Less inheritance-heavy setup | Helpful in small codebases |
| Legacy compatibility | Excellent | Still newer | Keep XCTest where it already works |
My recommendation:
- Keep XCTest in existing suites that already run reliably.
- Try Swift Testing in new areas, especially ViewModels and async code.
- Skip full migrations unless you have a concrete maintenance problem to solve.
That mixed approach is usually the right call for solo developers and small teams. You get newer syntax where it helps, without burning time rewriting tests that already do their job.
Use Arrange Act Assert from day one
Test structure matters more than framework choice in the first few weeks. Arrange-Act-Assert is still the best default because it keeps failures readable and helps you spot design problems early, as explained in Joe Masilotti's Swift unit testing guide.
- Arrange the data, dependencies, and object under test.
- Act by calling one behavior.
- Assert the result you care about.
Keep it tight.
One setup. One action. One clear expectation.
Tests usually get messy for predictable reasons. The setup is doing too much. The object under test has too many dependencies. Or the test is trying to verify several behaviors at once. Arrange-Act-Assert will not fix bad design by itself, but it exposes bad design fast, which is exactly what you want before the app gets bigger.
If you are planning to test async/await, Combine, or SwiftUI ViewModels later, this foundation matters. Those areas already add enough moving parts. A clean target structure and consistent test shape save time every single week.
Writing Your First Tests for Models and Logic
A good first unit test usually comes from a bug you do not want to ship twice. A settings flag flips the wrong way. A paywall rule grants access too early. A DTO maps into the wrong display state. That is the level to start at for indie apps and small teams, because these tests run fast and protect logic that undermines revenue, onboarding, and retention.
Start with pure logic
Take a simple settings model:
struct UserSettings {
var isOnboardingComplete = false
var selectedTheme = "system"
mutating func completeOnboarding() {
isOnboardingComplete = true
}
}
An XCTest version of a first test might look like this:
import XCTest
@testable import MyApp
final class UserSettingsTests: XCTestCase {
func test_completeOnboarding_setsFlagToTrue() {
// Arrange
var settings = UserSettings()
// Act
settings.completeOnboarding()
// Assert
XCTAssertTrue(settings.isOnboardingComplete)
}
}
That test pulls its weight. It checks behavior instead of peeking into private state, and it keeps paying off if the implementation later moves into a reducer, service, or ViewModel.
The same test in Swift Testing is even lighter:
import Testing
@testable import MyApp
struct UserSettingsTests {
@Test
func completeOnboarding_setsFlagToTrue() {
var settings = UserSettings()
settings.completeOnboarding()
#expect(settings.isOnboardingComplete == true)
}
}
For small teams, the practical takeaway is simple. Use XCTest where it already fits your project and CI. Try Swift Testing in new test files if you want cleaner syntax, especially in modern Swift code. The test design matters more than the framework at this stage.
Why Equatable cleans up your tests
Model tests get noisy fast when every assertion checks one field at a time. Conforming value types to Equatable usually fixes that.
Without Equatable, you end up doing this:
XCTAssertEqual(result.name, "Dark")
XCTAssertEqual(result.isEnabled, true)
XCTAssertEqual(result.source, "user")
With Equatable, you can compare the whole value:
struct Preference: Equatable {
let name: String
let isEnabled: Bool
let source: String
}
XCTAssertEqual(result, Preference(name: "Dark", isEnabled: true, source: "user"))
That reads closer to the business rule you care about. It also makes refactors safer, because the expected output stays obvious.
This style matters even more once your app grows into injected services and test doubles. If you need a refresher on that setup, this guide to dependency injection in Swift for testable app code is a good companion.
A few good starting targets for model tests are:
- Validation rules like email checks, password rules, or paywall state gating.
- Mapping logic such as converting DTOs into app models.
- Derived state like button enabled states, empty states, or settings summaries.
- Reducer-style logic where one input should produce one predictable state change.
If your first tests touch
URLSession, SwiftUI rendering, or a real database, you are probably starting too high in the stack.
Isolating Code with Mocks and Dependency Injection
Most indie apps hit a wall when logic starts talking to the outside world. The classic example is a ViewModel that fetches data directly with URLSession. It works fine in the app. It's awful in tests.
The untestable version
Here's the version that causes pain:
final class ProfileViewModel: ObservableObject {
@Published private(set) var username = ""
func loadProfile() async throws {
let url = URL(string: "https://example.com/profile")!
let (data, _) = try await URLSession.shared.data(from: url)
username = try JSONDecoder().decode(Profile.self, from: data).name
}
}
This code has a few problems for testing:
- It depends on the network.
- It hides the dependency inside the method.
- It gives you no clean way to force success or failure cases.
That's how flaky tests start. The bug isn't always your logic. Sometimes the test setup is just coupled to too much real infrastructure.

The refactor that makes testing possible
Dependency injection sounds bigger than it is. In practice, it usually means passing a dependency into an initializer instead of creating it internally.
protocol ProfileService {
func fetchProfile() async throws -> Profile
}
final class LiveProfileService: ProfileService {
func fetchProfile() async throws -> Profile {
let url = URL(string: "https://example.com/profile")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Profile.self, from: data)
}
}
final class ProfileViewModel: ObservableObject {
@Published private(set) var username = ""
private let service: ProfileService
init(service: ProfileService) {
self.service = service
}
func loadProfile() async throws {
let profile = try await service.fetchProfile()
username = profile.name
}
}
If you want a more practical walkthrough of these patterns in app architecture, this guide on dependency injection in Swift is worth keeping nearby.
A mock that earns its keep
Now the test can control behavior:
struct Profile: Equatable, Decodable {
let name: String
}
final class MockProfileService: ProfileService {
var result: Result<Profile, Error> = .success(Profile(name: "Test User"))
func fetchProfile() async throws -> Profile {
try result.get()
}
}
And the test becomes straightforward:
final class ProfileViewModelTests: XCTestCase {
func test_loadProfile_updatesUsernameOnSuccess() async throws {
let mock = MockProfileService()
mock.result = .success(Profile(name: "Taylor"))
let viewModel = ProfileViewModel(service: mock)
try await viewModel.loadProfile()
XCTAssertEqual(viewModel.username, "Taylor")
}
}
That's the point of mocks for small teams. Not to impress anyone with test theory. Just to make logic deterministic.
A few trade-offs matter here:
- Protocols help most when the dependency has side effects. Network, storage, analytics, auth.
- Don't mock everything. Pure value logic usually doesn't need it.
- Prefer simple mocks over giant fake frameworks. If your test double has more logic than the production code, you've gone too far.
Testing Asynchronous Code and Combine Publishers
Async code is where many Swift test suites start looking healthy on paper and flaky in reality. A lot of tutorials stop at XCTestExpectation, then leave you to figure out async/await, cancellation, actor isolation, and Combine by trial and error.
Why basic expectations stop being enough
There's a real gap here. Coverage of asynchronous testing in Swift often goes no further than basic expectations, while many teams now mix callback-based code with structured concurrency. Guidance also points out that flaky tests are often caused less by the API boundary and more by nondeterminism from task scheduling, timers, and shared mutable state, as discussed in Bitrise's writeup on Swift testing gaps.
That matches what I see in actual projects. Developers blame the network first. The scheduler is usually guiltier.
The test that “sometimes fails on CI” usually isn't unlucky. It usually depends on timing you don't control.
A better pattern for async await
For modern async functions, the cleanest tests are usually direct async tests with controlled dependencies. Keep test data fixed. Avoid random values and hidden background work. Call one async behavior, then assert one observable outcome.
final class AuthViewModel {
private let service: AuthService
private(set) var state: State = .idle
enum State: Equatable {
case idle
case loading
case success
case failure
}
init(service: AuthService) {
self.service = service
}
func login() async {
state = .loading
do {
_ = try await service.login()
state = .success
} catch {
state = .failure
}
}
}
Test it like this:
func test_login_setsSuccessStateWhenServiceSucceeds() async {
let service = MockAuthService(result: .success(()))
let sut = AuthViewModel(service: service)
await sut.login()
XCTAssertEqual(sut.state, .success)
}
What doesn't work well:
- sleeping the test and hoping state settles
- using random delays to mimic “real life”
- asserting intermediate states that race past too quickly
- sharing mutable mock state across tests
If you must test callback-based legacy code, XCTestExpectation is still fine. Just don't build your whole async strategy around waiting and timeouts when direct async tests can express the behavior more clearly.
Testing Combine without guesswork
Combine tests get unstable when you subscribe too late, ignore cancellation, or rely on timing instead of controlling input. The easiest pattern is to test publisher-driven ViewModels by injecting a publisher source you control.
final class SearchViewModel: ObservableObject {
@Published private(set) var results: [String] = []
private var cancellables = Set<AnyCancellable>()
init(service: SearchService) {
service.resultsPublisher
.receive(on: DispatchQueue.main)
.assign(to: &$results)
}
}
A test can feed values through a subject:
final class MockSearchService: SearchService {
let subject = PassthroughSubject<[String], Never>()
var resultsPublisher: AnyPublisher<[String], Never> {
subject.eraseToAnyPublisher()
}
}
func test_resultsPublisher_updatesResults() {
let service = MockSearchService()
let sut = SearchViewModel(service: service)
service.subject.send(["One", "Two"])
XCTAssertEqual(sut.results, ["One", "Two"])
}
If delivery depends on main-thread scheduling, keep that explicit in the design so the test can account for it. The key is still isolation. Control the publisher. Control the input. Don't rely on ambient app state.
A Pragmatic Approach to Testing SwiftUI Views
Trying to unit test SwiftUI layout directly is usually the wrong fight. View code changes often. Labels move, stacks nest differently, modifiers get reordered. If your tests are tied to visual composition, they'll break every time you do normal UI cleanup.

Test state not pixels
A better strategy is to make the SwiftUI view thin and push meaningful behavior into an ObservableObject or similar state holder. Then test the state transitions and actions that drive the screen.
That approach gets even nicer with newer tooling because Swift Testing supports parameterized tests, traits, and tags while reducing boilerplate with @Test and #expect(), as outlined in this modern Swift unit testing overview. For ViewModel-heavy apps, parameterized tests are especially useful when the same action should behave consistently across several state inputs.
You can also sharpen your mental model of SwiftUI architecture with this primer on getting started with SwiftUI.
Here's the practical split:
- Test the ViewModel when a button tap changes loading state, filters data, triggers an async action, or computes display-ready text.
- Use previews and manual checks for layout and visual polish.
- Reserve UI tests for a small number of high-value user flows.
This short video gives a useful visual complement before you go deeper into your own architecture.
A ViewModel test that matters
Suppose your screen has a button that loads products and toggles loading state. Don't test whether the ProgressView exists in some exact hierarchy. Test the state that determines whether it should appear.
final class ProductsViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var products: [String] = []
private let service: ProductService
init(service: ProductService) {
self.service = service
}
func load() async {
isLoading = true
defer { isLoading = false }
products = (try? await service.fetchProducts()) ?? []
}
}
And the test:
func test_load_updatesProductsAndClearsLoading() async {
let service = MockProductService(products: ["Pro", "Plus"])
let sut = ProductsViewModel(service: service)
await sut.load()
XCTAssertEqual(sut.products, ["Pro", "Plus"])
XCTAssertFalse(sut.isLoading)
}
Your SwiftUI view should mostly reflect state. If testing the screen feels hard, the ViewModel often isn't carrying enough responsibility.
That gives you fast, stable confidence without coupling tests to view tree trivia.
Maintainable Tests and CI Integration
Writing a few tests is easy. Keeping them useful six months later is the true skill. Test suites become dead weight when they're slow, brittle, or so tied to implementation details that everyone ignores failures.
Rules that keep tests useful
The most practical workflow is still Arrange-Act-Assert with one behavior and one focused verification. Guidance for Swift unit tests also recommends fixed test data, deterministic scope, and avoiding changes that weaken encapsulation just to make private code testable, as explained in Vadim Bulavin's iOS testing best practices.

The rules I'd keep on a small team are:
- Name tests by behavior. A failing test name should tell you what broke without opening the file.
- Keep data boring. Fixed values beat random fixtures because failures are reproducible.
- Test public behavior. Don't pry open private internals just to satisfy a test.
- Delete weak tests. A noisy test that fails for setup reasons teaches the team to ignore red builds.
If you're thinking beyond unit coverage, this article on integration testing best practices is a useful complement because some bugs belong above the unit level.
Run tests automatically
CI matters because local discipline fades under deadlines. If tests only run when someone remembers, they won't protect release quality consistently.
A small GitHub Actions setup is enough for many iOS projects:
name: iOS Tests
on:
push:
pull_request:
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'
Code coverage can help, but don't treat it like the goal. It's a flashlight, not a scoreboard. If coverage highlights an untested payments rule, persistence edge case, or state transition, that's useful. If it pushes you to test trivial getters, it's wasting your time.
A healthy Swift test suite should feel like a fast feedback loop, not a compliance burden.
If you're shipping SwiftUI apps as a solo developer or tiny team, Spaceport is worth a look. It helps you generate production-ready SwiftUI projects with modern architecture, prewired app essentials, and a setup that's much friendlier to reliable testing than stitching everything together from scratch.
