Skip to content

Commit 4f602e0

Browse files
authored
Defer the recursive propagation of suite traits until after filtering (#820)
This PR includes some optimizations to the testing library's planning logic (in `Runner.Plan`) to avoid unnecessary work in common cases, and remove unnecessary nodes from the test graph once they're no longer needed. ### Motivation The testing library's planning phase includes taking all runtime-discovered `Test` instances and organizing them into a tree-like data structure using the internal `Graph` type. It traverses this graph several times to do things like only run certain tests the user indicated (by applying a filter), propagate eligible traits from suites to their children (by recursively applying traits), and implement other features. Currently, the step of recursively propagating eligible suite traits to their children must occur _before_ applying a test filter. This is because a test filter may require knowledge of trait information -- specifically, the `.tags` and `.hidden` traits. Even though test filtering only needs these specific traits, the planning phase currently propagates _all_ recursive suite traits down to _all_ tests, even though filtering may end up removing the majority of tests from the graph entirely. This can lead to wasted effort for any tests which were filtered out, and I anticipate this may become more pronounced in the future if/when we enhance the trait system to support things like consolidating same-typed traits with children. Trait propagation is done by inserting all inherited traits from the parent at the beginning of the child's trait list (to ensure deterministic ordering), so this requires shifting the elements in an array. And this work is only needed if the test filter being applied includes a query of tests' `.tags` trait. (The `.hidden` trait is semantically simpler and easier to handle: it's recursive, but it's a simple Boolean flag which cannot be overridden by children, so it doesn't need to be "merged" with children.) There is an opportunity to optimize this flow and completely skip trait propagation in the common case where a test filter doesn't care about tags, and allow propagation to happen after the filtering stage. Additionally, to reduce memory usage and simplify the graph for easier debugging, the filtering logic could be modified to "prune" the graph by removing branches which have zero children. ### Modifications - Add a property to `Configuration.TestFilter` indicating whether the filter requires knowledge of trait information. - Consult this property when applying a filter to a test graph. When necessary, temporarily convert the passed-in graph to a modified graph and recursively propagate tags to it. - Modify `Configuration.TestFilter` to prune unnecessary children from the graph before finishing. - Modify planning logic in `Runner.Plan` to move the recursive trait propagation step to after filering. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 94f8a22 commit 4f602e0

File tree

3 files changed

+203
-68
lines changed

3 files changed

+203
-68
lines changed

Sources/Testing/Running/Configuration.TestFilter.swift

Lines changed: 183 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ extension Configuration.TestFilter {
191191
extension Configuration.TestFilter {
192192
/// An enumeration which represents filtering logic to be applied to a test
193193
/// graph.
194-
fileprivate enum Operation: Sendable {
194+
fileprivate enum Operation<Item>: Sendable where Item: _FilterableItem {
195195
/// A filter operation which has no effect.
196196
///
197197
/// All tests are allowed when this operation is applied.
@@ -211,7 +211,7 @@ extension Configuration.TestFilter {
211211
/// - Parameters:
212212
/// - predicate: The function to predicate tests against.
213213
/// - membership: How to interpret the result when predicating tests.
214-
case function(_ predicate: @Sendable (borrowing Test) -> Bool, membership: Membership)
214+
case function(_ predicate: @Sendable (borrowing Item) -> Bool, membership: Membership)
215215

216216
/// A filter operation which is a combination of other operations.
217217
///
@@ -235,34 +235,31 @@ extension Configuration.TestFilter.Kind {
235235
/// test filter kind. One example is the creation of a `Regex` from a
236236
/// `.pattern` kind: if the pattern is not a valid regular expression, an
237237
/// error will be thrown.
238-
var operation: Configuration.TestFilter.Operation {
239-
get throws {
240-
switch self {
241-
case .unfiltered:
242-
return .unfiltered
243-
case let .testIDs(testIDs, membership):
244-
return .precomputed(Test.ID.Selection(testIDs: testIDs), membership: membership)
245-
case let .tags(tags, anyOf, membership):
246-
return .function({ test in
247-
if anyOf {
248-
!test.tags.isDisjoint(with: tags) // .intersects()
249-
} else {
250-
test.tags.isSuperset(of: tags)
251-
}
252-
}, membership: membership)
253-
case let .patterns(patterns, membership):
254-
guard #available(_regexAPI, *) else {
255-
throw SystemError(description: "Filtering by regular expression matching is unavailable")
256-
}
257-
258-
nonisolated(unsafe) let regexes = try patterns.map(Regex.init)
259-
return .function({ test in
260-
let id = String(describing: test.id)
261-
return regexes.contains { id.contains($0) }
262-
}, membership: membership)
263-
case let .combination(lhs, rhs, op):
264-
return try .combination(lhs.operation, rhs.operation, op)
238+
func operation<T>(itemType: T.Type = T.self) throws -> Configuration.TestFilter.Operation<T> where T: _FilterableItem {
239+
switch self {
240+
case .unfiltered:
241+
return .unfiltered
242+
case let .testIDs(testIDs, membership):
243+
return .precomputed(Test.ID.Selection(testIDs: testIDs), membership: membership)
244+
case let .tags(tags, anyOf, membership):
245+
let predicate: @Sendable (borrowing T) -> Bool = if anyOf {
246+
{ !$0.tags.isDisjoint(with: tags) /* .intersects() */ }
247+
} else {
248+
{ $0.tags.isSuperset(of: tags) }
249+
}
250+
return .function(predicate, membership: membership)
251+
case let .patterns(patterns, membership):
252+
guard #available(_regexAPI, *) else {
253+
throw SystemError(description: "Filtering by regular expression matching is unavailable")
265254
}
255+
256+
nonisolated(unsafe) let regexes = try patterns.map(Regex.init)
257+
return .function({ item in
258+
let id = String(describing: item.test.id)
259+
return regexes.contains { id.contains($0) }
260+
}, membership: membership)
261+
case let .combination(lhs, rhs, op):
262+
return try .combination(lhs.operation(), rhs.operation(), op)
266263
}
267264
}
268265
}
@@ -278,20 +275,25 @@ extension Configuration.TestFilter.Operation {
278275
///
279276
/// This function provides the bulk of the implementation of
280277
/// ``Configuration/TestFilter/apply(to:)``.
281-
fileprivate func apply(to testGraph: Graph<String, Test?>) -> Graph<String, Test?> {
278+
fileprivate func apply(to testGraph: Graph<String, Item?>) -> Graph<String, Item?> {
282279
switch self {
283280
case .unfiltered:
284281
return testGraph
285282
case let .precomputed(selection, membership):
286-
return testGraph.mapValues { _, test in
287-
guard let test else {
288-
return nil
283+
switch membership {
284+
case .including:
285+
return testGraph.mapValues { _, item in
286+
guard let item else {
287+
return nil
288+
}
289+
return selection.contains(item.test) ? item : nil
289290
}
290-
return switch membership {
291-
case .including:
292-
selection.contains(test) ? test : nil
293-
case .excluding:
294-
!selection.contains(test, inferAncestors: false) ? test : nil
291+
case .excluding:
292+
return testGraph.mapValues { _, item in
293+
guard let item else {
294+
return nil
295+
}
296+
return !selection.contains(item.test, inferAncestors: false) ? item : nil
295297
}
296298
}
297299
case let .function(function, membership):
@@ -307,15 +309,15 @@ extension Configuration.TestFilter.Operation {
307309
let testIDs = testGraph
308310
.compactMap(\.value).lazy
309311
.filter(function)
310-
.map(\.id)
312+
.map(\.test.id)
311313
let selection = Test.ID.Selection(testIDs: testIDs)
312314
return Self.precomputed(selection, membership: membership).apply(to: testGraph)
313315
case let .combination(lhs, rhs, op):
314316
return zip(
315317
lhs.apply(to: testGraph),
316318
rhs.apply(to: testGraph)
317319
).mapValues { _, value in
318-
op.functionValue(value.0, value.1)
320+
op(value.0, value.1)
319321
}
320322
}
321323
}
@@ -330,20 +332,81 @@ extension Configuration.TestFilter {
330332
///
331333
/// - Returns: A copy of `testGraph` with filtered tests replaced with `nil`.
332334
func apply(to testGraph: Graph<String, Test?>) throws -> Graph<String, Test?> {
333-
var result = try _kind.operation.apply(to: testGraph)
334-
335-
// After performing the test function, run through one more time and remove
336-
// hidden tests. (Note that this property's value is not recursively set on
337-
// combined test filters. It is only consulted on the outermost call to
338-
// apply(to:), not in _apply(to:).)
339-
if !includeHiddenTests {
340-
result = result.mapValues { _, test in
341-
(test?.isHidden == true) ? nil : test
342-
}
335+
var result: Graph<String, Test?>
336+
337+
if _kind.requiresTraitPropagation {
338+
// Convert the specified test graph to a graph of filter items temporarily
339+
// while performing filtering, and apply inheritance for the properties
340+
// which are relevant when performing filtering (e.g. tags).
341+
var filterItemGraph = testGraph.mapValues { $1.map(FilterItem.init(test:)) }
342+
_recursivelyApplyFilterProperties(to: &filterItemGraph)
343+
344+
result = try _kind.operation().apply(to: filterItemGraph)
345+
.mapValues { $1?.test }
346+
} else {
347+
result = try _kind.operation().apply(to: testGraph)
343348
}
344349

350+
// After filtering, run through one more time and prune the test graph to
351+
// remove any unnecessary nodes, since that reduces work in later stages of
352+
// planning.
353+
//
354+
// If `includeHiddenTests` is false, this will also remove any nodes
355+
// representing hidden tests. (Note that the value of the
356+
// `includeHiddenTests` property is not recursively set on combined test
357+
// filters. It is only consulted on the outermost call to apply(to:), not in
358+
// _apply(to:).)
359+
_recursivelyPruneTestGraph(&result)
360+
345361
return result
346362
}
363+
364+
/// Recursively apply filtering-related properties from test suites to their
365+
/// children in a graph.
366+
///
367+
/// - Parameters:
368+
/// - graph: The graph of filter items to modify.
369+
/// - tags: Tags from the parent of `graph` which `graph` should inherit.
370+
private func _recursivelyApplyFilterProperties(to graph: inout Graph<String, FilterItem?>, tags: Set<Tag> = []) {
371+
var tags = tags
372+
if let item = graph.value {
373+
tags.formUnion(item.tags)
374+
graph.value?.tags = tags
375+
}
376+
377+
for (key, var childGraph) in graph.children {
378+
_recursivelyApplyFilterProperties(to: &childGraph, tags: tags)
379+
graph.children[key] = childGraph
380+
}
381+
}
382+
383+
/// Recursively prune a test graph to remove unnecessary nodes.
384+
///
385+
/// - Parameters:
386+
/// - testGraph: The graph of tests to modify.
387+
private func _recursivelyPruneTestGraph(_ graph: inout Graph<String, Test?>) {
388+
// The recursive function. This is structured as a distinct function to
389+
// ensure that the root node itself is always preserved (despite its value
390+
// being `nil`).
391+
func pruneGraph(_ graph: Graph<String, Test?>) -> Graph<String, Test?>? {
392+
if !includeHiddenTests, let test = graph.value, test.isHidden {
393+
return nil
394+
}
395+
396+
var graph = graph
397+
for (key, childGraph) in graph.children {
398+
graph.children[key] = pruneGraph(childGraph)
399+
}
400+
if graph.value == nil && graph.children.isEmpty {
401+
return nil
402+
}
403+
return graph
404+
}
405+
406+
for (key, childGraph) in graph.children {
407+
graph.children[key] = pruneGraph(childGraph)
408+
}
409+
}
347410
}
348411

349412
// MARK: - Combining
@@ -364,21 +427,23 @@ extension Configuration.TestFilter {
364427
/// This operator is equivalent to `||`.
365428
case or
366429

367-
/// The equivalent of this instance as a callable function.
368-
fileprivate var functionValue: @Sendable (Test?, Test?) -> Test? {
430+
/// Evaluate this combination operator with two optional operands.
431+
///
432+
/// - Parameters:
433+
/// - lhs: The left-hand argument
434+
/// - rhs: The right-hand argument.
435+
///
436+
/// - Returns: The combined result of evaluating this operator.
437+
fileprivate func callAsFunction<T>(_ lhs: T?, _ rhs: T?) -> T? where T: _FilterableItem {
369438
switch self {
370439
case .and:
371-
return { lhs, rhs in
372-
if lhs != nil && rhs != nil {
373-
lhs
374-
} else {
375-
nil
376-
}
440+
if lhs != nil && rhs != nil {
441+
lhs
442+
} else {
443+
nil
377444
}
378445
case .or:
379-
return { lhs, rhs in
380-
lhs ?? rhs
381-
}
446+
lhs ?? rhs
382447
}
383448
}
384449
}
@@ -422,3 +487,60 @@ extension Configuration.TestFilter {
422487
self = combining(with: other, using: op)
423488
}
424489
}
490+
491+
// MARK: - Filterable types
492+
493+
extension Configuration.TestFilter.Kind {
494+
/// Whether this kind of test filter requires knowledge of test traits.
495+
///
496+
/// If the value of this property is `true`, the values of a test graph must
497+
/// be converted to `FilterItem` and have trait information recursively
498+
/// propagated before the filter can be applied, or else the results may be
499+
/// inaccurate. This facilitates a performance optimization where trait
500+
/// propagation can be skipped for filters which don't require such knowledge.
501+
fileprivate var requiresTraitPropagation: Bool {
502+
switch self {
503+
case .unfiltered,
504+
.testIDs,
505+
.patterns:
506+
false
507+
case .tags:
508+
true
509+
case let .combination(lhs, rhs, _):
510+
lhs.requiresTraitPropagation || rhs.requiresTraitPropagation
511+
}
512+
}
513+
}
514+
515+
/// A protocol representing a value which can be filtered using
516+
/// ``Configuration/TestFilter-swift.struct``.
517+
private protocol _FilterableItem {
518+
/// The test this item represents.
519+
var test: Test { get }
520+
521+
/// The complete set of tags for ``test``, including those inherited from
522+
/// containing suites.
523+
var tags: Set<Tag> { get }
524+
}
525+
526+
extension Test: _FilterableItem {
527+
var test: Test {
528+
self
529+
}
530+
}
531+
532+
/// An item representing a test and its filtering-related properties.
533+
///
534+
/// Instances of this type are needed when applying a test graph to a kind of
535+
/// filter for which the value of the `requiresFilterItemConversion` property
536+
/// is `true`.
537+
fileprivate struct FilterItem: _FilterableItem {
538+
var test: Test
539+
540+
var tags: Set<Tag>
541+
542+
init(test: Test) {
543+
self.test = test
544+
self.tags = test.tags
545+
}
546+
}

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ extension Runner.Plan {
141141
/// The traits in `testGraph.value?.traits` are added to each node in
142142
/// `testGraph`, and then this function is called recursively on each child
143143
/// node.
144-
private static func _recursivelyApplyTraits(_ parentTraits: [any Trait] = [], to testGraph: inout Graph<String, Test?>) {
145-
let traits: [any SuiteTrait] = (parentTraits + (testGraph.value?.traits ?? [])).lazy
144+
private static func _recursivelyApplyTraits(_ parentTraits: [any SuiteTrait] = [], to testGraph: inout Graph<String, Test?>) {
145+
let traits: [any SuiteTrait] = parentTraits + (testGraph.value?.traits ?? []).lazy
146146
.compactMap { $0 as? any SuiteTrait }
147147
.filter(\.isRecursive)
148148

@@ -181,11 +181,6 @@ extension Runner.Plan {
181181
actionGraph.insertValue(runAction, at: idComponents, intermediateValue: runAction)
182182
}
183183

184-
// Ensure the trait lists are complete for all nested tests. (Make sure to
185-
// do this before we start calling configuration.testFilter or prepare(for:)
186-
// or we'll miss the recursively-added traits.)
187-
_recursivelyApplyTraits(to: &testGraph)
188-
189184
// Remove any tests that should be filtered out per the runner's
190185
// configuration. The action graph is not modified here: actions that lose
191186
// their corresponding tests are effectively filtered out by the call to
@@ -203,6 +198,16 @@ extension Runner.Plan {
203198
// and that is already guarded earlier in the SwiftPM entry point.
204199
}
205200

201+
// Recursively apply all recursive suite traits to children.
202+
//
203+
// This must be done _before_ calling `prepare(for:)` on the traits below.
204+
// It is safe to do this _after_ filtering the test graph since filtering
205+
// internally propagates information about traits which are needed to
206+
// correctly evaluate the filter. It's also more efficient, since it avoids
207+
// needlessly applying non-filtering related traits to tests which might be
208+
// filtered out.
209+
_recursivelyApplyTraits(to: &testGraph)
210+
206211
// For each test value, determine the appropriate action for it.
207212
//
208213
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and

Tests/TestingTests/PlanTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,14 @@ struct PlanTests {
421421
#expect(plan.stepGraph.subgraph(at: typeInfo.fullyQualifiedNameComponents + CollectionOfOne("reserved1(reserved2:)")) != nil)
422422
}
423423

424+
@Test("Nodes for filtered-out tests are removed from the step graph")
425+
func filteringPrunesGraph() async throws {
426+
let plan = await Runner.Plan(selecting: SendableTests.self)
427+
#expect(plan.stepGraph.children.count == 1)
428+
let moduleGraph = try #require(plan.stepGraph.children.values.first)
429+
#expect(moduleGraph.children.count == 1)
430+
}
431+
424432
#if !SWT_NO_SNAPSHOT_TYPES
425433
@Test("Test cases of a disabled test are not evaluated")
426434
func disabledTestCases() async throws {

0 commit comments

Comments
 (0)