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:
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)
}
@Testmarks 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 importlets you seeinternaldeclarations.
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, nottest1. - 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 importand wondering whyinternalsymbols are missing. - XCTest-style
testprefix in Swift Testing — not needed; use@Test. - Mixing frameworks in one function (
XCTAssertinside@Test) — doesn't work as expected.
Exercises¶
- Write five Swift Testing tests for
slugifyincluding at least one parameterized test. - Test-drive
func isValidEmail(_ s: String) -> Bool: write the tests first, then the implementation. - Add a protocol-based fake for the current date to a function that formats "x days ago", and test it.
- Write tests for your
Stack<Element>from the Generics lesson, including popping from empty. - Use
#expect(throws:)and#requirein a test for a throwing parser. - Translate one of your Swift Testing tests into XCTest and compare the failure output when you break the code.
Recap¶
swift testruns 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.