Swift and SwiftUI tutorials for Swift Developers

How to write Unit Tests in Swift

In the competitive world of mobile development, the difference between a “good” app and a “great” app often lies in its stability and reliability. For an iOS developer, mastering unit tests in Swift is not just a desirable skill—it is a fundamental requirement for scaling projects and maintaining a healthy codebase.

With the arrival of Swift and SwiftUI, application architecture has evolved, and with it, the way we approach testing. If you are looking to improve your Swift programming skills and ensure your business logic is bulletproof across iOS, macOS, and watchOS, you have come to the right place.

This tutorial about how to write unit tests in Swift, will guide you from basic concepts to advanced dependency injection techniques, using the native XCTest framework.


Why are Unit Tests Vital in the SwiftUI Era?

Before opening Xcode, we must understand the why. In the old imperative paradigm (UIKit), we often mixed logic and views. With Swift and SwiftUI, separation of concerns is more natural, especially if we use patterns like MVVM (Model-View-ViewModel).

Unit tests focus on verifying that small pieces of code (functions, methods, classes) work exactly as expected in isolation.

Key Benefits for the Developer:

  1. Early Bug Detection: Find bugs before they reach QA or, worse, production.
  2. Safe Refactoring: You can improve your code with peace of mind, knowing that if you break something, the tests will alert you.
  3. Living Documentation: Tests explain how your code is supposed to work better than any comment ever could.
  4. Modular Design: Writing testable code forces you to write better code (decoupled and clean).

Setting Up the Environment in Xcode

To start with unit tests in Swift, you need a project with a “Unit Testing Bundle”.

Step 1: Create or Modify the Project

If you are creating a new app in Xcode:

  1. Go to File > New > Project.
  2. Select App.
  3. In the options, make sure to check the “Include Tests” box.

If you have an existing app:

  1. Go to the Project Navigator (blue folder icon).
  2. Select your main project target.
  3. At the bottom left, click the + button and search for “Unit Testing Bundle”.

This will create a new folder in your project with a YourAppTests.swift file.


The Anatomy of a Swift Test

XCTest is Apple’s native framework. A typical test class looks like this:

import XCTest
@testable import MyAwesomeApp

final class MyAwesomeAppTests: XCTestCase {

    override func setUpWithError() throws {
        // Runs BEFORE each test.
        // Ideal for resetting state or creating objects.
    }

    override func tearDownWithError() throws {
        // Runs AFTER each test.
        // Ideal for clearing memory or closing connections.
    }

    func testExample() throws {
        // 1. Arrange (Prepare)
        let valueA = 10
        let valueB = 20
        
        // 2. Act (Perform action)
        let result = valueA + valueB
        
        // 3. Assert (Verify)
        XCTAssertEqual(result, 30, "The sum should be 30")
    }
}

Important Keywords:

  • @testable import: Allows access to internal classes and methods of your main module without having to make them public.
  • XCTAssert...: These are the assertions. If the condition is false, the test fails.

Testing Strategy in SwiftUI: The MVVM Pattern

This is where Swift and SwiftUI converge. You should not try to test SwiftUI Views with unit tests (UI Tests or Snapshot Tests exist for that). Your goal is to test the state logic, which should reside in your ViewModel or data services.

Let’s build a realistic scenario. Imagine a Task Management app (To-Do List).

The Model and the ViewModel

First, let’s define the logic in our app (Main Target):

import Foundation

struct TaskItem: Identifiable, Equatable {
    let id: UUID
    let title: String
    var isCompleted: Bool
}

class TaskManagerViewModel: ObservableObject {
    @Published var tasks: [TaskItem] = []
    
    func addTask(title: String) {
        guard !title.isEmpty else { return }
        let newTask = TaskItem(id: UUID(), title: title, isCompleted: false)
        tasks.append(newTask)
    }
    
    func completeTask(id: UUID) {
        if let index = tasks.firstIndex(where: { $0.id == id }) {
            tasks[index].isCompleted = true
        }
    }
    
    func clearAll() {
        tasks.removeAll()
    }
}

This code is pure Swift programming. It doesn’t matter if the UI is on iOS, macOS, or watchOS; the logic is shared.


Writing Your First Real Unit Test

Now, let’s go to the Test Target (YourAppTests) and write tests to validate this ViewModel.

1. Initial State Test

import XCTest
@testable import MyTaskApp

final class TaskManagerTests: XCTestCase {
    
    var viewModel: TaskManagerViewModel!

    override func setUpWithError() throws {
        // We initialize the SUT (System Under Test) before each test
        viewModel = TaskManagerViewModel()
    }

    override func tearDownWithError() throws {
        viewModel = nil
    }

    func test_TaskManager_StartsEmpty() {
        // Assert
        XCTAssertTrue(viewModel.tasks.isEmpty, "The manager should start without tasks")
        XCTAssertEqual(viewModel.tasks.count, 0)
    }
}

2. Business Logic Test (Add)

Here we test that the addTask function actually works and handles edge cases (like empty titles).

func test_TaskManager_AddTask_ShouldIncreaseCount() {
        // Arrange
        let title = "Learn XCTest"
        
        // Act
        viewModel.addTask(title: title)
        
        // Assert
        XCTAssertEqual(viewModel.tasks.count, 1)
        XCTAssertEqual(viewModel.tasks.first?.title, title)
        XCTAssertFalse(viewModel.tasks.first!.isCompleted)
    }
    
    func test_TaskManager_AddEmptyTask_ShouldNotAdd() {
        // Act
        viewModel.addTask(title: "")
        
        // Assert
        XCTAssertTrue(viewModel.tasks.isEmpty, "Tasks without titles should not be allowed")
    }

3. State Modification Test

func test_TaskManager_CompleteTask_ShouldChangeState() {
        // Arrange
        let title = "Test SwiftUI"
        viewModel.addTask(title: title)
        let taskId = viewModel.tasks.first!.id
        
        // Act
        viewModel.completeTask(id: taskId)
        
        // Assert
        XCTAssertTrue(viewModel.tasks.first!.isCompleted)
    }

Advanced Level: Dependency Injection and Mocking

A senior iOS developer knows that real apps don’t keep everything in memory; they connect to APIs or databases. Testing against a real API is a bad practice (it’s slow, unstable, and depends on the internet).

This is where Mocking comes in. To make this testable, we must use Protocols.

Step 1: Define the Protocol

Let’s refactor the code so the ViewModel doesn’t depend on a concrete implementation, but on an abstraction.

// In your main code

protocol DataServiceProtocol {
    func downloadItems() async throws -> [String]
}

class RealDataService: DataServiceProtocol {
    func downloadItems() async throws -> [String] {
        // Simulate network call
        try await Task.sleep(nanoseconds: 1_000_000_000) 
        return ["Apples", "Pears", "Oranges"]
    }
}

class ShoppingListViewModel: ObservableObject {
    @Published var items: [String] = []
    let service: DataServiceProtocol
    
    // Dependency Injection
    init(service: DataServiceProtocol) {
        self.service = service
    }
    
    func loadData() async {
        do {
            let newItems = try await service.downloadItems()
            DispatchQueue.main.async {
                self.items = newItems
            }
        } catch {
            print("Error")
        }
    }
}

Step 2: Create the Mock for Tests

In your Test folder (not in the app), create a simulated object.

class MockDataService: DataServiceProtocol {
    
    var itemsToSend: [String] = []
    var shouldFail: Bool = false
    
    func downloadItems() async throws -> [String] {
        if shouldFail {
            throw URLError(.badServerResponse)
        }
        return itemsToSend
    }
}

Step 3: Testing with Async/Await

Since Swift 5.5, testing asynchronous code is much cleaner. We no longer rely as much on XCTestExpectation for simple tasks.

final class ShoppingListTests: XCTestCase {
    
    var viewModel: ShoppingListViewModel!
    var mockService: MockDataService!
    
    override func setUp() {
        mockService = MockDataService()
        viewModel = ShoppingListViewModel(service: mockService)
    }
    
    func test_LoadData_Success_ShouldPopulateArray() async {
        // Arrange
        mockService.itemsToSend = ["Test 1", "Test 2"]
        
        // Act
        await viewModel.loadData()
        
        // Assert
        // Note: Since the @Published update happens on MainActor, 
        // sometimes we need to wait a cycle. In pure unit tests of
        // async logic, we validate the direct result if possible.
        // For this simple example, we assume await waits for execution.
        
        XCTAssertEqual(viewModel.items.count, 2)
        XCTAssertEqual(viewModel.items.first, "Test 1")
    }
}

Cross-Platform Testing: iOS, macOS, watchOS

The beauty of developing with Swift and SwiftUI is portability.

When you create a test bundle, it is usually linked to a Target (e.g., your iOS App). What happens if your logic is in a shared Framework or if you have a Multiplatform App?

  1. Swift Packages: The modern best practice is to move your business logic (Models and ViewModels) to a local Swift Package.
  2. Test Targets: Swift Packages have their own test targets that can run on any platform (Mac, iPhone, Watch) simply by selecting the destination in the top Xcode bar.

If you keep your logic decoupled from UIKit or SwiftUI (importing only Foundation or Combine where possible), your unit tests will work identically on watchOS as they do on iOS.


Quality Metrics: Code Coverage

Xcode includes a fantastic tool to see how much of your code is being tested.

  1. Go to Product > Scheme > Edit Scheme.
  2. Select “Test” in the sidebar.
  3. Go to the “Options” tab.
  4. Check the box “Gather coverage for all targets”.

Now, when you run your tests (Cmd + U), you can go to the “Report Navigator” (the last icon on the right in the left panel), select the last test run, and view the “Coverage” tab. You will see a percentage.

Pro Tip: Don’t obsess over 100%. An 80% is usually an excellent standard. Focus on testing complex and critical logic, not simple setters and getters.


Best Practices for the Modern iOS Developer

To wrap up this tutorial, here is a summary of the golden rules for test-oriented Swift programming:

1. Naming Conventions

The test name should tell a story. Use the format: test_Object_Action_ExpectedResult

  • ❌ test1()
  • ✅ test_Calculator_WhenAddingTwoPlusTwo_ShouldReturnFour()

2. The FIRST Principle

  • Fast: They must run in milliseconds.
  • Isolated: A test should not depend on the result of another.
  • Repeatable: They must always yield the same result.
  • Self-validating: They pass or fail, without manual interpretation.
  • Timely: Written before or during development, not months later.

3. Avoid Logic in Tests

Your tests should not have complex if or for loops. If your test needs complex logic, who tests the test? Keep them linear: Arrange, Act, Assert.


Conclusion

Implementing unit tests in Swift may seem like a slow task at first, but it is the only way to guarantee speed in the long run. By separating your logic from the view using Swift and SwiftUI with MVVM, and by using dependency injection, you create robust code that can survive major changes and refactoring.

As an iOS developer, your responsibility is not just to write code that works today, but code that can be maintained tomorrow. Unit tests are your safety net on iOS, macOS, and watchOS.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

What is and how to use @AppStorage in SwiftUI

Next Article

Swift Testing Setup

Related Posts