Swift Package Testing with SPM: Unit Tests, Code Coverage, and Xcode Cloud

Swift Package Testing with SPM: Unit Tests, Code Coverage, and Xcode Cloud

Swift Package Manager is the standard way to distribute reusable Swift code — networking libraries, analytics SDKs, UI components. But SPM packages are often distributed without tests, or with tests that can't run outside the original developer's environment.

This guide covers how to structure testable Swift packages, generate code coverage with swift test, and automate testing with GitHub Actions and Xcode Cloud.

Package Structure for Testing

A well-structured package separates implementation from tests:

MyLibrary/
├── Package.swift
├── Sources/
│   └── MyLibrary/
│       ├── MyLibrary.swift
│       ├── NetworkClient.swift
│       └── DataParser.swift
├── Tests/
│   └── MyLibraryTests/
│       ├── MyLibraryTests.swift
│       ├── NetworkClientTests.swift
│       ├── DataParserTests.swift
│       └── Fixtures/
│           └── sample-response.json
└── README.md

Package.swift Configuration

// Package.swift
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "MyLibrary",
    platforms: [
        .iOS(.v16),
        .macOS(.v13),
        .tvOS(.v16),
        .watchOS(.v9),
    ],
    products: [
        .library(
            name: "MyLibrary",
            targets: ["MyLibrary"]
        ),
    ],
    dependencies: [
        // External dependencies
        .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
    ],
    targets: [
        .target(
            name: "MyLibrary",
            dependencies: [
                .product(name: "Algorithms", package: "swift-algorithms"),
            ],
            swiftSettings: [
                .enableExperimentalFeature("StrictConcurrency"),
            ]
        ),
        .testTarget(
            name: "MyLibraryTests",
            dependencies: [
                "MyLibrary",
                // Test-only dependencies
            ],
            resources: [
                .copy("Fixtures"),  // Include test fixture files
            ]
        ),
    ]
)

Writing Package Unit Tests

Tests in a Swift package work identically to app tests:

// Tests/MyLibraryTests/DataParserTests.swift
import XCTest
@testable import MyLibrary

final class DataParserTests: XCTestCase {
    var parser: DataParser!
    
    override func setUp() {
        super.setUp()
        parser = DataParser()
    }
    
    func testParseValidJSON() throws {
        let json = """
        {"id": "1", "name": "Alice", "score": 95.5}
        """.data(using: .utf8)!
        
        let result = try parser.parse(User.self, from: json)
        
        XCTAssertEqual(result.id, "1")
        XCTAssertEqual(result.name, "Alice")
        XCTAssertEqual(result.score, 95.5, accuracy: 0.001)
    }
    
    func testParseInvalidJSONThrows() {
        let invalidJSON = "not json".data(using: .utf8)!
        
        XCTAssertThrowsError(try parser.parse(User.self, from: invalidJSON)) { error in
            XCTAssertTrue(error is DecodingError)
        }
    }
    
    func testParseFromFixtureFile() throws {
        let url = Bundle.module.url(forResource: "sample-response", withExtension: "json")!
        let data = try Data(contentsOf: url)
        
        let result = try parser.parse(APIResponse.self, from: data)
        
        XCTAssertEqual(result.users.count, 3)
    }
}
Note: Use Bundle.module (not Bundle.main) to access test resources in Swift packages. SPM generates this automatically.

Running Tests from the Command Line

# Run all tests
swift test

# Run tests on a specific platform (macOS)
swift test --platform macOS

# Run a specific test
swift test --filter DataParserTests/testParseValidJSON

# Run with verbose output
swift test -v

# Run in parallel (default behavior in Swift 5.9+)
swift test --parallel

# Run with sanitizers
swift test --sanitize address      # AddressSanitizer
swift test --sanitize thread       # ThreadSanitizer
swift test --sanitize undefined    # UndefinedBehaviorSanitizer

Code Coverage

Generate code coverage reports:

# Enable coverage
swift test --enable-code-coverage

# Coverage data is in .build/debug/codecov/
# Generate human-readable report with llvm-cov:
BINARY_PATH=$(swift build --show-bin-path)/MyLibrary.o
PROFDATA=$(find .build -name "*.profdata" | head -1)

llvm-cov report \
  $BINARY_PATH \
  --instr-profile=$PROFDATA \
  --ignore-filename-regex=".build|Tests"

Example output:

Filename                    Regions   Miss  Cover   Lines   Miss  Cover
Sources/MyLibrary/DataParser.swift   45      5  88.9%     62      3  95.2%
Sources/MyLibrary/NetworkClient.swift  38     12  68.4%     51     15  70.6%
TOTAL                               83     17  79.5%    113     18  84.1%

Export LCOV for tools like Codecov or Coveralls:

llvm-cov export \
  $BINARY_PATH \
  --instr-profile=$PROFDATA \
  --format=lcov \
  --ignore-filename-regex=".build|Tests" > coverage.lcov

Testing Async/Await Code in Packages

// Tests/MyLibraryTests/NetworkClientTests.swift
import XCTest
@testable import MyLibrary

final class NetworkClientTests: XCTestCase {
    func testFetchUserSucceeds() async throws {
        let mockSession = MockURLSession(
            data: loadFixture("user-response.json"),
            response: HTTPURLResponse(url: URL(string: "https://api.example.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
        )
        
        let client = NetworkClient(session: mockSession)
        let user = try await client.fetchUser(id: "user-1")
        
        XCTAssertEqual(user.id, "user-1")
        XCTAssertEqual(user.name, "Alice")
    }
    
    func testFetchUser404Throws() async {
        let mockSession = MockURLSession(
            data: Data(),
            response: HTTPURLResponse(url: URL(string: "https://api.example.com")!, statusCode: 404, httpVersion: nil, headerFields: nil)!
        )
        
        let client = NetworkClient(session: mockSession)
        
        do {
            _ = try await client.fetchUser(id: "missing")
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is NetworkError)
        }
    }
}

GitHub Actions CI

# .github/workflows/test.yml
name: Swift Package Tests

on: [push, pull_request]

jobs:
  test-macos:
    name: Test on macOS
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      
      - name: Select Xcode version
        run: sudo xcode-select -s /Applications/Xcode_16.0.app
      
      - name: Run tests
        run: swift test --enable-code-coverage -v
      
      - name: Generate coverage report
        run: |
          BINARY=$(swift build --show-bin-path)
          PROFDATA=$(find .build -name "*.profdata" | head -1)
          llvm-cov export \
            $BINARY/MyLibraryPackageTests.xctest/Contents/MacOS/MyLibraryPackageTests \
            --instr-profile=$PROFDATA \
            --format=lcov > coverage.lcov
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: coverage.lcov

  test-linux:
    name: Test on Linux
    runs-on: ubuntu-latest
    container:
      image: swift:5.10
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: swift test -v

Xcode Cloud CI

Xcode Cloud is Apple's native CI for App Store apps. For Swift packages used in an app:

  1. Open your app in Xcode → Integrate → Create Workflow
  2. Name it "Test Swift Package"
  3. Configure actions:
    • Test: macOS, iOS Simulator
  4. Add environment variables if needed
# ci_scripts/ci_post_clone.sh
#!/bin/sh
# Runs after Xcode Cloud clones your repo

# Install dependencies if needed
# Xcode Cloud runs `swift package resolve` automatically

For packages without an app (standalone libraries), use GitHub Actions — Xcode Cloud requires an App Store Connect account and is primarily designed for app projects.

Multi-Platform Testing

Swift packages often support multiple platforms. Test on each:

jobs:
  test:
    strategy:
      matrix:
        destination:
          - "platform=macOS"
          - "platform=iOS Simulator,name=iPhone 15,OS=17.0"
          - "platform=tvOS Simulator,name=Apple TV,OS=17.0"
    
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - run: |
          xcodebuild test \
            -scheme MyLibrary \
            -destination "${{ matrix.destination }}"

Documenting Your Tests

Add a test plan file (.xctestplan) to control which tests run in Xcode:

{
  "configurations": [
    {
      "id": "all-tests",
      "name": "All Tests",
      "options": {
        "codeCoverageTargets": ["MyLibrary"],
        "maximumTestRepetitions": 1
      }
    }
  ],
  "defaultOptions": {
    "testTimeoutsEnabled": true,
    "defaultTestExecutionTimeAllowance": 60
  },
  "testTargets": [
    {
      "target": {
        "containerPath": "container:MyLibrary.xcodeproj",
        "identifier": "MyLibraryTests",
        "name": "MyLibraryTests"
      }
    }
  ],
  "version": 1
}

Conclusion

A well-tested Swift package is more than just code that compiles — it has a test target configured in Package.swift, uses Bundle.module for fixtures, covers async code with async throws test functions, and generates coverage reports in CI. Whether you're building an internal library or an open-source package, these practices make your package trustworthy for the people who depend on it.

Read more

Start now free