To give every scenario its own result and rerun control in Xcode, move a loop-based XCTest unit test to Swift Testing’s parameterized @Test. This walkthrough uses a local tile-layout calculation to show the migration and verify its reporting behavior.

Why doesn’t an XCTest loop report each scenario separately?

XCTest discovers test methods on an XCTestCase subclass. Those methods take no parameters and have names beginning with test. A loop inside one discovered method remains part of that method; its iterations don’t become separately discovered tests. Adding a scenario name to an assertion can improve diagnostics, but it doesn’t create an individual run control. See Apple’s Defining Test Cases and Test Methods.

The lifecycle follows the same boundary. XCTest runs its instance setup methods before each test method and its teardown methods afterward. A five-iteration loop therefore doesn’t automatically receive five setup-and-teardown cycles. If an iteration mutates a fixture, the next iteration may inherit that state unless the test resets it explicitly. Apple documents this sequence in Set Up and Tear Down State in Your Tests.

Swift Testing provides the outcome needed here: one function accepts scenario arguments, and the runner treats its invocations as independent cases. Xcode displays their arguments and offers individual run controls. Apple demonstrates this behavior in Go further with Swift Testing.

The distinction matters when diagnosing failures. An assertion failure and an early exit are different events: a loop may keep checking after an ordinary assertion, while a thrown error or explicit return can prevent later iterations from executing. Separating scenarios into cases removes that shared control flow between scenarios. It does not make a process-ending failure recoverable.

Prepare the existing unit-test target

Use Xcode 16 or later for this workflow. Swift Testing is included in those Xcode distributions and in Swift.org toolchains starting with Swift 6.0. The Swift project recommends using the built-in copy for ordinary testing work; this example needs no external package dependency. See Obtaining Swift Testing.

Keep the existing unit-test bundle. Apple explicitly supports Swift Testing and XCTest in the same target, so a gradual migration can start with one new Swift file. In Xcode, choose File > New > New File From Template, select Swift Testing Unit Test, and add the file to the existing unit-test target. If the project has no test target, create a Unit Testing Bundle through File > New > Target. These steps are documented in Adding tests to your Xcode project.

This walkthrough tests calculation code directly. It does not automate a desktop window. Apple’s documented path for UI automation continues to use XCTest; use this parameterized approach for the underlying logic that determines the layout. The same guide to adding tests describes that framework boundary.

Turn each scenario into a complete argument record

Suppose a Mac app arranges tiles in rows. With four columns, zero tiles require zero rows, four tiles fill one row, and five tiles require two rows. The concrete task is to preserve those checks while making each scenario independently visible and runnable.

Use one record containing the scenario name, inputs, and expected output. This keeps the expected result attached to its input when cases are added or reordered. The following five records are illustrative test data, not observations from an existing application.

Scenario Tile count Columns Expected rows Purpose
empty 0 4 0 Empty input
one-tile 1 4 1 Partial first row
exact-row 4 4 1 Exact boundary
wrap 5 4 2 First tile beyond the boundary
two-full-rows 8 4 2 Exact multiple

Place this complete, self-contained file in the unit-test target. It includes a synthetic implementation so it can be tried without an app module or other files. It uses no services, mocks, files, or network access.

import Testing

private struct TileLayout {
    func rowCount(tileCount: Int, columns: Int) -> Int {
        precondition(tileCount >= 0)
        precondition(columns > 0)

        let fullRows = tileCount / columns
        let partialRow = tileCount % columns == 0 ? 0 : 1
        return fullRows + partialRow
    }
}

private struct LayoutScenario:
    Codable, Sendable, CustomTestStringConvertible {
    let name: String
    let tileCount: Int
    let columns: Int
    let expectedRows: Int

    var testDescription: String { name }

    static let examples: [LayoutScenario] = [
        .init(name: "empty", tileCount: 0,
              columns: 4, expectedRows: 0),
        .init(name: "one-tile", tileCount: 1,
              columns: 4, expectedRows: 1),
        .init(name: "exact-row", tileCount: 4,
              columns: 4, expectedRows: 1),
        .init(name: "wrap", tileCount: 5,
              columns: 4, expectedRows: 2),
        .init(name: "two-full-rows", tileCount: 8,
              columns: 4, expectedRows: 2)
    ]
}

struct TileLayoutTests {
    @Test("Tile rows", arguments: LayoutScenario.examples)
    fileprivate func rowCountMatchesScenario(
        _ scenario: LayoutScenario
    ) {
        let layout = TileLayout()
        let actualRows = layout.rowCount(
            tileCount: scenario.tileCount,
            columns: scenario.columns
        )
        #expect(actualRows == scenario.expectedRows)
    }
}

The migration moves iteration into @Test(arguments:): each element becomes the argument for one invocation. Codable supplies encoding support for selective reruns, while CustomTestStringConvertible supplies the readable scenario label. Apple documents parameterization in Implementing parameterized tests and custom argument descriptions in Go further with Swift Testing.

The fixture is deliberately constructed inside the test function. Every invocation creates its own TileLayout; nothing depends on an earlier scenario. If migrating an existing fixture, move its setup deliberately rather than expecting inherited XCTest hooks to run. Swift Testing suites don’t inherit from XCTestCase; Apple maps setup to an initializer and class or actor teardown to deinit in Migrating a test from XCTest.

The calculation accepts nonnegative tile counts and strictly positive column counts. Its preconditions define that contract; invalid values are outside this example’s cases. If real application code must return a recoverable error for invalid input, change that API contract and add corresponding tests before extending this table.

Confirm separate results and rerun one failing case

Run the parameterized function using the diamond beside its declaration. Then open the Test navigator and expand the function to inspect its cases. To run one case, point to that case’s diamond and click its play control. Clicking the parent function’s play control runs all its cases. Apple documents both actions in Running tests and interpreting results.

For a controlled reporting check, temporarily change the wrap record’s expectedRows from 2 to 3. Run the whole parameterized function. The expected outcome is five case executions, with the wrap comparison failing because the implementation returns 2. Select that case and rerun it individually, then restore the expected value to 2. This is a deliberate test-data change, not evidence of an implementation defect.

Inspect the failed expectation’s Results and Arguments details to compare the computed value with the scenario input. For the full run, open View > Navigators > Reports, select the test action, and inspect its Tests section. These inspection controls are described in Apple’s test-results guide.

Selective execution has an additional requirement beyond readable labels: the runner must deterministically match every argument. Apple’s current documentation accepts CustomTestArgumentEncodable, certain RawRepresentable types, Encodable, or Identifiable with an encodable identifier. The sample’s Codable record satisfies the encoding route. A custom display string alone doesn’t satisfy that requirement. See Implementing parameterized tests.

The expected arithmetic for all five records was checked locally with Python integer division and remainder. An illustrative incorrect implementation using only floor division fails one-tile and wrap. The Swift file was inspected but not compiled in this research environment because Swift was unavailable; the Xcode run above remains the executable verification step.

Preserve the intended case count and lifecycle

A useful migration check compares three things: the number of scenario records, the cases executed in the full run, and whether a selected case can run alone. For this example, the intended count is five. A different count should prompt inspection of argument construction and test selection before changing the calculation.

Keep paired expectations together. Passing two separate collections to arguments: generates their Cartesian product. Five inputs and five expected outputs would therefore produce 25 combinations, rather than five matched scenarios. Use separate collections when every combination is meaningful; use records or zip() for paired data. Apple explains the distinction in Implementing parameterized tests.

If using zip(), check collection lengths before constructing the pairs: Swift stops at the shorter sequence. An extra expected result could otherwise remain unused without generating a case. A single scenario record avoids that particular mismatch. The truncation behavior is documented in Swift’s zip(::) reference.

Execution order also changes the migration decision. Swift Testing runs cases in parallel by default. This example has immutable arguments and a local fixture, so its results don’t depend on ordering. For a parameterized function that temporarily needs serial execution, add .serialized to its @Test attribute. Applying that trait to a suite also serializes its contained tests and cases, but it doesn’t serialize unrelated suites. See Running tests serially or in parallel.

Serial execution does not create fresh state or establish a scenario dependency contract. If a case only succeeds after another case has changed something, redesign its arrangement so it can run independently. If the steps intentionally form one continuous operation, retain them within one test and parameterize complete operations instead.

Use Swift Testing assertions throughout this migrated example. When reusing helpers that contain XCTest assertions, check the toolchain’s interoperability behavior: Apple’s current migration guide documents modes and defaults that vary by toolchain and package tools version. Keeping this file entirely within Testing avoids that dependency. See Migrating a test from XCTest.

If migration must wait, the supported XCTest alternative is one named, parameterless test method per scenario, with shared calculation and assertion helpers as appropriate. Apple recommends a test for each scenario in its guide to adding tests. That preserves separate method results without requiring Swift Testing parameterization.

The conversion is complete when each original scenario has a corresponding executed case, its fixture starts from the intended state, and a selected case can run successfully on its own. Preserve the boundary inputs and expected outputs during that change: independent reporting is useful because it makes those existing checks easier to locate, diagnose, and repeat.

Sources