Skip to content

11. Testing

What & why

Tests are how you change code without fear. Swift has two test frameworks that work side by side:

  • Swift Testing (import Testing) — the modern framework (Swift 6): macros, parameterized tests, expressive failures. Use it for new code.
  • XCTest (import XCTest) — the long-standing framework. Still required for UI tests and performance tests, and everywhere older code lives.

Both run with swift test on every platform.

Setting up

In a package, tests live in Tests/<Name>Tests/ and are declared with .testTarget:

.target(name: "TextKit"),
.testTarget(name: "TextKitTests", dependencies: ["TextKit"]),

Here's a small function to test:

// Sources/TextKit/Slug.swift
public func slugify(_ text: String) -> String {
    let lowered = text.lowercased()
    var result = ""
    var lastWasDash = true                       // avoids leading dashes
    for ch in lowered {
        if ch.isLetter || ch.isNumber {
            result.append(ch)
            lastWasDash = false
        } else if !lastWasDash {
            result.append("-")
            lastWasDash = true
        }
    }
    if result.hasSuffix("-") { result.removeLast() }
    return result
}

Swift Testing

// Tests/TextKitTests/SlugTests.swift
import Testing
@testable import TextKit

@Test func simpleTitle() {
    #expect(slugify("Hello World") == "hello-world")
}

@Test func punctuationIsCollapsed() {
    #expect(slugify("Swift -- is  fun!!") == "swift-is-fun")
}

@Test func emptyStaysEmpty() {
    #expect(slugify("").isEmpty)
}
  • @Test marks a test function (free functions are fine; no class needed).
  • #expect(condition) records a failure but keeps going; #require(condition) stops the test on failure and unwraps optionals.
  • Failure messages show the evaluated sub-expressions (slugify("x") → "y"), so you rarely need custom messages.
  • @testable import lets you see internal declarations.

Parameterized tests

Run one test body over many inputs — each is reported separately:

@Test(arguments: [
    ("Hello World", "hello-world"),
    ("  trim me  ", "trim-me"),
    ("Ünïcode 123", "ünïcode-123"),
    ("---", ""),
])
func slugs(input: String, expected: String) {
    #expect(slugify(input) == expected)
}

Expecting errors and unwrapping

enum ParseError: Error { case invalid }
func parsePort(_ s: String) throws -> Int {
    guard let n = Int(s), (1...65535).contains(n) else { throw ParseError.invalid }
    return n
}

@Test func rejectsBadPort() {
    #expect(throws: ParseError.invalid) { try parsePort("99999") }
}

@Test func parsesGoodPort() throws {
    let port = try #require(try? parsePort("8080"))      // fails the test if nil
    #expect(port == 8080)
}

Organizing: suites, tags, traits

@Suite("Slugify") struct SlugSuite {
    let sample = "Hello World"                 // a fresh instance is made for each test

    @Test func basic() { #expect(slugify(sample) == "hello-world") }

    @Test(.disabled("flaky on CI")) func skipMe() { }
    @Test(.timeLimit(.minutes(1))) func slowOne() async { }
}

Use init() / deinit in a suite for setup/teardown. Tests run in parallel by default, so keep them independent (no shared global state, no fixed temp file names).

Async tests

@Test func fetchWorks() async throws {
    let value = try await loadValue()
    #expect(value > 0)
}
func loadValue() async throws -> Int { 42 }

XCTest

import XCTest
@testable import TextKit

final class SlugXCTests: XCTestCase {
    func testSimpleTitle() {
        XCTAssertEqual(slugify("Hello World"), "hello-world")
    }

    func testThrowing() throws {
        XCTAssertThrowsError(try parsePort("0")) { error in
            XCTAssertEqual(error as? ParseError, .invalid)
        }
        let port = try XCTUnwrap(try? parsePort("22"))
        XCTAssertEqual(port, 22)
    }

    override func setUp() { /* runs before each test */ }
}

Methods starting with test are discovered. Common assertions: XCTAssertTrue/False, XCTAssertEqual/NotEqual, XCTAssertNil/NotNil, XCTAssertThrowsError, XCTFail. Async tests are func testX() async throws. Keep XCTest for performance measurement (measure { }), UI testing, and legacy suites.

What makes a good test?

  • Fast and deterministic. No sleeping, no network, no dependence on the current date.
  • Tests one behaviour, named for it: rejectsNegativeAmounts, not test1.
  • Arrange / Act / Assert: set up inputs, call the code, check outcomes.
  • Edge cases first: empty input, one element, maximum, duplicates, unicode, negative numbers.
  • Test behaviour, not implementation so refactors don't break tests.

Testing code that talks to the outside world

Hide I/O behind a protocol and pass a fake (see the Protocols lesson):

protocol Clock { func now() -> Date }

struct Subscription {
    var expires: Date
    func isActive(clock: any Clock) -> Bool { clock.now() < expires }
}

struct FixedClock: Clock { var date: Date; func now() -> Date { date } }

@Test func expiredSubscription() {
    let sub = Subscription(expires: Date(timeIntervalSince1970: 1_000))
    #expect(!sub.isActive(clock: FixedClock(date: Date(timeIntervalSince1970: 2_000))))
}

For files, use a unique temporary directory per test:

func makeTempDir() throws -> URL {
    let url = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString, isDirectory: true)
    try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
    return url
}

Running and measuring

swift test                              # everything
swift test --filter slugs               # by name substring
swift test --parallel                   # XCTest in parallel (Swift Testing is parallel already)
swift test --enable-code-coverage       # produce coverage data

Find the coverage report with swift test --show-codecov-path. Coverage numbers tell you what is not tested; they don't prove the tested code is right.

Test-driven development in one paragraph

Write a failing test for the next small behaviour; make it pass with the simplest code; refactor with the tests as a safety net; repeat. You don't have to do this always, but it's excellent for algorithms and bug fixes: reproduce every bug with a failing test before fixing it.

Common mistakes

  • Tests that depend on each other or on execution order — Swift Testing runs in parallel with random ordering.
  • Shared mutable global state between tests.
  • Using real network/time/filesystem paths, making tests slow and flaky.
  • Asserting too much in one test, so a failure doesn't tell you what broke.
  • Forgetting @testable import and wondering why internal symbols are missing.
  • XCTest-style test prefix in Swift Testing — not needed; use @Test.
  • Mixing frameworks in one function (XCTAssert inside @Test) — doesn't work as expected.

Exercises

  1. Write five Swift Testing tests for slugify including at least one parameterized test.
  2. Test-drive func isValidEmail(_ s: String) -> Bool: write the tests first, then the implementation.
  3. Add a protocol-based fake for the current date to a function that formats "x days ago", and test it.
  4. Write tests for your Stack<Element> from the Generics lesson, including popping from empty.
  5. Use #expect(throws:) and #require in a test for a throwing parser.
  6. Translate one of your Swift Testing tests into XCTest and compare the failure output when you break the code.

Recap

  • swift test runs both Swift Testing (@Test, #expect, #require) and XCTest suites.
  • Parameterized tests keep many cases tidy; suites group and share setup.
  • Tests must be independent, fast, and deterministic; hide I/O behind protocols.
  • Reproduce bugs with failing tests first.

Next: Capstone: Markdown link checker →