All articles
iOS12 min read

Unit Testing in Swift Made Easy: A Beginner’s Guide With Real Examples

A beginner’s guide to Swift unit testing — learn the basics, write your first tests, and improve your code quality.

K
Karan Pal
Author
Image generated by AI
Image generated by AI

✍️ Introduction

Unit testing in Swift sounds serious… until you realize it’s just code that checks your code ✅

If you’ve ever deployed an app, crossed your fingers, and said “Please don’t crash,” this one’s for you. Writing unit tests might feel like a chore at first — but once you get the hang of it, they become your personal bug bouncers. No crash? No panic? No problem.

In this easy-to-follow guide, we’ll break down unit testing in Swift using real-world examples, minus the jargon and overwhelm. Whether you’re building your first feature or trying to cover an entire legacy mess (no judgment 😬), this article will help you get started confidently.

Let’s write tests that future-you will actually appreciate — no time machines needed 🧪🚀

🧪 Why Unit Testing Matters (Yes, Even for Small Projects)

If you’ve ever tried fixing a bug and ended up introducing three new ones, you’re not alone. It’s like playing Whac-A-Mole with your code 🐹🔨

Unit testing helps stop that chaos before it starts. Instead of waiting for a QA team (or worse, your users) to discover what’s broken, you let your tests do the catching for you. Every time you change something, your tests give you instant feedback — no guesswork, no crossed fingers, no late-night debugging sessions (hopefully).

But it’s not just about avoiding bugs. Unit tests make your code:

And let’s not forget the big win: writing tests actually makes you a better developer. When your functions are testable, your architecture often gets cleaner — which leads to fewer tangled dependencies and mysterious side effects.

👉 If you want to design your code so it’s easier to test from the get-go, _understanding SOLID principles in Swift_ is a great next step.

**SOLID Principles in Swift Made Easy (With Real-Life Examples)** _A beginner-friendly guide to SOLID principles in Swift — packed with code examples and real-world use cases🤓🔥_medium.comhttps://medium.com/swift-pal/solid-principles-in-swift-made-easy-with-real-life-examples-9af053523e82

Ready to meet XCTest, your testing sidekick? 🦸‍♂️

🧰 What Is XCTest and How Does It Work?

Before you start writing tests, you need to meet your new BFF in the Swift world: XCTest.

Think of XCTest as the toolkit Apple gives you to test your Swift code. It’s built right into Xcode and lets you:

At its core, XCTest is just a bunch of functions that look like:

XCTAssertEqual(actualValue, expectedValue)

That’s it! You’re telling Swift: “Yo, I expect this value to be 5. If it’s not, scream at me.”

⚙️ A Quick Look at the XCTest Lifecycle

Here’s what a basic test class looks like:

import XCTest

final class MathUtilsTests: XCTestCase {
    
    override func setUp() {
        super.setUp()
        // Called before each test method — great for setup
    }

    override func tearDown() {
        super.tearDown()
        // Clean up after each test method
    }

    func testAddition() {
        let result = MathUtils.add(2, 3)
        XCTAssertEqual(result, 5)
    }
}

Let’s break it down:

And just like that, you’ve written your first test.

Ready to write a real one together? Let’s go 🚀

🧑‍💻 Writing Your First Unit Test (Step-by-Step)

Alright, you’ve met XCTest — now let’s put it to work. We’ll start with something simple and relatable: testing a tiny function that adds two numbers.

🧠 Step 1: Create a Simple Function to Test

Let’s say you have a Swift file like this in your main app target:

// MathUtils.swift
struct MathUtils {
    static func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
}

Looks innocent enough, right? Now let’s test it.

🧪 Step 2: Add a Test Target (If You Don’t Have One)

If your project doesn’t already have a test target, here’s how to add one:

  1. Go to your Xcode project settings.
  2. Click the + under Targets, then choose Unit Testing Bundle.
  3. Name it something like YourAppTests and make sure it’s linked to your main app target.

Now you should see a YourAppTests folder with a YourAppTests.swift file in it.

🧷 Step 3: Write Your First Test Case

Inside that test file, import your module and write your test:

import XCTest
@testable import YourApp

final class MathUtilsTests: XCTestCase {
    
    func testAdd_twoPositiveNumbers_returnsCorrectSum() {
        // Arrange
        let a = 2
        let b = 3

        // Act
        let result = MathUtils.add(a, b)

        // Assert
        XCTAssertEqual(result, 5)
    }
}

Let’s break it down, quick and clean:

Run the test with ⌘U, and boom — you’re officially testing in Swift 🥳

Want to test something more real-world next? Like a ViewModel or a network response? Let’s go there next 👇

🔍 Testing Real Code: ViewModels, Networking, and More

Toy examples are great… but your real code is rarely just 2 + 3. Let’s now look at how to test the kinds of logic you’ll actually have in a real app — like ViewModels, services, and even async networking code.

🧠 Example: Testing a ViewModel

Let’s say we have a very “startup MVP-ish” LoginViewModel:

// LoginViewModel.swift
final class LoginViewModel {
    func isValid(email: String, password: String) -> Bool {
        return email.contains("@") && password.count >= 8
    }
}

Now in your test target:

final class LoginViewModelTests: XCTestCase {
    
    func testIsValid_withValidInputs_returnsTrue() {
        let viewModel = LoginViewModel()
        let isValid = viewModel.isValid(email: "test@email.com", password: "password123")
        
        XCTAssertTrue(isValid)
    }

    func testIsValid_withInvalidEmail_returnsFalse() {
        let viewModel = LoginViewModel()
        let isValid = viewModel.isValid(email: "invalid", password: "password123")
        
        XCTAssertFalse(isValid)
    }
}

✅ Easy, fast, and gives you confidence your logic won’t go rogue mid-sprint.

🌐 Example: Testing a Networking Layer

Let’s say your networking logic is abstracted in a protocol like this:

protocol AuthService {
    func login(email: String, password: String, completion: @escaping (Bool) -> Void)
}

Now, you want to test your logic without actually hitting the network. This is where mocks shine ✨

final class MockAuthService: AuthService {
    var shouldSucceed = true

    func login(email: String, password: String, completion: @escaping (Bool) -> Void) {
        completion(shouldSucceed)
    }
}

And the ViewModel might look like:

final class LoginViewModel {
    private let authService: AuthService

    init(authService: AuthService) {
        self.authService = authService
    }

    func login(email: String, password: String, completion: @escaping (Bool) -> Void) {
        authService.login(email: email, password: password, completion: completion)
    }
}

This pattern of injecting mocks is exactly why good architecture makes testing so smooth 🧈

🎯 Want to see how to design your app so this kind of testing becomes second nature?
Check out _Understanding Clean Architecture in iOS_ and _How to Structure a Scalable iOS App With Modular Architecture_

**Understanding Clean Architecture in iOS: A Beginner’s Guide** _Tired of Spaghetti Code? 🍝 Let’s Clean Things Up in iOS!_medium.comhttps://medium.com/swift-pal/understanding-clean-architecture-in-ios-a-beginners-guide-69d09b4883c4

**How to Structure a Scalable iOS App with Modular Architecture** _Struggling with messy codebases and slow builds? Learn how modular architecture can make your iOS app scalable…_medium.comhttps://medium.com/swift-pal/how-to-structure-a-scalable-ios-app-with-modular-architecture-b0130da83bca

🛠️ Mocks, Stubs, and Fakes: Making Tests Actually Useful

So far, you’ve written tests for pure functions and even mocked a network service. But what happens when your code starts depending on everything — APIs, databases, time, weather, your manager’s mood? 🫣

That’s where mocks, stubs, and fakes come in. They help you test code in isolation, without dragging in the whole ecosystem.

Let’s decode the trio:

🧪 Stub

A stub gives back hardcoded data — no logic, no flair.

final class StubUserDefaults: UserDefaults {
    override func bool(forKey defaultName: String) -> Bool {
        return true // Always returns true, no matter the input
    }
}

🎭 Mock

A mock doesn’t just return values — it also records what happens.

final class MockLogger: Logger {
    var didLogError = false

    func log(error: Error) {
        didLogError = true
    }
}

Useful for verifying interactions like:

“Did this method actually call the logger when the network failed?”

🧱 Fake

A fake has actual working logic — just simplified. Think of it like a test double with a pulse.

final class FakeDatabase: Database {
    private var data: [String] = []

    func save(_ item: String) {
        data.append(item)
    }

    func fetchAll() -> [String] {
        return data
    }
}

💡 Real Example Use Case

Imagine testing a UserManager class that:

Without mocks/stubs, your test would be a nightmare stew of real API calls and analytics SDKs.

With mocks? You can test just the login logic in peace ✌️

🔄 Want your code to allow this kind of testing magic?
_Here’s a guide to Dependency Injection in Swift_ that makes writing tests 10x easier.

**Dependency Injection in Swift: A Beginner-to-Advanced Guide** _🧪 Feeling tangled in singletons and tightly-coupled code? Learn how Dependency Injection in Swift can help you write…_medium.comhttps://medium.com/swift-pal/dependency-injection-in-swift-a-beginner-to-advanced-guide-b85378c6f8d2

⚠️ Common Unit Testing Mistakes and How to Avoid Them

Even with the best intentions, it’s easy to write tests that end up doing more harm than good. Or worse — tests that look fine but silently lie to you 🫠. Let’s save you from that fate.

Here are some common mistakes (and how to dodge them like Neo in The Matrix 🕶️):

❌ Testing Implementation, Not Behavior

Bad:

XCTAssertEqual(viewModel.internalState, "loading")

Good:

viewModel.loadData()
XCTAssertEqual(viewModel.items.count, 3)

Why it’s bad: You’re locking your test to how something works instead of what it does. If you refactor your internal state later, the test breaks for no real reason.

❌ Over-Mocking Everything

Mocking is great… until your test has 7 mocks and you have no idea what’s being tested anymore.

If your test setup looks like:

let mockA = MockA()
let mockB = MockB()
let mockC = MockC()
// ...

You’re probably trying to test too much in one go. Split your logic — and your tests — into smaller chunks.

❌ No Assertions (a.k.a. “Tests” That Don’t Test)

We’ve all been there:

func testSomething() {
    let result = doSomething()
    print(result)
}

This test is just a glorified playground. Add an XCTAssert or it’s not pulling its weight.

❌ Ignoring Async or Timing Issues

Async code can be sneaky. A test might pass today and fail tomorrow — because it didn’t actually wait for the result.

Use XCTestExpectation or even better, Swift’s native async/await with:

func testSomethingAsync() async throws {
    let result = try await viewModel.fetchData()
    XCTAssertEqual(result.count, 5)
}

Reliable tests = trustworthy tests ✅

❌ Skipping Testable Architecture

If your code is tightly coupled and hard to test, it’s not just a testing problem — it’s an architecture problem.

🧱 Not sure which architecture plays nice with tests?
_This breakdown of MVC vs MVVM vs VIPER_ can help you choose what’s best for your team and sanity.

**MVC vs MVVM vs VIPER in iOS: Which Architecture Should You Choose in 2025?** _In this no-fluff breakdown, we’ll compare the good, the bad, and the “wait… why is this so complex?” of each…_medium.comhttps://medium.com/swift-pal/mvc-vs-mvvm-vs-viper-in-ios-which-architecture-should-you-choose-in-2025-38386312e0c1

⏱️ Bonus: Testing Async Code in Swift (The Easy Way)

Testing async code used to feel like a mix of hope, magic, and XCTestExpectation voodoo. But with Swift’s modern concurrency, writing async tests is finally clean and readable. 🙌

Let’s compare both styles quickly — so you know which one to reach for.

🧙‍♂️ Old-School Way:

XCTestExpectation

If you’re dealing with code that uses completion handlers, this still works:

func testLoginSuccess_withCompletion() {
    let expectation = XCTestExpectation(description: "Login succeeds")

    viewModel.login(email: "user@test.com", password: "password") { success in
        XCTAssertTrue(success)
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 2.0)
}

Useful, but… a bit clunky. Let’s modernize it.

⚡ The Swift Concurrency Way: async/await in Tests

If your methods are marked async, your test can be too:

func testFetchUser_returnsCorrectUser() async throws {
    let user = try await userService.fetchUser(id: "123")
    XCTAssertEqual(user.name, "Karan")
}

Boom — no expectations, no wait, no nonsense. Just beautiful async test flow.

You can also test throwing functions like this:

func testFetchUser_withInvalidID_throwsError() async {
    do {
        _ = try await userService.fetchUser(id: "invalid")
        XCTFail("Expected error, but got success")
    } catch {
        XCTAssertTrue(error is NetworkError)
    }
}

Yes, your tests can finally look like real Swift code. 😌

✅ Pro Tip: Use @MainActor if Testing UI-Related Code

If you’re testing something tied to the UI or @Published properties:

@MainActor
func testViewModelUpdate() async {
    let vm = YourViewModel()
    await vm.loadData()
    XCTAssertEqual(vm.state, .loaded)
}

It helps avoid nasty threading issues and matches your actual runtime behavior.

Want to understand Swift’s concurrency model deeper (and avoid async gotchas)?
Check out _Mastering async/await in Swift (Part 1)_

**Mastering async/await in Swift: A Beginner’s Guide to Modern Concurrency (Part 1)** _Confused by Swift’s async/await? Learn the basics of async/await, how it improves your Swift code, and how to get…_medium.comhttps://medium.com/swift-pal/mastering-async-await-in-swift-a-beginners-guide-to-modern-concurrency-part-1-88cdb659ac3b

🧘 Closing Thoughts: Make Testing a Habit, Not a Hassle

Unit testing isn’t just for massive teams or “enterprise” apps with 72 developers and a Jira board that looks like a crime scene 🧩. It’s for you, the indie dev, the solo hacker, the side-project builder — anyone who wants their code to just work.

Here’s what we hope you take away:

You don’t need to test everything. Just test what breaks your heart when it breaks 😅

🔗 Want to Go Deeper?

Here are some great next reads to level up your skills:

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading