Skip to content

Instantly share code, notes, and snippets.

@brennanMKE
Created October 9, 2025 15:18
Show Gist options
  • Save brennanMKE/ec84033c0a71f847919f3d34716ed104 to your computer and use it in GitHub Desktop.
Save brennanMKE/ec84033c0a71f847919f3d34716ed104 to your computer and use it in GitHub Desktop.
Swift Testing: A Practical Guide

Swift Testing: A Practical Guide

This guide shows how to adopt the Swift Testing framework in Swift 6 projects, with a focus on Swift Concurrency. It also covers organizing tests using suites, groups, and tags.

Targets: Swift 6 language mode, Xcode 16+, Testing framework (import Testing)


1) Getting Started

Add tests

import Testing

@Test
func example() {
  #expect(2 + 2 == 4)
}
  • @Test marks a free function or static method as a test.
  • Use #expect to assert. Prefer #require when you need to bail out early.
  • Tests run in parallel by default; write tests to be thread‑safe.

Asynchronous tests

@Test
func fetchesUser() async throws {
  let user = try await API().user(id: 42)
  #expect(user.id == 42)
}
  • Write async / throws just like production code.
  • Use await freely; no explicit expectations or semaphores are required.

Parameterized tests

enum Fixture { static let ids = [1, 2, 3, 5, 8] }

@Test("user lookup succeeds", arguments: Fixture.ids)
func looksUpUser(id: Int) async throws {
  let user = try await API().user(id: id)
  #expect(user.id == id)
}
  • Pass a collection to arguments:; the test runs once per element.
  • Give the test a descriptive name as the first argument to @Test.

2) Organize with Suites and Groups

Create suites by namespacing tests inside a type annotated with @Suite. You can nest suites to mirror your module structure.

@Suite("API")
struct APITests {
  @Test func decode() { /* … */ }

  @Suite("Auth")
  struct Auth {
    @Test func signIn() async throws { /* … */ }
  }
}

Use groups by naming suites meaningfully and nesting where it matches your domain (e.g., Networking, Database, UI).


3) Tagging Tests (categorize and filter)

Tags let you slice test runs (e.g., smoke, networking, ui, linuxOnly). Apply tags with the .tags trait on a test or an entire suite.

@Suite("Networking", .tags(.networking))
struct Networking {
  @Test(.tags(.smoke))
  func healthcheck() async throws { /* … */ }
}

Define custom tags once and reuse them:

import Testing

@Tag
extension Tag {
  static var smoke: Self
  static var networking: Self
  static var ui: Self
}

Filtering by tag

  • In Xcode’s Test navigator/inspector, run tests by tag.
  • From the SwiftPM CLI, filter with arguments that match the tag (see your CI’s integration or Xcode scheme settings for tag filters).

4) Runtime Behavior with Traits

Traits modify or document how a test runs. The most useful ones for concurrency:

  • .timeLimit(_:) — fail a test that exceeds a duration.

    @Test(.timeLimit(.seconds(5)))
    func completesQuickly() async throws { /* … */ }
  • .serialized — run enclosed tests one‑at‑a‑time (use sparingly; prefer fixing shared state).

    @Suite("Stateful integration", .serialized)
    struct Integration { /* parameterized tests, etc. */ }
  • .enabled(if:) / .disabled() — conditionally include tests (feature flags, OS availability).

    @Test(.enabled(if: FeatureFlags.search))
    func searchIndexing() async throws { /* … */ }
  • .tags(_:) — covered above.

Tip: Apply traits at a suite to inherit them for its tests.


5) Concurrency-Friendly Patterns

Prefer pure functions and value types in test helpers. If state is required, isolate it.

Use actors to protect shared mutable state:**

actor TempStore {
  private var values: [String: Int] = [:]
  func set(_ k: String, _ v: Int) { values[k] = v }
  func get(_ k: String) -> Int? { values[k] }
}

@Test
func actorIsolation() async {
  let store = TempStore()
  await store.set("a", 1)
  #expect(await store.get("a") == 1)
}

Run independent work in parallel with async let or task groups inside tests when validating concurrency behavior:

@Test
func parallelFetches() async throws {
  async let a = API().user(id: 1)
  async let b = API().user(id: 2)
  let (u1, u2) = try await (a, b)
  #expect(u1.id != u2.id)
}

Cancellation — make long‑running helpers cooperative and assert behavior:

@Test
func cancelsWork() async throws {
  let task = Task { try await Work().run() }
  task.cancel()
  do {
    _ = try await task.value
    #expect(Bool(false), "expected CancellationError")
  } catch is CancellationError { /* success */ }
}

Main‑actor code — mark tests or helpers with @MainActor when interacting with UI or main‑thread–only APIs.

@MainActor @Test
func updatesViewModel() async {
  let vm = ViewModel()
  await vm.load()
  #expect(vm.isLoaded)
}

6) Migrating from XCTest (quick notes)

  • You can keep XCTest and Swift Testing side‑by‑side.
  • Replace XCTestCase subclasses with free functions or suites using @Suite.
  • Replace XCTAssert… with #expect / #require.
  • For async code, remove XCTestExpectation boilerplate; write async tests.

7) CI & Command Line

  • Tests run with the SwiftPM CLI and in Xcode. Examples:

    • Run everything: swift test
    • Run a suite or a test by name: use Xcode’s navigator or CLI filters
    • Filter by tag: configure tags in your scheme or CI runner (per your tool’s support)

Parallelism is default; keep tests isolated and idempotent. For unavoidable shared resources (file system, ports), scope via temp directories and unique identifiers, or fall back to .serialized temporarily while refactoring.


8) Handy Patterns & Snippets

Require then continue

@Test
func requireExample() {
  let value = #require(Int("42"))
  #expect(value == 42)
}

Parameterized with zip (pairwise)

@Test("status mapping", arguments: zip([200, 404, 500], [true, false, false]))
func mapsStatus(code: Int, isOK: Bool) {
  #expect(isOK == (code == 200))
}

Time‑boxed async

@Test(.timeLimit(.seconds(2)))
func completesUnderTwoSeconds() async throws {
  _ = try await slowCall()
}

9) Recommendations (Swift 6 mode)

  • Enable strict concurrency in build settings and make your test helpers Sendable where applicable.
  • Prefer async let, task groups, and actors over shared mutable state.
  • Tag slow, networked, and flaky tests; keep your default CI job fast by excluding these tags.
  • Use .timeLimit to catch hangs early.
  • Use .serialized only as a temporary crutch for legacy tests; refactor toward parallel‑safe helpers.

Appendix: Minimal Project Layout

MyApp/
  Sources/
  Tests/
    APITests.swift   // @Suite("API"){…}
    UITests.swift    // @Suite("UI", .tags(.ui))

That’s it — you’re ready to test Swift 6 code using Swift Concurrency with clarity and speed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment