iosNudge

How do you write unit tests in Swift? Compare Swift Testing and XCTest.

EasyAsked oftenTesting 2 min read

TL;DR

  • Swift Testing (Xcode 16+): @Test functions, #expect and #require macros, parameterized tests, runs in parallel by default. Use it for new unit tests.
  • XCTest: XCTestCase subclasses, test... methods, XCTAssert... functions. Still needed for UI tests and performance tests.
  • Both can live in the same test target, so you can migrate step by step.

A Swift Testing example

import Testing
@testable import Shop

@Suite("Cart")
struct CartTests {
    @Test func emptyCartHasZeroTotal() {
        let cart = Cart()
        #expect(cart.total == 0)
    }

    @Test func addingItemsUpdatesTotal() throws {
        var cart = Cart()
        try cart.add(Item(price: 10), quantity: 2)
        #expect(cart.total == 20)
        #expect(cart.items.count == 1)
    }
}
  • #expect shows the actual values on failure, like cart.total → 15 == 20.
  • A new CartTests instance is created for each test. Use init for setup and deinit (in a class) for teardown.

#require: stop when a precondition fails

@Test func decodesFirstProduct() throws {
    let products = try JSONDecoder().decode([Product].self, from: fixture)
    let first = try #require(products.first) // unwraps or fails and stops
    #expect(first.name == "Lamp")
}

Parameterized tests

One test, many inputs. Each case shows as its own result.

@Test(arguments: [
    ("a@b.com", true),
    ("no-at-sign", false),
    ("", false)
])
func validatesEmail(input: String, isValid: Bool) {
    #expect(EmailValidator.isValid(input) == isValid)
}

Async and errors

@Test func loadsProfile() async throws {
    let model = ProfileModel(api: FakeAPI(user: .sample))
    try await model.load()
    #expect(model.name == "Ana")
}

@Test func rejectsNegativeQuantity() {
    var cart = Cart()
    #expect(throws: CartError.invalidQuantity) {
        try cart.add(Item(price: 5), quantity: -1)
    }
}

For callbacks, use confirmation:

@Test func notifiesOnSave() async {
    await confirmation("saved") { saved in
        let store = Store(onSave: { saved() })
        await store.save()
    }
}

Traits

@Test(.disabled("Flaky on CI")) func syncs() async {}
@Test(.tags(.networking), .timeLimit(.minutes(1))) func downloads() async {}
@Suite(.serialized) struct DatabaseTests {} // run one by one

The same tests in XCTest

import XCTest
@testable import Shop

final class CartXCTests: XCTestCase {
    var cart: Cart!

    override func setUp() {
        super.setUp()
        cart = Cart()
    }

    func testAddingItemsUpdatesTotal() throws {
        try cart.add(Item(price: 10), quantity: 2)
        XCTAssertEqual(cart.total, 20)
    }

    func testLoadsProfile() async throws {
        let model = ProfileModel(api: FakeAPI(user: .sample))
        try await model.load()
        XCTAssertEqual(model.name, "Ana")
    }
}
Swift TestingXCTest
Declare a test@Test on any functionMethod name starts with test
Assertions#expect, #requireMany XCTAssert... functions
Setupinit / deinitsetUp / tearDown
ParameterizedBuilt inManual loops
ParallelBy default, in processOptional, separate processes
UI and performance testsNoYes

What makes a good unit test

  • Fast: no real network, disk or sleeps.
  • Isolated: no shared state between tests. Important now that tests run in parallel.
  • Clear: Arrange, Act, Assert. One behavior per test. A name that says what is expected.
  • Deterministic: inject clocks, random generators and UUIDs.

Common follow-up questions

  • What does @testable import do? It lets tests see internal symbols of the module.
  • How do you test @MainActor code? Mark the test or suite @MainActor.
  • How do you measure code coverage? Turn on "Gather coverage" in the scheme's test settings. Aim for important paths, not a number.
  • What do you test in a view model? State changes after actions, error states, and calls to dependencies.
  • Can Swift Testing run XCTest tests? They run side by side in one target. Do not mix their assertions in one test.