Swift Testing: .serialized did not stop another suite deleting test files
A filesystem test failed while writing its fixture, even though its suite used @Suite(.serialized) and each fixture had a UUID directory. The underlying error was:
NSPOSIXErrorDomain Code=2 "No such file or directory"
Another suite was constructing the application core, whose initializer swept the shared cache directory. Injecting that startup operation, with an inert test value, stopped unrelated tests from deleting each other’s fixtures.
A unique child directory still has a shared parent #
The failing suite created a directory, then wrote a file into it. Its own cleanup tests ran serially. Elsewhere in the same Swift Testing process, ordinary tests constructed the core as part of their setup. Every construction triggered a sweep of the common cache parent.
The destructive interleaving was small:
cleanup test: create cache/<UUID>/
unrelated test: construct core -> sweep cache children
cleanup test: write cache/<UUID>/fixture.bin -> missing directory
UUIDs prevented name collisions. They could not protect a child from an operation that deleted every child.
Swift Testing’s parallelization trait documentation
describes the scope: .serialized orders a suite’s contained tests and sub-suites. It does not order those tests relative to unrelated suites. Putting the attribute on the failing suite left the other constructors free to run alongside it.
Make ordinary test construction inert #
The fix moved the startup sweep behind an injected operation. Production kept the real sweep; the test dependency supplied a no-op. Tests that needed to verify sweeping explicitly selected the real operation.
Here is a generalized closure-based adaptation, independent of a dependency-injection library:
import Foundation
struct StartupSweep {
var run: () throws -> Void
static var inert: StartupSweep { StartupSweep(run: {}) }
static func live(root: URL) -> StartupSweep {
StartupSweep {
let manager = FileManager.default
for child in try manager.contentsOfDirectory(
at: root, includingPropertiesForKeys: nil
) {
try manager.removeItem(at: child)
}
}
}
}
struct CacheCore {
init(startupSweep: StartupSweep) throws {
try startupSweep.run()
}
}
The production composition root passes .live(root: cacheRoot). The ordinary test factory passes .inert; neither caller relies on an initializer default. This example propagates filesystem errors so a cleanup test cannot pass by silently ignoring a failed sweep.
For a test-owned root containing a prepared fixture file, the two assertions look like this. This is a test-body excerpt using Testing:
// Ordinary construction must preserve the existing fixture.
_ = try CacheCore(startupSweep: .inert)
#expect(FileManager.default.fileExists(atPath: fixture.path))
// Cleanup coverage explicitly opts into the real operation.
_ = try CacheCore(startupSweep: .live(root: root))
#expect(!FileManager.default.fileExists(atPath: fixture.path))
Use a disposable root for this check. Never point the illustrative live operation at a directory containing files you need. Injecting a separate root per test is another way to isolate filesystem work when the design permits it.
Prove the constructor contract, then repeat the broader target #
The historical investigation used an isolated-suite control: the cleanup suite repeated 30 times with zero issues. The broader core target failed on its first run. That narrowed the search to interference from outside the serialized suite.
A regression test then created a fixture, built the core-under-test dependencies, and asserted that the file still existed. Before the injection fix, that assertion failed. It converted an intermittent write failure into a direct check of the destructive constructor behavior.
After the fix, the archived broader-target output reported:
exit: 0
** TEST SUCCEEDED **
issues:
0
max repetition:
repetition 30
✔ Test run with 165 tests in 28 suites passed after 2.806 seconds.
Those results verify the original fix under Xcode and iOS Simulator. The closure-based adaptation above has not been compiled separately. The reported test count is the runner’s summary, not a total multiplied by the repetition limit.
When a serialized suite loses files, check every initializer that can sweep their shared parent, including constructors called by unrelated suites.