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.mdPackage.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: UseBundle.module(notBundle.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 # UndefinedBehaviorSanitizerCode 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.lcovTesting 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 -vXcode Cloud CI
Xcode Cloud is Apple's native CI for App Store apps. For Swift packages used in an app:
- Open your app in Xcode → Integrate → Create Workflow
- Name it "Test Swift Package"
- Configure actions:
- Test: macOS, iOS Simulator
- 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` automaticallyFor 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.