Isolating Shared State in Parallel Swift Testing on Cloud Macs

Isolating Shared State in Parallel Swift Testing on Cloud Macs

When the same Swift test suite passes consistently in isolation but fails intermittently in parallel jobs on a cloud Mac, the machine is usually not the problem. More often, the tests are sharing directories, preferences, databases, ports, or random state. Adding retries only hides the contamination. A more reliable approach is to give every test an identifiable, cleanable, and reproducible execution boundary.

First determine whether the failures come from shared state

Keep parallelism enabled initially rather than immediately switching to serial execution. Run the same target with a single worker and multiple workers, and generate a separate result bundle for each run:

set -o pipefail

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled NO \
  -resultBundlePath Artifacts/serial.xcresult

xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath Artifacts/parallel.xcresult

If the single-worker run is stable but the parallel run fails, inspect the following resources before questioning the assertions:

Shared resource Common symptom Isolation method
Temporary directory Files are overwritten or missing during cleanup Create a unique subdirectory for every test
UserDefaults Values written by other test cases are read Use a separate suiteName
SQLite file Lock conflicts or fluctuating record counts Use a separate database for every test
Fixed port Address already in use Bind to port 0
Global random number generator Results change when execution order changes Explicitly record the random seed

Passing in serial only proves that shared state was not accessed concurrently. It does not prove that the tests are properly isolated.

Give every test its own sandbox

Do not let every test case write to /tmp/app-tests. Generate a unique identifier when each test starts, place its files, database, and exported results in that directory, and clean it up when the test finishes.

import Foundation
import Testing

struct TestSandbox {
    let id: String
    let root: URL

    init(name: String) throws {
        id = "\(name)-\(UUID().uuidString)"
        root = FileManager.default.temporaryDirectory
            .appending(path: "ZoneMiniTests")
            .appending(path: id)
        try FileManager.default.createDirectory(
            at: root,
            withIntermediateDirectories: true
        )
    }

    func preferences() throws -> UserDefaults {
        guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
            throw CocoaError(.fileWriteUnknown)
        }
        return defaults
    }

    func remove() throws {
        try FileManager.default.removeItem(at: root)
        UserDefaults.standard.removePersistentDomain(
            forName: "tests.\(id)"
        )
    }
}

Pass root to the code under test through dependency injection instead of letting production code read a global temporary path itself. Put cleanup in defer so that it still runs when an assertion fails. If the failure state must be preserved, skip deletion based on an environment variable and include the sandbox path in the test attachments.

Do not use the test name as the unique key

Parameterized tests may run multiple input sets with the same function name in parallel. Using only the function name can still cause collisions, so combine the test name, a parameter summary, and a UUID. If the parameters contain tokens or user data, do not include them directly in the directory name. Generate a non-reversible digest first.

Isolate configuration, databases, and listening ports

The standard UserDefaults domain is process-wide shared state. Tests should receive a dedicated instance and remove its persistent domain when they finish. Apply the same principle to databases: every test should create its own file, and migration tests must not reuse the same copy as ordinary read and write tests.

Fixed ports are a common source of false failures in network tests. Bind the test server to port 0 so the system selects an available port, then pass the actual port to the client. Do not scan for an available port, close the scanning connection, and then attempt to bind again. That approach creates a race window between scanning and binding.

Tests that genuinely depend on a global singleton and cannot be refactored in the short term can be serialized locally:

import Testing

@Suite(.serialized)
struct LegacyDatabaseTests {
    @Test
    func migratesExistingStore() async throws {
        // Test implementation
    }
}

Limit .serialized to the legacy scope. Serializing the entire test target may eliminate parallel contamination, but it also removes both the performance benefit and the clues needed to diagnose the problem.

Record a random seed instead of retrying blindly

Tests involving shuffling, retry backoff, or generated data should read their seed from an environment variable. Generate and record the base seed once when the job starts, then have every test process derive its own sub-seed from that value.

let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806

A sub-seed can be calculated deterministically from the base seed and the test’s unique key. At minimum, failure reports should preserve the base seed, worker count, test target, and run command. This makes it possible to reproduce the combination of specific input and specific parallelism instead of blindly rerunning the suite ten times.

On ZoneMini persistent execution nodes, write each result bundle to a directory associated with the job ID so that a later job does not overwrite an earlier one:

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"

TEST_SEED=20260806 xcodebuild test \
  -scheme AppTests \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -parallel-testing-enabled YES \
  -maximum-parallel-testing-workers 4 \
  -resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"

Verify the fix with repeated load testing

Do not stop after one successful run. First, run the suite repeatedly with the same seed and parallelism, then change the seed to cover different inputs. Every iteration must use a different result-bundle path; otherwise, xcodebuild will exit because the destination already exists.

Use four explicit acceptance criteria:

  1. Single-worker and four-worker runs produce the same assertion results.
  2. Every test case writes only to its own directory, preferences domain, and database.
  3. Listening services do not use hard-coded ports.
  4. Failure logs identify the seed, sandbox, and result bundle.

Finally, review the cleanup policy. Successful jobs can delete temporary sandboxes. Failed jobs should retain the necessary logs and result bundles, but credentials, repository tokens, and unredacted request content must not be stored long-term. Stable parallel testing does not come from adding more retries. It comes from ensuring that every test owns only its own state and that every failure leaves enough evidence to reproduce it.

Frequently asked questions

Should parallel execution be disabled when Swift Testing starts failing?

Not by default. First isolate directories, UserDefaults, databases, and ports per test. Use the .serialized trait only as a local fallback for legacy tests that cannot yet run safely in parallel.

How can a failure that appears only in CI be reproduced?

Record the random seed, parallel worker count, test destination, and xcresult path for every run. Re-run the complete failing condition with the same seed and concurrency level.

Why should parallel tests avoid fixed listener ports?

Multiple processes can bind the same port or connect to a server owned by another test. Bind the test server to port 0 and pass the operating system’s assigned port to the client.

DEDICATED BUILD SLOT

Choose a dedicated cloud Mac for continuous build tasks

Review the M4 chip, memory, storage, node, and billing cycle, then connect the fixed build environment to your existing pipeline.

Choose a configuration and order