Hi folks! I'm prototyping a few feature for Swift Testing and could use feedback from the community!
Background
When writing test suites over complex codebases, especially those that have dependencies on other languages like C, you may find your tests touch shared mutable state that isn't guarded by Swift concurrency primitives such as actors. Canonical examples of such state are the standard I/O streams and the environment block, either of which can be mutated by any thread at any time outside the control of Swift's structured concurrency.
We generally recommend that you try to rewrite your Swift code, where possible, to avoid using such state. If you cannot avoid using it, Swift Testing offers the .serialized
trait which tells Swift Testing to run the tests and test cases therein to run one at a time. You can apply this trait to your test suites and parameterized tests:
@Suite(.serialized)
struct `Environment tests` {
@Test func peek() {
let ev = getenv("ENVVAR")
...
}
@Test func poke() {
setenv("ENVVAR", "VALUE", 1)
...
}
...
}
This works in contexts where all the tests that might touch such state are closely related and can live in a single suite. But what if you have some tests that rely on the environment block, some that rely on stdout
, and some that rely on both?
Today, the only way to ensure that all these tests run serially is to nest them all in a single suite marked .serialized
. This can limit your ability to structure your tests to your liking and, worse, may not be possible as the number of dependencies grows. This requirement has also proven to be confusing as it's not clear that .serialized
only applies locally rather than globally.
Whatever shall we do, @grynspan?
I'm working on an enhancement to .serialized
that I'm very tentatively calling data-dependent test serialization[1].
This feature will introduce new variants of .serialized
that take a uniquely identifiable argument, currently either a key path, a non-ephemeral pointer, or a testing tag (declared elsewhere):
@Suite(.serialized(for: \ProcessInfo.environment))
struct `Environment tests` {
...
}
@Suite(unsafe .serialized(for: stdout))
struct `Standard I/O tests` {
...
}
extension Tag {
@Tag static var changesLocale: Self
}
@Sute(.serialized(for: .changesLocale))
struct `System locale tests` {
...
}
At runtime, Swift Testing will serialize all tests with a given dependency. So, for example, all tests that declare a dependency on ProcessInfo.environment
will be serialized regardless of where they appear in the test plan and in relation to each other. These tests will still be free to run in parallel with tests that do not declare any dependencies, or declare dependencies on unrelated values such as stdout
or Locale.current
.
If a test or suite declares a dependency on more than one datum:
@Suite(
.serialized(for: \ProcessInfo.environment),
unsafe .serialized(for: stdout),
.serialized(for: .changesLocale)
)
struct `Environment AND standard I/O AND locale tests` {
...
}
Then Swift Testing will order that test or suite serially with tests that are dependent on any (or all) of said data.
For particularly complex tests where the test author cannot reasonably enumerate all their dependencies, you can also specify a wildcard dependency[2]:
@Test(.serialized(for: *))
func `Dependent on everything`() {
...
}
What's next?
Community feedback will be key to the success of this feature. Open questions include but are not limited to:
- What do we name the new symbols we're introducing? We don't have much in the way of guidelines for naming traits.
- What kinds of data do you think you'd need to pass as dependencies? Data that acts as a dependency needs to have identity in order for us to determine that two unrelated tests share it.
- How do you see yourself using this feature? What sort of tests are you unable to write today without it, or at least without a lot of effort?
- Should the existing
.serialized
trait become a synonym for.serialized(for: *)
? That would mean that a test marked.serialized
is serialized with respect to all other such tests rather than just those in the same suite.
Naming is hard, okay?
"Dependency" in particular is not a great name because dependency injection is a commonly-used pattern when writing tests, and this feature isn't directly related to that pattern. I'm happy to take ideas for better names here! ↩︎
Yes, this is valid Swift syntax! I suspect we would spell it differently in the final proposal. Just making sure you're paying attention!
↩︎