Skip to content

Instantly share code, notes, and snippets.

@leogdion
Last active January 28, 2026 18:21
Show Gist options
  • Select an option

  • Save leogdion/0806c2f41aeb2c77db6a4a846cf13c0f to your computer and use it in GitHub Desktop.

Select an option

Save leogdion/0806c2f41aeb2c77db6a4a846cf13c0f to your computer and use it in GitHub Desktop.
Swift Testing Guide: Hierarchical organization patterns and best practice

Swift Testing Guide

A comprehensive guide to organizing and writing tests with Apple's Swift Testing framework.

What you'll find here:

  • Hierarchical test organization patterns for scaling your test suite
  • Naming conventions and best practices that prevent common pitfalls
  • Quick reference cheat sheet for daily use
  • Complete Swift Testing API reference

Who this is for: Swift developers adopting Swift Testing, especially those working on complex projects with growing test suites that need clear organization and maintainability.


Navigation

Guide Purpose When to Use Length
Quick Reference One-page cheat sheet with copy-pasteable patterns, naming rules, and API essentials Use while coding - Ctrl+F to find patterns instantly 15 min skim
Test Organization Guide In-depth guide to hierarchical test organization patterns with complete examples Read when setting up new test suites or refactoring existing tests 30 min read
Swift Testing Reference Complete Apple Swift Testing API documentation Reference when learning specific features or APIs Lookup only

Recommended reading order:

  1. Start with this README to understand the value proposition
  2. Skim the Quick Reference to see patterns at a glance
  3. Read the Test Organization Guide when implementing
  4. Reference the Swift Testing API docs as needed

Quick Example

Here's what organized Swift tests look like using Pattern A (parent has "Tests" suffix):

// File: UserManagerTests.swift
import Testing

/// Test suite for UserManager functionality.
@Suite("User Manager")
internal enum UserManagerTests {}
// File: UserManagerTests+Validation.swift
import Testing

extension UserManagerTests {
  /// Input validation tests for UserManager.
  @Suite("Validation Tests")
  internal struct Validation {
    // MARK: - Test Data Setup

    private static let validUserID = "user123"
    private static let emptyUserID = ""
    private static let tooLongUserID = String(repeating: "a", count: 1000)

    // MARK: - User ID Validation

    @Test("Validate user ID with correct format")
    internal func validateCorrectFormat() {
      let isValid = UserManager.validateUserID(Self.validUserID)
      #expect(isValid)
    }

    @Test("Reject empty user ID")
    internal func rejectEmptyUserID() {
      let isValid = UserManager.validateUserID(Self.emptyUserID)
      #expect(!isValid)
    }

    @Test("Reject user ID that is too long")
    internal func rejectTooLongUserID() {
      let isValid = UserManager.validateUserID(Self.tooLongUserID)
      #expect(!isValid)
    }
  }
}

File structure:

Tests/MyLibraryTests/
└── UserManager/
    ├── UserManagerTests.swift            # Parent enum
    ├── UserManagerTests+Validation.swift # Validation category
    ├── UserManagerTests+Errors.swift     # Error handling category
    └── UserManager+TestHelpers.swift     # Test helpers

Benefits:

  • ✅ Clear hierarchy groups related tests together
  • ✅ Scalable - add categories without bloating files
  • ✅ Parallel execution - Swift Testing runs suites concurrently
  • ✅ Easy navigation - file structure mirrors test organization

Key Concepts

The "Tests" Suffix Rule

CRITICAL RULE: Include "Tests" in EITHER the parent enum OR the child struct, NEVER both.

✅ CORRECT:

UserManagerTests + Validation      → UserManagerTests+Validation.swift
UserManager + ValidationTests      → UserManager+ValidationTests.swift

❌ INCORRECT:

UserManagerTests + ValidationTests → UserManagerTests+ValidationTests.swift (redundant!)

Why this matters:

  • Reduces redundancy and cognitive load
  • File location already indicates these are tests
  • Shorter names in test output and file lists
  • Forces consistent project-wide convention

Pattern A vs Pattern B

Pattern A: Parent has "Tests"

  • Parent: UserManagerTests | Children: Basic, Validation, Errors
  • Advantages: Matches standard test file naming, clear test hierarchy, shorter child names
  • File: UserManagerTests+Validation.swift

Pattern B: Child has "Tests"

  • Parent: UserManager | Children: BasicTests, ValidationTests, ErrorTests
  • Advantages: Parent matches production type name, easy to filter test structs, clear separation
  • File: UserManager+ValidationTests.swift

Both patterns are equally valid. Choose one and apply it consistently across your project.

Simple vs Hierarchical Patterns

Use Simple Pattern (single struct) when:

  • Type has < 10 tests
  • Focused, straightforward behavior
  • No need for multiple test categories
// File: FieldValueTests.swift
@Suite("Field Value")
internal struct FieldValueTests {
  @Test("Create from string")
  internal func createFromString() { ... }
}

Use Hierarchical Pattern (enum + extensions) when:

  • Type has multiple test categories (validation, errors, concurrency, etc.)
  • Test suite will grow over time
  • Different categories need different test data
  • You need scalable organization
// File: UserManagerTests.swift
@Suite("User Manager")
internal enum UserManagerTests {}

// File: UserManagerTests+Validation.swift
extension UserManagerTests {
  @Suite("Validation")
  internal struct Validation { ... }
}

Links & Resources

Official Apple Documentation

Related Resources

Contributing

This guide documents patterns for organizing Swift tests effectively. If you have suggestions or improvements, please open an issue or pull request.

License

This guide is provided as-is for educational purposes. Code examples may be used freely in your projects.

Swift Testing Quick Reference

Purpose: Fast lookup cheat sheet for Swift Testing patterns. Use Ctrl+F to find what you need while coding.


Table of Contents

  1. Naming Conventions
  2. Three Essential Patterns
  3. File Structure Templates
  4. Decision Guide
  5. Common Mistakes & Fixes
  6. MARK Sections Checklist
  7. Swift Testing Essentials
  8. XCTest Migration

Naming Conventions

The Critical Rule

Include "Tests" in EITHER parent OR child, NEVER both.

Pattern Parent Child Filename Status
Pattern A UserManagerTests Validation UserManagerTests+Validation.swift ✅ Correct
Pattern B UserManager ValidationTests UserManager+ValidationTests.swift ✅ Correct
❌ Avoid UserManagerTests ValidationTests UserManagerTests+ValidationTests.swift ❌ Redundant

Test Helper Naming

Always extend the production type, not the test enum:

// ✅ Correct: Extend production type
// File: UserManager+TestHelpers.swift
extension UserManager {
  internal func testValidate() async -> Bool { ... }
}

// ❌ Wrong: Don't extend test enum
extension UserManagerTests {
  internal func testValidate() async -> Bool { ... }
}

Three Essential Patterns

1. Simple Pattern (Single Struct)

When to use: Types with < 10 tests, focused behavior, no need for categories

// File: FieldValueTests.swift
import Testing

/// Tests for FieldValue type conversions.
@Suite("Field Value")
internal struct FieldValueTests {
  // MARK: - Test Data Setup

  private static let testString = "Hello, World"
  private static let testNumber = 42

  // MARK: - String Value Tests

  @Test("Create field value from string")
  internal func createFromString() {
    let value = FieldValue.string(Self.testString)
    #expect(value.stringValue == Self.testString)
  }

  @Test("Convert field value to string")
  internal func convertToString() {
    let value = FieldValue.string(Self.testString)
    #expect(value.stringValue == Self.testString)
  }

  // MARK: - Number Value Tests

  @Test("Create field value from integer")
  internal func createFromInteger() {
    let value = FieldValue.int(Self.testNumber)
    #expect(value.intValue == Self.testNumber)
  }
}

2. Pattern A: Parent Has "Tests"

When to use: Most common pattern - matches standard test file naming conventions

// File: UserManagerTests.swift
import Testing

/// Test suite for UserManager functionality.
@Suite("User Manager")
internal enum UserManagerTests {}
// File: UserManagerTests+Basic.swift
import Testing

extension UserManagerTests {
  /// Basic UserManager functionality tests.
  @Suite("Basic Tests")
  internal struct Basic {
    // MARK: - Test Data Setup

    private static let testUserID = "user123"
    private static let testConfig = UserConfig(
      autoSave: true,
      cacheEnabled: true
    )

    // MARK: - Initialization Tests

    @Test("Initialize with valid configuration")
    internal func initializeWithValidConfiguration() {
      let manager = UserManager(config: Self.testConfig)
      #expect(manager.config.autoSave)
      #expect(manager.config.cacheEnabled)
    }

    @Test("Initialize with default configuration")
    internal func initializeWithDefaultConfiguration() {
      let manager = UserManager()
      #expect(manager.config != nil)
    }

    // MARK: - User Fetching Tests

    @Test("Fetch user successfully")
    internal func fetchUserSuccessfully() async {
      let manager = UserManager(config: Self.testConfig)
      let user = await manager.fetchUser(id: Self.testUserID)
      #expect(user != nil)
    }
  }
}
// File: UserManagerTests+Validation.swift
import Testing

extension UserManagerTests {
  /// UserManager validation tests.
  @Suite("Validation Tests")
  internal struct Validation {
    // MARK: - Test Data Setup

    private static let validUserID = "user123"
    private static let emptyUserID = ""
    private static let tooLongUserID = String(repeating: "a", count: 1000)

    // MARK: - User ID Validation

    @Test("Validate user ID with correct format")
    internal func validateCorrectFormat() {
      let isValid = UserManager.validateUserID(Self.validUserID)
      #expect(isValid)
    }

    @Test("Reject empty user ID")
    internal func rejectEmptyUserID() {
      let isValid = UserManager.validateUserID(Self.emptyUserID)
      #expect(!isValid)
    }

    @Test("Reject user ID that is too long")
    internal func rejectTooLongUserID() {
      let isValid = UserManager.validateUserID(Self.tooLongUserID)
      #expect(!isValid)
    }
  }
}

3. Pattern B: Child Has "Tests"

When to use: Parent name should match production type exactly

// File: UserManager.swift
import Testing

/// Test suite for UserManager functionality.
@Suite("User Manager")
internal enum UserManager {}
// File: UserManager+ValidationTests.swift
import Testing

extension UserManager {
  /// UserManager validation tests.
  @Suite("Validation Tests")
  internal struct ValidationTests {
    // MARK: - Test Data Setup

    private static let validUserID = "user123"
    private static let emptyUserID = ""

    // MARK: - User ID Validation

    @Test("Validate user ID with correct format")
    internal func validateCorrectFormat() {
      let isValid = UserManager.validateUserID(Self.validUserID)
      #expect(isValid)
    }

    @Test("Reject empty user ID")
    internal func rejectEmptyUserID() {
      let isValid = UserManager.validateUserID(Self.emptyUserID)
      #expect(!isValid)
    }
  }
}

File Structure Templates

Pattern A Directory Structure

Tests/MyLibraryTests/
└── UserManager/
    ├── UserManagerTests.swift                # Parent enum: UserManagerTests {}
    ├── UserManagerTests+Basic.swift          # Nested struct: Basic
    ├── UserManagerTests+Validation.swift     # Nested struct: Validation
    ├── UserManagerTests+Errors.swift         # Nested struct: Errors
    ├── UserManagerTests+Concurrent.swift     # Nested struct: Concurrent
    └── UserManager+TestHelpers.swift         # Helpers on UserManager (production type)

Pattern B Directory Structure

Tests/MyLibraryTests/
└── UserManager/
    ├── UserManager.swift                     # Parent enum: UserManager {}
    ├── UserManager+BasicTests.swift          # Nested struct: BasicTests
    ├── UserManager+ValidationTests.swift     # Nested struct: ValidationTests
    ├── UserManager+ErrorTests.swift          # Nested struct: ErrorTests
    ├── UserManager+ConcurrentTests.swift     # Nested struct: ConcurrentTests
    └── UserManager+TestHelpers.swift         # Helpers on UserManager

Standard Category Names

Use these common category names consistently:

  • Basic / BasicTests - Core functionality, happy path tests
  • Initialization / InitializationTests - Constructor and setup tests
  • Validation / ValidationTests - Input validation and constraints
  • Errors / ErrorTests - Error handling and edge cases
  • Concurrent / ConcurrentTests - Concurrency and thread safety
  • Integration / IntegrationTests - Integration with other components
  • Performance / PerformanceTests - Performance and benchmarking

Decision Guide

Should I Use Simple or Hierarchical Pattern?

START: How many tests do I need?
│
├─→ Less than 10 tests
│   └─→ Use Simple Pattern (single struct)
│
└─→ More than 10 tests OR will grow over time
    │
    ├─→ Do I need multiple test categories?
    │   ├─→ YES → Use Hierarchical Pattern (enum + extensions)
    │   └─→ NO → Use Simple Pattern
    │
    └─→ Do different tests need different test data?
        ├─→ YES → Use Hierarchical Pattern
        └─→ NO → Use Simple Pattern

Pattern A vs Pattern B Comparison

Criteria Pattern A (Parent has "Tests") Pattern B (Child has "Tests")
Parent name UserManagerTests UserManager
Child name Validation ValidationTests
Filename UserManagerTests+Validation.swift UserManager+ValidationTests.swift
Best for Matching test file conventions Matching production type names
Advantage Clear test hierarchy, shorter child names Easy to search for all test structs
Search filter Filter parent by "Tests" Filter child by "Tests"

Choose Pattern A if: You want file names to immediately signal tests with "Tests" prefix

Choose Pattern B if: You want parent enum to exactly match production type name

Do I Need Test Helpers?

START: What do I need to test?
│
├─→ Async operations need simpler interface
│   └─→ Create async wrapper helpers
│
├─→ Need factory methods for test instances
│   └─→ Create factory helpers
│
├─→ Complex validation used across multiple tests
│   └─→ Create validation helpers
│
└─→ Simple operations, no repetition
    └─→ No helpers needed

Common Mistakes & Fixes

1. Using "Tests" in Both Parent and Child

WRONG:

internal enum UserManagerTests {}
extension UserManagerTests {
  struct ValidationTests { ... }  // Redundant!
}
// File: UserManagerTests+ValidationTests.swift

FIX - Use Pattern A:

internal enum UserManagerTests {}
extension UserManagerTests {
  struct Validation { ... }  // No "Tests"
}
// File: UserManagerTests+Validation.swift

FIX - Use Pattern B:

internal enum UserManager {}
extension UserManager {
  struct ValidationTests { ... }  // Has "Tests"
}
// File: UserManager+ValidationTests.swift

2. Mutable Test Data

WRONG:

internal struct Validation {
  private var testUserID = "user123"  // Mutable, instance property

  @Test func validateUser() {
    self.testUserID = "modified"  // ❌ Can't modify in struct
  }
}

FIX:

internal struct Validation {
  private static let testUserID = "user123"  // Immutable, static

  @Test func validateUser() {
    let id = Self.testUserID  // Access via Self
    #expect(UserManager.validate(id))
  }
}

3. Multiple Suites in One File

WRONG:

// File: UserManagerTests+Categories.swift
extension UserManagerTests {
  struct Validation { ... }
  struct Errors { ... }      // ❌ Should be in separate file
  struct Concurrent { ... }  // ❌ Should be in separate file
}

FIX:

// File: UserManagerTests+Validation.swift
extension UserManagerTests {
  struct Validation { ... }
}

// File: UserManagerTests+Errors.swift
extension UserManagerTests {
  struct Errors { ... }
}

// File: UserManagerTests+Concurrent.swift
extension UserManagerTests {
  struct Concurrent { ... }
}

4. Using XCTest Assertions

WRONG:

import XCTest

@Test("Validate user")
func validateUser() {
  XCTAssertEqual(user.id, "123")     // ❌ XCTest API
  XCTAssertTrue(user.isValid)        // ❌ XCTest API
  XCTAssertNotNil(user.name)         // ❌ XCTest API
}

FIX:

import Testing

@Test("Validate user")
func validateUser() {
  #expect(user.id == "123")          // ✅ Swift Testing
  #expect(user.isValid)              // ✅ Swift Testing
  #expect(user.name != nil)          // ✅ Swift Testing
}

5. Mismatched File Names

WRONG:

// File: UserManagerTests+ValidationTests.swift  ❌ Name suggests both have "Tests"
extension UserManagerTests {
  struct Validation { ... }  // Actual child doesn't have "Tests"
}

FIX:

// File: UserManagerTests+Validation.swift  ✅ Matches actual names
extension UserManagerTests {
  struct Validation { ... }
}

MARK Sections Checklist

Standard MARK Order

Copy-paste these into your test files:

// MARK: - Test Data Setup

// MARK: - Initialization Tests

// MARK: - Functional Tests

// MARK: - Error Handling Tests

// MARK: - Concurrent Tests

// MARK: - Edge Cases

// MARK: - Performance Tests

// MARK: - Integration Tests

When to Use Each Section

Section Use When Example Tests
Test Data Setup Always - define static test data first private static let validConfig = ...
Initialization Tests Testing constructors, setup, defaults Constructor validation, default values
Functional Tests Testing core functionality, happy paths Main operations, typical use cases
Error Handling Tests Testing invalid input, errors, recovery Throws assertions, error types
Concurrent Tests Testing thread safety, race conditions Actor isolation, concurrent access
Edge Cases Testing boundary values, nil, empty inputs Empty strings, max values, nil handling
Performance Tests Benchmarking speed, memory, scalability Time limits, memory usage
Integration Tests Testing component interaction, workflows End-to-end flows, system tests

Sub-sections Example

For complex categories, use sub-sections:

// MARK: - Functional Tests

// MARK: Basic Operations
@Test("Perform simple query")
internal func performSimpleQuery() { ... }

// MARK: Complex Operations
@Test("Perform multi-step workflow")
internal func performMultiStepWorkflow() { ... }

// MARK: Batch Operations
@Test("Process batch request")
internal func processBatchRequest() { ... }

Swift Testing Essentials

Assertions

// Basic expectation
#expect(condition)
#expect(user.isValid)

// Equality
#expect(value == expected)
#expect(value != unexpected)

// Boolean
#expect(result)
#expect(!failure)

// Optional unwrapping (fails test if nil)
let unwrapped = try #require(optionalValue)
#expect(unwrapped != nil)

// Error throwing
#expect(throws: ValidationError.self) {
  try validateInput("")
}

#expect(throws: (any Error).self) {
  try anyErrorFunction()
}

// Error NOT throwing
#expect(throws: Never.self) {
  try nonThrowingFunction()
}

// Custom messages
#expect(value == expected, "Value should equal expected result")

// Collection expectations
#expect(array.contains(item))
#expect(array.isEmpty)
#expect(array.count == 5)

Suite Attributes

// Basic suite
@Suite("Description")
internal struct MyTests { ... }

// Conditional suite (runs only on specific platform)
@Suite("iOS Tests", .enabled(if: os(iOS)))
internal struct IOSTests { ... }

// Tagged suite (run with: swift test --filter tag:slow)
@Suite("Performance Tests", .tags(.slow))
internal struct PerformanceTests { ... }

// Disabled suite
@Suite("Legacy Tests", .disabled("Deprecated functionality"))
internal struct LegacyTests { ... }

// Multiple attributes
@Suite(
  "Integration Tests",
  .enabled(if: !DEBUG),
  .tags(.integration, .slow)
)
internal struct IntegrationTests { ... }

// Serial execution (tests run one at a time)
@Suite("Serial Tests", .serialized)
internal struct SerialTests { ... }

Test Attributes

// Basic test
@Test("Description")
internal func testSomething() { ... }

// Async test
@Test("Async operation")
internal func testAsync() async {
  let result = await fetchData()
  #expect(result != nil)
}

// Throwing test
@Test("Validate input throws")
internal func testThrows() throws {
  try validateInput("test")
}

// Async throwing test
@Test("Fetch data or throw")
internal func testAsyncThrows() async throws {
  let data = try await fetchData()
  #expect(data.count > 0)
}

// Conditional test
@Test("macOS only", .enabled(if: os(macOS)))
internal func testMacOSFeature() { ... }

// Tagged test
@Test("Network test", .tags(.network, .slow))
internal func testNetwork() async { ... }

// Disabled test
@Test("Broken test", .disabled("Bug #12345"))
internal func testBroken() { ... }

// Bug tracking
@Test("Ice cream test", .bug("https://example.com/bug/12345", "Out of sprinkles"))
internal func testIceCream() { ... }

// Time limit (fails if test takes longer)
@Test("Fast test", .timeLimit(.minutes(1)))
internal func testFast() async { ... }

Parameterized Tests

// Single parameter
@Test("Validate inputs", arguments: [
  "valid@example.com",
  "user@domain.co.uk",
  "test+tag@example.com"
])
internal func validateEmail(email: String) {
  #expect(EmailValidator.isValid(email))
}

// Multiple parameters (cartesian product)
@Test(
  "Test combinations",
  arguments: ["a", "b", "c"],  // 3 strings
  [1, 2, 3]                     // 3 numbers
)
internal func testCombinations(string: String, number: Int) {
  // This test runs 3 × 3 = 9 times
  #expect(!string.isEmpty)
  #expect(number > 0)
}

// Zipped parameters (paired)
@Test(
  "Test pairs",
  arguments: zip(
    ["user1", "user2", "user3"],
    ["pass1", "pass2", "pass3"]
  )
)
internal func testLogin(username: String, password: String) {
  // This test runs 3 times (paired)
  let result = authenticate(username, password)
  #expect(result.isValid)
}

// Complex arguments
private static let testCases = [
  (input: "hello", expected: "HELLO"),
  (input: "world", expected: "WORLD"),
  (input: "Swift", expected: "SWIFT")
]

@Test("Uppercase conversion", arguments: testCases)
internal func testUppercase(testCase: (input: String, expected: String)) {
  let result = testCase.input.uppercased()
  #expect(result == testCase.expected)
}

Custom Tags

// Define custom tags
extension Tag {
  @Tag static var slow: Self
  @Tag static var network: Self
  @Tag static var integration: Self
  @Tag static var unit: Self
  @Tag static var performance: Self
}

// Use tags on suites
@Suite("Network Tests", .tags(.network, .integration))
internal struct NetworkTests { ... }

// Use tags on tests
@Test("API call", .tags(.network, .slow))
internal func testAPICall() async { ... }

// Run specific tags:
// swift test --filter tag:network
// swift test --filter tag:unit
// swift test --skip tag:slow

Async Testing

// Basic async test
@Test("Fetch data")
internal func testFetchData() async {
  let data = await fetchData()
  #expect(data != nil)
}

// Async throwing
@Test("Fetch or throw")
internal func testFetchOrThrow() async throws {
  let data = try await fetchData()
  #expect(data.count > 0)
}

// Concurrent operations
@Test("Concurrent fetches")
internal func testConcurrent() async {
  async let result1 = fetch(id: 1)
  async let result2 = fetch(id: 2)
  async let result3 = fetch(id: 3)

  let results = await [result1, result2, result3]
  #expect(results.allSatisfy { $0 != nil })
}

// Actor isolation
@Test("Actor-isolated operation")
internal func testActor() async {
  let actor = MyActor()
  let result = await actor.performOperation()
  #expect(result.isValid)
}

// Task groups
@Test("Task group")
internal func testTaskGroup() async {
  await withTaskGroup(of: Int.self) { group in
    for i in 1...10 {
      group.addTask { await fetch(id: i) }
    }

    var count = 0
    for await _ in group {
      count += 1
    }
    #expect(count == 10)
  }
}

Time Limits

// Per-test time limit
@Test("Quick operation", .timeLimit(.seconds(5)))
internal func testQuick() async {
  // Must complete in 5 seconds or fail
  await performQuickOperation()
}

// Per-suite time limit
@Suite("Fast Tests", .timeLimit(.minutes(1)))
internal struct FastTests {
  @Test("Test 1")
  internal func test1() async { ... }

  @Test("Test 2")
  internal func test2() async { ... }

  // All tests combined must complete in 1 minute
}

// Different time units
@Test("Timeout test", .timeLimit(.milliseconds(500)))
@Test("Timeout test", .timeLimit(.seconds(30)))
@Test("Timeout test", .timeLimit(.minutes(5)))

Serial Execution

// Suite-level serialization (all tests run one at a time)
@Suite("Serial Suite", .serialized)
internal struct SerialTests {
  @Test("Test 1")
  internal func test1() { ... }

  @Test("Test 2")
  internal func test2() { ... }

  // Tests run sequentially, not in parallel
}

// Test-level serialization (specific tests run serially)
@Test("Serial test 1", .serialized)
internal func serialTest1() { ... }

@Test("Serial test 2", .serialized)
internal func serialTest2() { ... }

// Use when:
// - Tests share mutable state
// - Testing order-dependent behavior
// - Resource contention issues
// - Debugging timing issues

XCTest Migration

Quick Conversion Table

XCTest Swift Testing
class FooTests: XCTestCase @Suite("Foo") struct FooTests
func testSomething() @Test("Something") func something()
XCTAssertEqual(a, b) #expect(a == b)
XCTAssertNotEqual(a, b) #expect(a != b)
XCTAssertTrue(condition) #expect(condition)
XCTAssertFalse(condition) #expect(!condition)
XCTAssertNil(value) #expect(value == nil)
XCTAssertNotNil(value) #expect(value != nil)
XCTAssertThrowsError #expect(throws:)
XCTUnwrap(optional) try #require(optional)
XCTFail("message") Issue.record("message")
setUp() / tearDown() init() / deinit
setUpWithError() / tearDownWithError() init() throws / deinit
addTeardownBlock { } Use defer in test

Migration Example

Before (XCTest):

import XCTest

class UserManagerTests: XCTestCase {
  var manager: UserManager!

  override func setUp() {
    super.setUp()
    manager = UserManager(config: .default)
  }

  override func tearDown() {
    manager = nil
    super.tearDown()
  }

  func testValidateUserID() {
    let isValid = manager.validateUserID("user123")
    XCTAssertTrue(isValid, "User ID should be valid")
  }

  func testRejectEmptyUserID() {
    let isValid = manager.validateUserID("")
    XCTAssertFalse(isValid, "Empty user ID should be rejected")
  }

  func testFetchUserAsync() {
    let expectation = expectation(description: "Fetch user")
    Task {
      let user = await manager.fetchUser(id: "user123")
      XCTAssertNotNil(user)
      expectation.fulfill()
    }
    waitForExpectations(timeout: 5)
  }
}

After (Swift Testing):

import Testing

@Suite("User Manager")
internal struct UserManagerTests {
  // MARK: - Test Data Setup

  private static let testUserID = "user123"
  private static let emptyUserID = ""
  private static let testConfig = UserConfig.default

  // MARK: - Validation Tests

  @Test("Validate user ID with correct format")
  internal func validateUserID() {
    let manager = UserManager(config: Self.testConfig)
    let isValid = manager.validateUserID(Self.testUserID)
    #expect(isValid)
  }

  @Test("Reject empty user ID")
  internal func rejectEmptyUserID() {
    let manager = UserManager(config: Self.testConfig)
    let isValid = manager.validateUserID(Self.emptyUserID)
    #expect(!isValid)
  }

  // MARK: - Async Tests

  @Test("Fetch user asynchronously")
  internal func fetchUserAsync() async {
    let manager = UserManager(config: Self.testConfig)
    let user = await manager.fetchUser(id: Self.testUserID)
    #expect(user != nil)
  }
}

Key Differences

  1. No test class inheritance - Swift Testing uses structs with @Suite, not classes inheriting from XCTestCase

  2. No setUp/tearDown - Use init() for setup and deinit for teardown, or create instances in each test

  3. Static test data - Use private static let for test data instead of instance properties

  4. Native async support - Just mark test as async, no need for expectations and waitForExpectations

  5. Simpler assertions - Use #expect() with Swift expressions instead of XCTest assertion functions


End of Quick Reference

For comprehensive coverage and detailed explanations, see:

Test Organization Guide for Swift Testing

Overview

This guide documents a hierarchical test organization pattern for Swift Testing using empty enum containers with nested struct extensions. This approach provides:

  • Clear hierarchy: Logical grouping of related tests
  • Scalability: Easy to add new test categories without file bloat
  • Parallel execution: Swift Testing can run suites concurrently
  • Discoverable structure: File organization mirrors test hierarchy
  • Maintainability: Isolated test categories in separate files

When to use this pattern:

  • Production types with multiple test categories (validation, errors, concurrency, etc.)
  • Test suites that will grow over time
  • When you need to organize tests by feature or behavior
  • For complex types requiring different test data per category

When to use simple patterns:

  • Types with a small, focused set of tests (< 10 tests)
  • Utility types with straightforward behavior
  • Tests that don't require multiple categories

Core Pattern Overview

Swift Testing supports hierarchical test organization through nested suites. This guide presents a pattern using empty enums as parent containers with struct extensions for categories, along with a critical naming convention.

Pattern A: Parent Has "Tests" Suffix

// 1. Empty enum as parent container (has "Tests")
@Suite("Parent Suite Name")
internal enum ParentTests {}

// 2. Extension with nested suite struct (no "Tests")
extension ParentTests {
  @Suite("Category Description")
  internal struct Category {
    // 3. Test methods
    @Test("Test description")
    internal func testSomething() {
      #expect(condition)
    }
  }
}

File structure:

  • ParentTests.swift - Defines the empty enum
  • ParentTests+Category.swift - Extension with nested struct

Pattern B: Child Has "Tests" Suffix

// 1. Empty enum as parent container (no "Tests")
@Suite("Parent Suite Name")
internal enum Parent {}

// 2. Extension with nested suite struct (has "Tests")
extension Parent {
  @Suite("Category Description")
  internal struct CategoryTests {
    // 3. Test methods
    @Test("Test description")
    internal func testSomething() {
      #expect(condition)
    }
  }
}

File structure:

  • Parent.swift - Defines the empty enum
  • Parent+CategoryTests.swift - Extension with nested struct

Critical Convention: The "Tests" Suffix Rule

Never use "Tests" in both the parent enum AND child struct names.

This is the most important naming rule when using this organizational pattern. Choose one location for "Tests" and apply it consistently throughout your project.

Naming Convention Rationale

The "Tests" Suffix Rule Explained

Rule: Include "Tests" in EITHER the parent enum OR the child struct, NEVER both.

Why This Matters

  1. Reduces Redundancy:

    • UserManagerTests+Validation is cleaner
    • UserManagerTests+ValidationTests is redundant
  2. Clearer Intent: The file extension and test directory location already indicate these are tests

    • File: TypeNameTests+Category.swift in Tests/ directory → obviously tests
    • File: TypeName+CategoryTests.swift in Tests/ directory → also obviously tests
  3. Shorter Names: Reduces cognitive load when reading test output and file lists

  4. Consistency: Pick one pattern for your project and maintain it

Examples

Correct patterns:

UserManagerTests + Validation      → UserManagerTests+Validation.swift
UserManager + ValidationTests      → UserManager+ValidationTests.swift
NetworkClientTests + Errors        → NetworkClientTests+Errors.swift
NetworkClient + ErrorTests         → NetworkClient+ErrorTests.swift

Avoid (redundant "Tests"):

UserManagerTests + ValidationTests → TypeNameTests+CategoryTests.swift
NetworkClientTests + ErrorTests    → TypeNameTests+CategoryTests.swift

Choosing a Pattern for Your Project

Pattern A (Parent has "Tests")

  • Parent: UserManagerTests
  • Children: Basic, Validation, Errors
  • Advantages:
    • Matches test file naming convention
    • Clear that the entire hierarchy is for tests
    • Shorter child struct names

Pattern B (Child has "Tests")

  • Parent: UserManager
  • Children: BasicTests, ValidationTests, ErrorTests
  • Advantages:
    • Parent enum name matches production type exactly
    • Easy to search for all test structs (filter by "Tests" suffix)
    • Clear separation between test enums and production types

Recommendation: Choose one pattern and apply it consistently across your entire test suite. Document your choice in your project's contribution guidelines.

Step-by-Step Tutorial

Creating a New Test Suite

This tutorial demonstrates Pattern A (parent with "Tests" suffix), but the steps apply equally to Pattern B.

Step 1: Choose Your Naming Convention

Decision point: Where should "Tests" appear?

For this tutorial, we'll use Pattern A:

  • Parent enum: TypeNameTests (has "Tests")
  • Child structs: Category1, Category2 (no "Tests")

Alternatively, you could use Pattern B:

  • Parent enum: TypeName (no "Tests")
  • Child structs: Category1Tests, Category2Tests (have "Tests")

Step 2: Create the Parent Enum File

Create a file named after your production type with "Tests" suffix:

// File: TypeNameTests.swift
import Testing

/// Test suite for TypeName functionality.
@Suite("Type Name")
internal enum TypeNameTests {}

Key points:

  • Empty enum (no properties or methods)
  • @Suite attribute with descriptive name
  • internal or public access level as appropriate
  • Import Testing framework

Step 3: Create Extension Files for Each Test Category

Create separate files for each test category:

// File: TypeNameTests+Initialization.swift
import Testing

extension TypeNameTests {
  /// Tests for TypeName initialization scenarios.
  @Suite("Initialization Tests")
  internal struct Initialization {
    // Tests go here
  }
}

Naming the extension file:

  • Format: ParentEnumName+ChildStructName.swift
  • Example: TypeNameTests+Initialization.swift
  • Child struct name has NO "Tests" suffix (parent already has it)

Common test categories:

  • Initialization - Constructor and setup tests
  • Validation - Input validation and constraints
  • Errors - Error handling and edge cases
  • Concurrent - Concurrency and thread safety
  • Integration - Integration with other components
  • Performance - Performance and benchmarking tests

Step 4: Add Test Data as Private Static Properties

extension TypeNameTests {
  @Suite("Initialization Tests")
  internal struct Initialization {
    // MARK: - Test Data Setup

    /// Valid configuration for testing.
    private static let validConfig = Configuration(
      timeout: 30,
      retryCount: 3
    )

    /// Invalid configuration for error testing.
    private static let invalidConfig = Configuration(
      timeout: -1,
      retryCount: 0
    )

    // MARK: - Tests

    @Test("Initialize with valid configuration")
    internal func initializeWithValidConfig() {
      let instance = TypeName(config: Self.validConfig)
      #expect(instance.config.timeout == 30)
    }
  }
}

Best practices for test data:

  • Use private static let for immutable test data
  • Group related data together
  • Use descriptive names (validConfig, not config1)
  • Document complex test data with comments
  • Access via Self.propertyName in test methods

Step 5: Write Test Methods with @Test Attribute

extension TypeNameTests {
  @Suite("Initialization Tests")
  internal struct Initialization {
    // MARK: - Test Data Setup
    private static let validInput = "test-value"

    // MARK: - Basic Initialization

    @Test("Initialize with valid input")
    internal func initializeWithValidInput() {
      let instance = TypeName(input: Self.validInput)
      #expect(instance.input == Self.validInput)
      #expect(instance.isValid)
    }

    @Test("Initialize with default configuration")
    internal func initializeWithDefaults() {
      let instance = TypeName()
      #expect(instance.config != nil)
    }

    // MARK: - Error Cases

    @Test("Initialize with empty input throws error")
    internal func initializeWithEmptyInput() {
      #expect(throws: ValidationError.self) {
        _ = try TypeName(input: "")
      }
    }

    // MARK: - Async Initialization

    @Test("Initialize with async validation")
    internal func initializeWithAsyncValidation() async throws {
      let instance = TypeName(input: Self.validInput)
      let isValid = await instance.validate()
      #expect(isValid)
    }
  }
}

Test method guidelines:

  • Use descriptive names matching the @Test description
  • Use #expect() for assertions
  • Use async for async operations
  • Use throws when testing error conditions
  • Group related tests with // MARK: sections

Step 6: Add Test Helpers if Needed

If you need helper methods for testing, create a separate file:

// File: TypeName+TestHelpers.swift

extension TypeName {
  /// Simplified async validation for testing.
  internal func testValidate() async -> Bool {
    do {
      try await validate()
      return true
    } catch {
      return false
    }
  }

  /// Create a test instance with default values.
  internal static func testInstance(input: String = "test-input") -> TypeName {
    TypeName(input: input)
  }
}

When to create helpers:

  • Async wrapper methods for easier testing
  • Simplified factory methods for test instances
  • Validation helpers used across multiple test files
  • Complex setup that's repeated in many tests

Where to place them:

  • File: TypeName+TestHelpers.swift (production type name, not test enum)
  • Extension on the production type, not the test enum
  • Use internal access level (visible to tests)

Common Pitfalls to Avoid

Using "Tests" in both parent and child:

// DON'T DO THIS
internal enum TypeNameTests {}
extension TypeNameTests {
  struct CategoryTests { ... }  // ❌ Redundant "Tests"
}

Making test data mutable or non-static:

// DON'T DO THIS
private var testValue = "..."  // ❌ Mutable, instance property

// DO THIS
private static let testValue = "..."  // ✅ Immutable, static

Using XCTest patterns:

// DON'T DO THIS
XCTAssertEqual(a, b)  // ❌ XCTest API

// DO THIS
#expect(a == b)  // ✅ Swift Testing API

Mismatching file names and type names:

// File: TypeNameTests+CategoryTests.swift  ❌ Name suggests both have "Tests"
extension TypeNameTests {
  struct Category { ... }  // Actual child doesn't have "Tests"
}

// File: TypeNameTests+Category.swift  ✅ Matches actual names
extension TypeNameTests {
  struct Category { ... }
}

File Organization Conventions

Naming Patterns

CRITICAL RULE: The "Tests" suffix should appear in EITHER the parent enum OR the nested struct, but NEVER in both.

Option 1: Parent Has "Tests"

// File: TypeNameTests.swift
@Suite("Type Name")
internal enum TypeNameTests {}

// File: TypeNameTests+Category.swift
extension TypeNameTests {
  @Suite("Category Tests")
  internal struct Category { ... }
}

File naming:

  • Parent: TypeNameTests.swift
  • Extensions: TypeNameTests+Category.swift, TypeNameTests+Validation.swift
  • Helpers: TypeName+TestHelpers.swift (note: production type name)

Option 2: Parent Without "Tests"

// File: TypeName.swift
@Suite("Type Name")
internal enum TypeName {}

// File: TypeName+CategoryTests.swift
extension TypeName {
  @Suite("Category Tests")
  internal struct CategoryTests { ... }
}

File naming:

  • Parent: TypeName.swift (matches production type exactly)
  • Extensions: TypeName+CategoryTests.swift, TypeName+ValidationTests.swift
  • Helpers: TypeName+TestHelpers.swift

What to Avoid

Redundant "Tests" suffix:

// DON'T DO THIS
internal enum TypeNameTests {}
extension TypeNameTests {
  struct CategoryTests { ... }  // "Tests" appears in both
}

// File would be: TypeNameTests+CategoryTests.swift  ❌ Redundant

Directory Structure Examples

Example 1: Parent Has "Tests"

Tests/MyLibraryTests/
└── Feature/
    ├── TypeNameTests.swift                # Parent enum: TypeNameTests {}
    ├── TypeNameTests+Initialization.swift # Nested struct: Initialization
    ├── TypeNameTests+Validation.swift     # Nested struct: Validation
    ├── TypeNameTests+Errors.swift         # Nested struct: Errors
    ├── TypeNameTests+Concurrent.swift     # Nested struct: Concurrent
    └── TypeName+TestHelpers.swift         # Helpers on TypeName (production type)

Example 2: Parent Without "Tests"

Tests/MyLibraryTests/
└── Feature/
    ├── TypeName.swift                     # Parent enum: TypeName {}
    ├── TypeName+InitializationTests.swift # Nested struct: InitializationTests
    ├── TypeName+ValidationTests.swift     # Nested struct: ValidationTests
    ├── TypeName+ErrorTests.swift          # Nested struct: ErrorTests
    ├── TypeName+ConcurrentTests.swift     # Nested struct: ConcurrentTests
    └── TypeName+TestHelpers.swift         # Helpers on TypeName

Example 3: Simple Pattern (No Hierarchy)

Tests/MyLibraryTests/
└── Utils/
    └── FieldValueTests.swift              # Single struct: FieldValueTests

When to use simple pattern:

  • Small, focused types with < 10 tests
  • Utilities without complex behavior categories
  • Tests that don't need multiple categories

Test Data Management

Pattern: Private Static Properties

Test data should be defined as private static properties within each suite struct:

@Suite("Validation Tests")
internal struct Validation {
  // MARK: - Test Data Setup

  /// Valid email address for testing.
  private static let validEmail = "user@example.com"

  /// Email address with invalid format.
  private static let invalidEmail = "not-an-email"

  /// Test user configuration.
  private static let testUserConfig = UserConfig(
    name: "Test User",
    email: validEmail,
    preferences: ["theme": "dark"]
  )

  // MARK: - Email Validation Tests

  @Test("Validate email with correct format")
  internal func validateCorrectFormat() {
    let result = EmailValidator.validate(Self.validEmail)
    #expect(result.isValid)
  }

  @Test("Reject email with invalid format")
  internal func rejectInvalidFormat() {
    let result = EmailValidator.validate(Self.invalidEmail)
    #expect(!result.isValid)
  }
}

Best Practices for Test Data

Do:

  • ✅ Use descriptive names that explain the data's purpose
  • ✅ Group related data together under the same // MARK: section
  • ✅ Use private static let for immutable data
  • ✅ Document complex or non-obvious test data with comments
  • ✅ Keep test data close to the tests that use it
  • ✅ Access via Self.propertyName in test methods

Don't:

  • ❌ Use vague names like data1, config2
  • ❌ Make test data mutable (var instead of let)
  • ❌ Use instance properties (non-static)
  • ❌ Share mutable state between tests
  • ❌ Define test data outside the suite struct

Organizing Test Data

@Suite("Integration Tests")
internal struct Integration {
  // MARK: - Test Data Setup

  // MARK: User Data
  private static let adminUser = User(role: .admin)
  private static let regularUser = User(role: .user)
  private static let guestUser = User(role: .guest)

  // MARK: Configurations
  private static let defaultConfig = Configuration(...)
  private static let customConfig = Configuration(...)

  // MARK: Mock Data
  private static let mockResponse = HTTPResponse(...)
  private static let mockError = NetworkError.timeout

  // MARK: - Tests
  // ...
}

Test Helpers Pattern

When to Create Helpers

Create test helper methods when you need:

  1. Async wrapper methods for easier testing
  2. Simplified interfaces for test-specific operations
  3. Factory methods to create test instances
  4. Validation helpers used across multiple tests
  5. Setup/teardown helpers for complex test scenarios

Where to Place Helpers

Always extend the production type, not the test enum:

// File: TypeName+TestHelpers.swift
import Testing

extension TypeName {
  /// Simplified async validation for testing.
  internal func testValidate() async -> Bool {
    do {
      try await validate()
      return true
    } catch {
      return false
    }
  }

  /// Create a test instance with default values.
  internal static func testInstance(
    value: String = "test-value",
    config: Configuration = .default
  ) -> TypeName {
    TypeName(value: value, config: config)
  }
}

Helper Method Examples

Async Wrapper Helpers

Simplify async operations for easier testing:

extension NetworkClient {
  /// Test helper: Fetch data and return success status.
  internal func testFetch() async -> Bool {
    do {
      _ = try await fetch()
      return true
    } catch {
      return false
    }
  }

  /// Test helper: Validate response with simple boolean result.
  internal func testValidate(_ response: Response) async -> Bool {
    do {
      try await validate(response)
      return true
    } catch {
      return false
    }
  }
}

Factory Helpers

Create test instances with sensible defaults:

extension Configuration {
  /// Create a test configuration with default values.
  internal static var testing: Configuration {
    Configuration(
      timeout: 30,
      retryCount: 3,
      cacheEnabled: true
    )
  }

  /// Create a minimal configuration for unit tests.
  internal static func minimal() -> Configuration {
    Configuration(
      timeout: 10,
      retryCount: 1,
      cacheEnabled: false
    )
  }
}

Validation Helpers

Common validation operations for tests:

extension DataStore {
  /// Test helper: Check if store contains an item.
  internal func testContains(id: String) async -> Bool {
    do {
      let item = try await retrieve(id: id)
      return item != nil
    } catch {
      return false
    }
  }

  /// Test helper: Get count of stored items.
  internal func testCount() async -> Int {
    (try? await allItems().count) ?? 0
  }
}

Best Practices for Helpers

Do:

  • ✅ Prefix helper methods with test to indicate test-only code
  • ✅ Use internal access level (visible to test targets)
  • ✅ Keep helpers simple and focused on testing needs
  • ✅ Document the helper's purpose
  • ✅ Place in separate +TestHelpers.swift file

Don't:

  • ❌ Add helpers to production codebase (keep in test-only extensions)
  • ❌ Make helpers public unless necessary
  • ❌ Create helpers that duplicate production functionality
  • ❌ Add complex business logic to helpers

Code Organization Within Test Files

MARK Sections for Logical Grouping

Use // MARK: comments to organize tests into logical sections:

extension TypeNameTests {
  @Suite("Comprehensive Tests")
  internal struct Comprehensive {
    // MARK: - Test Data Setup

    private static let validInput = "..."
    private static let testConfig = Configuration(...)

    // MARK: - Initialization Tests

    @Test("Initialize with valid parameters")
    internal func initializeWithValidParameters() { ... }

    @Test("Initialize with default configuration")
    internal func initializeWithDefaultConfiguration() { ... }

    // MARK: - Functional Tests

    @Test("Perform basic operation")
    internal func performBasicOperation() async throws { ... }

    @Test("Handle complex workflow")
    internal func handleComplexWorkflow() async throws { ... }

    // MARK: - Error Handling Tests

    @Test("Throws error for invalid input")
    internal func throwsErrorForInvalidInput() { ... }

    @Test("Recovers from failure")
    internal func recoversFromFailure() async { ... }

    // MARK: - Concurrent Tests

    @Test("Handles concurrent access safely")
    internal func handlesConcurrentAccessSafely() async { ... }

    @Test("Maintains consistency under load")
    internal func maintainsConsistencyUnderLoad() async { ... }

    // MARK: - Edge Cases

    @Test("Handles empty input")
    internal func handlesEmptyInput() { ... }

    @Test("Handles maximum values")
    internal func handlesMaximumValues() { ... }
  }
}

Common MARK Sections

Use these standard sections to organize tests consistently:

  1. // MARK: - Test Data Setup

    • Private static test data
    • Mock objects and fixtures
    • Test configurations
  2. // MARK: - Initialization Tests

    • Constructor tests
    • Default value tests
    • Parameter validation
  3. // MARK: - Functional Tests or // MARK: - Basic Tests

    • Core functionality
    • Happy path scenarios
    • Main use cases
  4. // MARK: - Error Handling Tests

    • Invalid input tests
    • Error throwing tests
    • Recovery scenarios
  5. // MARK: - Concurrent Tests

    • Thread safety tests
    • Race condition tests
    • Actor isolation tests
  6. // MARK: - Edge Cases

    • Boundary values
    • Empty/nil inputs
    • Maximum/minimum values
  7. // MARK: - Performance Tests

    • Speed benchmarks
    • Memory usage tests
    • Scalability tests
  8. // MARK: - Integration Tests

    • Component interaction tests
    • System-level tests
    • End-to-end workflows

Sub-sections Within Main Sections

For complex test categories, use sub-sections:

// MARK: - Functional Tests

// MARK: Basic Operations
@Test("Perform simple query")
internal func performSimpleQuery() { ... }

// MARK: Complex Operations
@Test("Perform multi-step workflow")
internal func performMultiStepWorkflow() { ... }

// MARK: Batch Operations
@Test("Process batch request")
internal func processBatchRequest() { ... }

Ordering Guidelines

Recommended order:

  1. Test Data Setup
  2. Initialization Tests
  3. Functional/Basic Tests
  4. Error Handling Tests
  5. Concurrent Tests
  6. Edge Cases
  7. Performance Tests
  8. Integration Tests

Rationale:

  • Setup first (test data)
  • Basic functionality before advanced
  • Error cases after happy paths
  • Performance and integration toward the end

Complete Examples

Example 1: Simple Pattern (Single Level)

// File: FieldValueTests.swift
import Testing

/// Tests for FieldValue type conversions and operations.
@Suite("Field Value")
internal struct FieldValueTests {
  // MARK: - Test Data Setup

  private static let testString = "Hello, World"
  private static let testNumber = 42
  private static let testDate = Date()

  // MARK: - String Value Tests

  @Test("Create field value from string")
  internal func createFromString() {
    let value = FieldValue.string(Self.testString)
    #expect(value.stringValue == Self.testString)
  }

  @Test("Convert field value to string")
  internal func convertToString() {
    let value = FieldValue.string(Self.testString)
    let result = value.stringValue
    #expect(result == Self.testString)
  }

  // MARK: - Number Value Tests

  @Test("Create field value from integer")
  internal func createFromInteger() {
    let value = FieldValue.int(Self.testNumber)
    #expect(value.intValue == Self.testNumber)
  }

  // MARK: - Date Value Tests

  @Test("Create field value from date")
  internal func createFromDate() {
    let value = FieldValue.date(Self.testDate)
    #expect(value.dateValue == Self.testDate)
  }
}

When to use:

  • Small types with focused functionality
  • Utilities without complex behavior categories
  • Types with < 10 tests total
  • Self-contained functionality

Example 2: Extension Pattern (Two Level) - Parent With "Tests"

// File: UserManagerTests.swift
import Testing

/// Test suite for UserManager functionality.
@Suite("User Manager")
internal enum UserManagerTests {}
// File: UserManagerTests+Basic.swift
import Testing

extension UserManagerTests {
  /// Basic UserManager functionality tests.
  @Suite("Basic Tests")
  internal struct Basic {
    // MARK: - Test Data Setup

    private static let testUserID = "user123"
    private static let testUsername = "testuser"

    private static let testConfig = UserConfig(
      autoSave: true,
      cacheEnabled: true
    )

    // MARK: - Initialization Tests

    @Test("Initialize with valid configuration")
    internal func initializeWithValidConfiguration() {
      let manager = UserManager(config: Self.testConfig)
      #expect(manager.config.autoSave)
      #expect(manager.config.cacheEnabled)
    }

    @Test("Initialize with default configuration")
    internal func initializeWithDefaultConfiguration() {
      let manager = UserManager()
      #expect(manager.config != nil)
    }

    // MARK: - User Fetching Tests

    @Test("Fetch user successfully")
    internal func fetchUserSuccessfully() async {
      let manager = UserManager(config: Self.testConfig)
      let user = await manager.fetchUser(id: Self.testUserID)
      #expect(user != nil)
    }
  }
}
// File: UserManagerTests+Validation.swift
import Testing

extension UserManagerTests {
  /// UserManager validation tests.
  @Suite("Validation Tests")
  internal struct Validation {
    // MARK: - Test Data Setup

    private static let validUserID = "user123"
    private static let emptyUserID = ""
    private static let tooLongUserID = String(repeating: "a", count: 1000)

    // MARK: - User ID Validation

    @Test("Validate user ID with correct format")
    internal func validateCorrectFormat() {
      let isValid = UserManager.validateUserID(Self.validUserID)
      #expect(isValid)
    }

    @Test("Reject empty user ID")
    internal func rejectEmptyUserID() {
      let isValid = UserManager.validateUserID(Self.emptyUserID)
      #expect(!isValid)
    }

    @Test("Reject user ID that is too long")
    internal func rejectTooLongUserID() {
      let isValid = UserManager.validateUserID(Self.tooLongUserID)
      #expect(!isValid)
    }
  }
}
// File: UserManager+TestHelpers.swift
import Testing

extension UserManager {
  /// Test helper: Fetch user with simple boolean result.
  internal func testFetchUser(id: String) async -> Bool {
    do {
      let user = try await fetchUser(id: id)
      return user != nil
    } catch {
      return false
    }
  }

  /// Create a test instance with default values.
  internal static func testInstance() -> UserManager {
    let config = UserConfig(autoSave: false, cacheEnabled: false)
    return UserManager(config: config)
  }
}

When to use:

  • Production types with multiple test categories
  • Tests that will grow over time
  • When different categories need different test data
  • Most common pattern for complex types

Example 3: Extension Pattern - Child With "Tests"

Alternative structure:

// File: UserManager.swift
import Testing

@Suite("User Manager")
internal enum UserManager {}
// File: UserManager+BasicTests.swift
import Testing

extension UserManager {
  @Suite("Basic Tests")
  internal struct BasicTests {
    // Tests here
  }
}
// File: UserManager+ValidationTests.swift
import Testing

extension UserManager {
  @Suite("Validation Tests")
  internal struct ValidationTests {
    // Tests here
  }
}

Note: This pattern has "Tests" in the child struct instead of the parent enum. Both patterns are valid - choose one and use it consistently.

Best Practices

Do

Use empty enums as parent containers

@Suite("Parent Suite")
internal enum ParentTests {}

Include "Tests" suffix in EITHER parent OR child, never both

// Option 1: Parent has "Tests"
internal enum TypeNameTests {}
extension TypeNameTests {
  struct Category { ... }  // No "Tests"
}

// Option 2: Child has "Tests"
internal enum TypeName {}
extension TypeName {
  struct CategoryTests { ... }  // Has "Tests"
}

One category per extension file

// File: TypeNameTests+Initialization.swift - Only Initialization suite
// File: TypeNameTests+Validation.swift - Only Validation suite

Private static test data in suite structs

internal struct Category {
  private static let testData = "..."
}

Descriptive suite and test names

@Suite("Input Validation and Format Checking")
@Test("Reject input with invalid format")

Async/await for all async operations

@Test("Fetch data asynchronously")
internal func fetchDataAsync() async throws {
  let result = try await fetchData()
  #expect(result != nil)
}

#expect() for assertions

#expect(value == expected)
#expect(throws: ErrorType.self) { code }

MARK sections for organization

// MARK: - Test Data Setup
// MARK: - Initialization Tests
// MARK: - Error Handling Tests

Document suites and tests with comments

/// Tests for error handling scenarios.
@Suite("Error Handling")
internal struct ErrorHandling { ... }

Match file names to actual type names

// File: TypeNameTests+Category.swift
extension TypeNameTests {
  struct Category { ... }  // ✅ Matches file name
}

Don't

Use "Tests" in both parent enum and child struct

// DON'T DO THIS
internal enum TypeNameTests {}
extension TypeNameTests {
  struct CategoryTests { ... }  // ❌ Redundant "Tests"
}

Put multiple suite structs in one file

// DON'T DO THIS - Split into separate files
extension TypeNameTests {
  struct Category1 { ... }
  struct Category2 { ... }  // ❌ Should be in separate file
}

Use classes for test suites

// DON'T DO THIS
@Suite("Tests")
internal class MyTests { ... }  // ❌ Use struct

// DO THIS
@Suite("Tests")
internal struct MyTests { ... }  // ✅ Use struct

Share mutable state between tests

// DON'T DO THIS
internal struct Tests {
  private var counter = 0  // ❌ Mutable state

  @Test func test1() { counter += 1 }
  @Test func test2() { counter += 1 }  // ❌ Tests depend on order
}

Create deeply nested hierarchies (max 2-3 levels)

// DON'T DO THIS
internal enum Level1 {}
extension Level1 {
  enum Level2 {}
}
extension Level1.Level2 {
  enum Level3 {}  // ❌ Too deeply nested
}

Use XCTest patterns

// DON'T DO THIS
XCTAssertEqual(a, b)  // ❌ XCTest API
XCTAssertTrue(condition)  // ❌ XCTest API

// DO THIS
#expect(a == b)  // ✅ Swift Testing API
#expect(condition)  // ✅ Swift Testing API

Mismatch file names and type names

// File: TypeNameTests+CategoryTests.swift  ❌ Suggests both have "Tests"
extension TypeNameTests {
  struct Category { ... }  // Actual child name doesn't match file name
}

// File: TypeNameTests+Category.swift  ✅ Matches actual names
extension TypeNameTests {
  struct Category { ... }
}

Swift Testing Features

Suite Attributes

Basic suite:

@Suite("Description")
internal struct MyTests { ... }

Conditional suite:

@Suite("Linux-only tests", .enabled(if: os(Linux)))
internal struct LinuxTests { ... }

Tagged suite:

@Suite("Slow integration tests", .tags(.slow))
internal struct IntegrationTests { ... }

Multiple attributes:

@Suite(
  "Platform-specific performance tests",
  .enabled(if: !DEBUG),
  .tags(.performance)
)
internal struct PerformanceTests { ... }

Test Attributes

Basic test:

@Test("Test description")
internal func testSomething() { ... }

Conditional test:

@Test("macOS-only test", .enabled(if: os(macOS)))
internal func testMacOSFeature() { ... }

Tagged test:

@Test("Slow network test", .tags(.slow, .network))
internal func testNetworkLatency() async { ... }

Parameterized test:

@Test("Validate multiple inputs", arguments: [
  "input1",
  "input2",
  "input3"
])
internal func validateInput(input: String) {
  #expect(Validator.isValid(input))
}

Parameterized test with multiple arguments:

@Test(
  "Test with combinations",
  arguments: ["a", "b", "c"],
  [1, 2, 3]
)
internal func testCombinations(string: String, number: Int) {
  #expect(!string.isEmpty)
  #expect(number > 0)
}

Assertions

Basic expectation:

#expect(condition)

Equality expectation:

#expect(value == expected)
#expect(value != unexpected)

Boolean expectation:

#expect(result)
#expect(!failure)

Optional expectation:

let value = try #require(optionalValue)  // Unwrap or fail test
#expect(value != nil)

Error throwing expectation:

#expect(throws: ErrorType.self) {
  try throwingFunction()
}

#expect(throws: (any Error).self) {
  try anyErrorFunction()
}

Error NOT throwing expectation:

#expect(throws: Never.self) {
  try nonThrowingFunction()
}

Collection expectations:

#expect(collection.contains(item))
#expect(collection.isEmpty)
#expect(collection.count == expected)

Custom messages:

#expect(value == expected, "Value should equal expected")

Issue Recording

Record custom issue:

if somethingWrong {
  Issue.record("Something went wrong: \(details)")
}

Record issue with source location:

Issue.record(
  "Unexpected state",
  sourceLocation: SourceLocation(
    fileID: #fileID,
    line: #line
  )
)

Async Testing

Basic async test:

@Test("Async operation")
internal func testAsync() async {
  let result = await performAsync()
  #expect(result != nil)
}

Async throwing test:

@Test("Async throwing operation")
internal func testAsyncThrowing() async throws {
  let result = try await fetchData()
  #expect(result.count > 0)
}

Concurrent async tests:

@Test("Multiple async operations")
internal func testConcurrent() async {
  async let result1 = operation1()
  async let result2 = operation2()

  let (r1, r2) = await (result1, result2)
  #expect(r1 != nil && r2 != nil)
}

Custom Tags

Define custom tags for filtering tests:

extension Tag {
  @Tag static var slow: Self
  @Tag static var network: Self
  @Tag static var integration: Self
  @Tag static var performance: Self
}

@Suite("Network Tests", .tags(.slow, .network))
internal struct NetworkTests { ... }

Run tests with specific tags:

swift test --filter tag:network
swift test --filter tag:slow --skip tag:integration

Migration Guide

Converting XCTest to Swift Testing

XCTest Swift Testing
class FooTests: XCTestCase @Suite("Foo") struct FooTests
func testSomething() @Test("Something") func something()
XCTAssertEqual(a, b) #expect(a == b)
XCTAssertNotEqual(a, b) #expect(a != b)
XCTAssertTrue(condition) #expect(condition)
XCTAssertFalse(condition) #expect(!condition)
XCTAssertNil(value) #expect(value == nil)
XCTAssertNotNil(value) #expect(value != nil)
XCTAssertThrowsError #expect(throws:)
XCTUnwrap(optional) try #require(optional)
setUp()/tearDown() init()/deinit or test-level setup
setUpWithError()/tearDownWithError() init() throws/deinit
XCTFail("message") Issue.record("message")
addTeardownBlock { } Use defer in test method

Migration Steps

  1. Change class to struct:
// Before (XCTest)
class MyTests: XCTestCase { }

// After (Swift Testing)
@Suite("My Tests")
struct MyTests { }
  1. Add @Test attribute:
// Before (XCTest)
func testFeature() { }

// After (Swift Testing)
@Test("Feature works correctly")
func testFeature() { }
  1. Replace assertions:
// Before (XCTest)
XCTAssertEqual(value, expected)

// After (Swift Testing)
#expect(value == expected)
  1. Handle setup/teardown:
// Before (XCTest)
override func setUp() {
  super.setUp()
  // setup code
}

// After (Swift Testing)
init() {
  // setup code
}
  1. Convert async tests:
// Before (XCTest)
func testAsync() {
  let expectation = expectation(description: "...")
  Task {
    await doWork()
    expectation.fulfill()
  }
  waitForExpectations(timeout: 5)
}

// After (Swift Testing)
@Test("Async work")
func testAsync() async {
  await doWork()
}
  1. Replace test expectations with async/await:
// Before (XCTest)
func testNetworkCall() {
  let exp = expectation(description: "Network call")
  networkManager.fetchData { result in
    XCTAssertNotNil(result)
    exp.fulfill()
  }
  wait(for: [exp], timeout: 5.0)
}

// After (Swift Testing)
@Test("Network call")
func testNetworkCall() async throws {
  let result = try await networkManager.fetchData()
  #expect(result != nil)
}

Quick Reference Card

Naming Convention

✅ Correct patterns:
TypeNameTests + Category          → TypeNameTests+Category.swift
TypeName + CategoryTests          → TypeName+CategoryTests.swift

❌ Avoid:
TypeNameTests + CategoryTests     → Redundant "Tests"

File Structure Template (Pattern A: Parent Has "Tests")

// File: TypeNameTests.swift
import Testing

/// Test suite for TypeName functionality.
@Suite("Type Name")
internal enum TypeNameTests {}
// File: TypeNameTests+Category.swift
import Testing

extension TypeNameTests {
  /// Tests for TypeName category functionality.
  @Suite("Category Description")
  internal struct Category {
    // MARK: - Test Data Setup

    /// Test data description.
    private static let testData = ...

    // MARK: - Tests

    @Test("Test description")
    internal func testSomething() {
      let result = performOperation(Self.testData)
      #expect(result == expected)
    }
  }
}
// File: TypeName+TestHelpers.swift
import Testing

extension TypeName {
  /// Test helper description.
  internal func testHelper() async -> Bool {
    do {
      try await operation()
      return true
    } catch {
      return false
    }
  }
}

Common MARK Sections

// MARK: - Test Data Setup
// MARK: - Initialization Tests
// MARK: - Functional Tests
// MARK: - Error Handling Tests
// MARK: - Concurrent Tests
// MARK: - Edge Cases
// MARK: - Performance Tests
// MARK: - Integration Tests

Frequently Used Patterns

Standalone (Simple):

@Suite("Type Name")
internal struct TypeNameTests {
  @Test("Test description")
  internal func testSomething() { ... }
}

Hierarchical (Two Level):

// Parent
@Suite("Type Name")
internal enum TypeNameTests {}

// Child
extension TypeNameTests {
  @Suite("Category")
  internal struct Category { ... }
}

Test Helpers:

// File: TypeName+TestHelpers.swift
extension TypeName {
  internal func testHelper() { ... }
}

Private Static Test Data:

internal struct Category {
  private static let testData = ...

  @Test("Use test data")
  internal func testWithData() {
    let data = Self.testData
  }
}

Swift Testing Cheat Sheet

Assertions:

#expect(condition)                    // Basic
#expect(value == expected)            // Equality
#expect(throws: Error.self) { code }  // Throws
let value = try #require(optional)    // Unwrap

Suite Attributes:

@Suite("Name")                        // Basic
@Suite("Name", .enabled(if: cond))    // Conditional
@Suite("Name", .tags(.slow))          // Tagged

Test Attributes:

@Test("Name")                         // Basic
@Test("Name", .enabled(if: cond))     // Conditional
@Test("Name", arguments: [...])       // Parameterized

Async Testing:

@Test("Async test")
internal func testAsync() async {
  let result = await operation()
  #expect(result != nil)
}

Real-World Example: MistKit

This organizational pattern is used in the MistKit project for organizing CloudKit Web Services tests. Here's how MistKit applies these principles:

Chosen convention: Parent has "Tests" suffix (Pattern A)

Example hierarchy:

Tests/MistKitTests/
├── Authentication/
│   └── WebAuth/
│       ├── WebAuthTokenManagerTests.swift           # Parent enum
│       ├── WebAuthTokenManagerTests+Basic.swift     # Basic tests
│       ├── WebAuthTokenManagerTests+Validation.swift # Validation tests
│       └── WebAuthTokenManager+TestHelpers.swift    # Test helpers
├── Core/
│   └── FieldValue/
│       └── FieldValueTests.swift                    # Simple pattern
└── Storage/
    └── InMemory/
        └── InMemoryTokenStorage/
            ├── InMemoryTokenStorageTests.swift      # Parent enum
            └── InMemoryTokenStorageTests+Expiration.swift # Expiration tests

This demonstrates how the pattern scales from simple utilities (FieldValue) to complex managers (WebAuthTokenManager) in a real Swift package.

Conclusion

This hierarchical test organization pattern provides:

  • Clear structure: Logical grouping with enums and structs
  • Scalability: Easy to add new test categories
  • Consistency: Standardized naming and file organization
  • Discoverability: Predictable location for tests
  • Maintainability: Isolated test categories for focused changes

Key takeaways:

  1. Never use "Tests" in both parent and child - Choose one location
  2. One category per extension file - Keep files focused
  3. Private static test data - Immutable data in suite structs
  4. Use MARK sections - Organize tests within files
  5. Test helpers in separate files - Extend production types

Choose a pattern for your project:

  • Pattern A: Parent has "Tests" (e.g., UserManagerTests+Validation)
  • Pattern B: Child has "Tests" (e.g., UserManager+ValidationTests)

Both patterns are equally valid. The important thing is to choose one and apply it consistently throughout your test suite. Document your choice in your project's contribution guidelines to ensure all contributors follow the same convention.

Swift Testing: Platform and OS Version Compatibility

A practical guide to conditionally enabling/disabling tests based on platform availability and OS versions.

Overview

Swift Testing provides .enabled(if:) and .disabled(if:) traits that allow you to conditionally run tests based on runtime conditions. This is especially useful for handling platform-specific features, OS version requirements, and API availability.

The Pattern: Platform Helper Utilities

Create a helper enum to centralize platform checks:

import Foundation
import Testing

/// Platform detection utilities for testing
internal enum Platform {
  /// Returns true if the current platform supports the required crypto functionality
  /// Requires macOS 11.0+, iOS 14.0+, tvOS 14.0+, or watchOS 7.0+
  internal static let isCryptoAvailable: Bool = {
    if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) {
      return true
    }
    return false
  }()
}

This pattern:

  • Centralizes platform checks in one location
  • Uses #available to check OS version requirements
  • Provides compile-time constant that can be used with traits
  • Makes tests self-documenting about their requirements

Applying Traits to Test Suites

Use .enabled(if:) on entire test suites to skip all tests when requirements aren't met:

import Testing
@testable import MistKit

@Suite("QueryFilter Tests", .enabled(if: Platform.isCryptoAvailable))
struct QueryFilterTests {
  // All tests in this suite require crypto functionality

  @Test func basicFilterCreation() async throws {
    // Test implementation
  }

  @Test func complexFilterChaining() async throws {
    // Test implementation
  }
}

Benefits:

  • Single trait applies to all tests in the suite
  • Clear documentation at the suite level
  • Reduces redundant trait declarations on individual tests

Applying Traits to Individual Tests

For more granular control, apply traits to specific tests:

extension ServerToServerAuthManagerTests {
  @Suite("Server-to-Server Auth Manager Initialization", .enabled(if: Platform.isCryptoAvailable))
  internal struct InitializationTests {

    @Test("Initialization with private key callback", .enabled(if: Platform.isCryptoAvailable))
    internal func initializationWithPrivateKeyCallback() async throws {
      guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else {
        Issue.record("ServerToServerAuthManager is not available on this operating system.")
        return
      }

      let manager = try ServerToServerAuthManager(
        keyID: "test-key-id",
        privateKeyCallback: generateTestPrivateKey()
      )

      #expect(manager.keyID == "test-key-id")
    }
  }
}

Defensive Pattern: Double-Checking Availability

Even with .enabled(if:) traits, it's good practice to add #available guards inside test bodies:

@Test("Private key signing validation", .enabled(if: Platform.isCryptoAvailable))
internal func privateKeySigningValidation() async throws {
  // Defensive check - should never hit this, but provides clarity
  guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else {
    Issue.record("Crypto functionality not available on this platform.")
    return
  }

  // Test implementation using platform-specific APIs
  let privateKey = P256.Signing.PrivateKey()
  // ...
}

Why this pattern?

  • Explicit documentation of OS requirements
  • Compiler enforcement of API availability
  • Clear error messages if trait logic fails
  • Makes code reviewers aware of platform constraints

Nested Suites with Traits

Traits can be applied at multiple levels:

@Suite("Authentication Middleware", .enabled(if: Platform.isCryptoAvailable))
struct AuthenticationMiddlewareTests {

  @Suite("Server-to-Server Tests", .enabled(if: Platform.isCryptoAvailable))
  struct ServerToServerTests {

    @Test(
      "Middleware intercepts and adds authentication",
      .enabled(if: Platform.isCryptoAvailable)
    )
    func middlewareInterceptsRequest() async throws {
      // Test implementation
    }
  }

  @Suite("API Token Tests", .enabled(if: Platform.isCryptoAvailable))
  struct APITokenTests {
    // Tests here
  }
}

Note: All conditions must pass for a test to run. If the outer suite is disabled, inner tests won't run regardless of their own traits.

Common Platform Checks

Here are examples of platform checks you might add to your Platform helper:

internal enum Platform {
  /// Crypto functionality (macOS 11.0+, iOS 14.0+, tvOS 14.0+, watchOS 7.0+)
  internal static let isCryptoAvailable: Bool = {
    if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) {
      return true
    }
    return false
  }()

  /// SwiftUI functionality (macOS 10.15+, iOS 13.0+, tvOS 13.0+, watchOS 6.0+)
  internal static let isSwiftUIAvailable: Bool = {
    if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) {
      return true
    }
    return false
  }()

  /// Async/await support (macOS 12.0+, iOS 15.0+, tvOS 15.0+, watchOS 8.0+)
  internal static let isAsyncAwaitAvailable: Bool = {
    if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) {
      return true
    }
    return false
  }()

  /// Current platform
  #if os(macOS)
  internal static let isMacOS = true
  #else
  internal static let isMacOS = false
  #endif

  #if os(iOS)
  internal static let isiOS = true
  #else
  internal static let isiOS = false
  #endif
}

Disabling Tests

Use .disabled() when you need to temporarily skip tests:

@Test("Feature under development", .disabled("Waiting for API changes"))
func incompleteFeature() async throws {
  // Test implementation
}

Or disable conditionally:

@Test(
  "iOS-specific feature",
  .disabled(if: !Platform.isiOS, "This feature only works on iOS")
)
func iOSOnlyFeature() async throws {
  // Test implementation
}

Combining Multiple Conditions

You can apply multiple traits to a single test:

@Test(
  "Advanced crypto feature",
  .enabled(if: Platform.isCryptoAvailable),
  .enabled(if: Platform.isMacOS),
  .timeLimit(.minutes(5))
)
func advancedCryptoFeature() async throws {
  // Test requires crypto AND macOS AND must complete in 5 minutes
}

Important: All conditions must be satisfied for the test to run. The first failing condition is reported as the skip reason.

Real-World Example from MistKit

Here's how MistKit uses this pattern across its test suite:

// Platform.swift - Centralized platform detection
internal enum Platform {
  internal static let isCryptoAvailable: Bool = {
    if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) {
      return true
    }
    return false
  }()
}

// Tests that require crypto functionality
@Suite("CloudKitService Query Operations", .enabled(if: Platform.isCryptoAvailable))
struct CloudKitServiceQueryTests {
  @Test func queryRecordsWithFilter() async throws { /* ... */ }
  @Test func queryRecordsWithSort() async throws { /* ... */ }
}

@Suite("Network Error", .enabled(if: Platform.isCryptoAvailable))
struct NetworkErrorTests {
  @Suite("Recovery Tests", .enabled(if: Platform.isCryptoAvailable))
  struct RecoveryTests {
    @Test func recoveryAfterTimeout() async throws { /* ... */ }
  }

  @Suite("Simulation Tests", .enabled(if: Platform.isCryptoAvailable))
  struct SimulationTests {
    @Test func simulateNetworkFailure() async throws { /* ... */ }
  }
}

Best Practices

  1. Centralize platform checks - Use a Platform helper enum rather than inline #available checks in traits
  2. Document requirements - Include OS version requirements in comments
  3. Be defensive - Add #available guards inside test bodies even when using traits
  4. Suite-level traits - Apply traits to entire suites when all tests share requirements
  5. Clear skip reasons - Provide descriptive comments in .disabled() traits
  6. Test your traits - Verify tests are actually skipped on unsupported platforms

Related Swift Testing Features

  • .bug(id:) - Associate tests with bug tracking IDs
  • .timeLimit() - Set execution time limits
  • .tags() - Categorize tests for selective execution
  • Issue.record() - Record test failures with custom messages

References

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