Testing iOS progress weights, cancellation and completion

Testing iOS progress weights, cancellation and completion

When processing files in batches, the progress bar reaches 100%, only for an error to appear moments later. A user clicks Cancel, yet the background task still commits its result. This usually is not a rendering problem: it means work accounting, cancellation signals, and the operation’s final state have been mixed together. On a GNUMac cloud Mac, first lock down those contracts with unit tests that require neither a network connection nor a simulator, then validate the iOS interface.

Define what progress means

This article uses a test fixture that converts four inputs and then validates two outputs. Conversion accounts for 60% of the total work, and validation accounts for 40%. These weights are specified by the test; they are not performance measurements and do not represent time remaining.

Set the parent progress total to one hundred, with each child progress measuring a file count. Do not add the number of converted files directly to the number of uploaded bytes: different units must first be mapped to weights at the parent level.

Track the operation’s state separately as waiting, running, cancelling, cancelled, failed, or succeeded. The progress object describes quantities only; it does not classify errors.

The percentage answers “how much of the agreed work is complete,” while the operation’s state answers “has the result been reliably delivered?” Neither can substitute for the other.

Before implementing anything, establish three constraints: attach each child progress only once; never let completed work exceed the total; and check for cancellation before committing the result. A single task owns the parent and child objects. Do not put them in a global variable shared by all tasks.

Validate weights with deterministic tests

Create a test file in the existing project’s unit test target and add the following code. The test uses only Foundation and XCTest. It does not wait for actual file conversion or use sleeps to guess execution order.

import Foundation
import XCTest

final class WeightedProgressTests: XCTestCase {
    func testWeightedStages() {
        let root = Progress(totalUnitCount: 100)
        let convert = Progress(totalUnitCount: 4)
        let verify = Progress(totalUnitCount: 2)

        root.addChild(convert, withPendingUnitCount: 60)
        root.addChild(verify, withPendingUnitCount: 40)

        XCTAssertEqual(root.fractionCompleted, 0, accuracy: 0.000001)

        convert.completedUnitCount = 2
        XCTAssertEqual(root.fractionCompleted, 0.3, accuracy: 0.000001)

        verify.completedUnitCount = 1
        XCTAssertEqual(root.fractionCompleted, 0.5, accuracy: 0.000001)

        convert.completedUnitCount = 4
        verify.completedUnitCount = 2
        XCTAssertEqual(root.fractionCompleted, 1, accuracy: 0.000001)
    }
}

The test deliberately interleaves progress in the two stages to confirm that the parent does not merely reflect the current stage. Compare floating-point results using a tolerance rather than asserting against percentages formatted as strings.

Run this class from the test navigator on the cloud Mac, and save the commit ID, test results, and details of the toolchain used. If you already have a command-line test entry point, add only this test target to it; do not create a separate simulator matrix just for progress tests.

Cancellation requires cooperation from the worker

Progress.cancel() is a signal; it does not forcibly terminate a running loop. Check the state before processing each unit, and increment the count only after that unit has actually finished. Check again immediately before the final replacement of the output file.

The following test verifies that “the next unit of work does not start after cancellation.” It does not claim to cover concurrency races, and you can add it directly to the preceding test class.

func testCancellationStopsNextUnit() {
    let progress = Progress(totalUnitCount: 4)
    progress.isCancellable = true
    var processed = 0

    func runNextUnit() {
        guard !progress.isCancelled else { return }
        processed += 1
        progress.completedUnitCount += 1
    }

    runNextUnit()
    progress.cancel()
    runNextUnit()

    XCTAssertTrue(progress.isCancelled)
    XCTAssertEqual(processed, 1)
    XCTAssertEqual(progress.completedUnitCount, 1)
}

An asynchronous worker also needs two controllable suspension points: one before starting a time-consuming operation, and another before committing the result. Have the test pause the task, request cancellation, and then release it. Assert that no new work starts and no final result is published. Coordinate this with test-controlled gates, not fixed delays.

If the underlying operation provides a cancellation API, forward the signal to it as well. If it cannot be interrupted, wait for it to return, then discard its result and clean up temporary files. Keep the interface in the “Cancelling” state rather than prematurely claiming that processing has stopped.

Do not disguise unknown totals or retries

While the full file count is still being discovered, show “Counting” rather than trying to calculate a percentage from a zero total. Once the scan is complete, freeze the task list, establish the full parent-child hierarchy, and start processing.

Continually increasing the denominator during execution makes progress move backward. If tasks really must be discovered dynamically, show the stage status or the number of items processed, and make it clear that the current percentage applies only to the stage whose scope is already known.

Retries should not repeatedly reset the same child progress to zero either. The business layer can track the attempt number, while the progress layer accumulates successfully completed logical units. A failed attempt does not mean another file has been completed.

Define a separate rule for zero inputs as well: if empty input is valid, immediately return a successful “Nothing to process” result. If the caller must provide input, report an error before starting instead of creating a task that remains at its starting point forever.

Connect the UI and ongoing validation

After observing a progress change, switch to the main executor to update UI state. Assign a separate task identifier to each run and check it before applying updates, so delayed notifications from the previous task cannot overwrite the new progress.

You can round the displayed percentage, but do not use that rounded value to determine success. If validation and an atomic commit are still required after the last file is processed, reserve work for those steps. On failure, retain the error state rather than forcing the count to its total.

Before merging, check each item:

  • Assertions for intermediate weights and interleaved progress pass without depending on execution speed.
  • Cancellation before starting, during processing, and before committing each have a separate test case.
  • Error paths are not mapped to success, and temporary output can be cleaned up.
  • Once a new task starts, notifications from the old task cannot change the current interface.
  • Empty input, unknown totals, and retries all have explicit display rules.

The final deliverable is not a progress bar that looks smooth, but a verifiable state contract. First make the counts trustworthy, then make cancellation controllable, and finally ensure that success means the result is genuinely usable.

Frequently asked questions

Does reaching 100 percent mean the operation succeeded?

No. Progress measures completed work units. Success also requires final validation and delivery, tracked through a separate business state.

Does cancel() automatically stop running work?

No. The worker must check cancellation, forward it to the underlying operation and check again before committing the final result.

Configure a dedicated Mac mini for your workflow

Choose a model, region, and rental term to match your project. Check memory, storage, and connectivity needs before ordering; availability is confirmed in real time by the console.

Start with your workflow, then get your Mac up and running.

A dedicated physical Mac mini. Choose a model, region, and rental term, then configure storage to suit your project.