Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SWT-NNNN] Introduce API allowing traits to customize test execution #733

Merged
499 changes: 499 additions & 0 deletions Documentation/Proposals/NNNN-custom-test-execution-traits.md

Large diffs are not rendered by default.

41 changes: 19 additions & 22 deletions Sources/Testing/Running/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,45 +56,42 @@ public struct Runner: Sendable {
// MARK: - Running tests

extension Runner {
/// Execute the ``CustomExecutionTrait/execute(_:for:testCase:)`` functions
/// associated with the test in a plan step.
/// Apply the custom scope for any test scope providers of the traits
/// associated with a specified test by calling their
/// ``TestScoping/provideScope(for:testCase:performing:)`` function.
///
/// - Parameters:
/// - step: The step being performed.
/// - testCase: The test case, if applicable, for which to execute the
/// custom trait.
/// - test: The test being run, for which to provide custom scope.
/// - testCase: The test case, if applicable, for which to provide custom
/// scope.
/// - body: A function to execute from within the
/// ``CustomExecutionTrait/execute(_:for:testCase:)`` functions of each
/// trait applied to `step.test`.
/// ``TestScoping/provideScope(for:testCase:performing:)`` function of
/// each non-`nil` scope provider of the traits applied to `test`.
///
/// - Throws: Whatever is thrown by `body` or by any of the
/// ``CustomExecutionTrait/execute(_:for:testCase:)`` functions.
private func _executeTraits(
for step: Plan.Step,
/// ``TestScoping/provideScope(for:testCase:performing:)`` function calls.
private func _applyScopingTraits(
for test: Test,
testCase: Test.Case?,
_ body: @escaping @Sendable () async throws -> Void
) async throws {
// If the test does not have any traits, exit early to avoid unnecessary
// heap allocations below.
if step.test.traits.isEmpty {
return try await body()
}

if case .skip = step.action {
if test.traits.isEmpty {
return try await body()
}

// Construct a recursive function that invokes each trait's ``execute(_:for:testCase:)``
// function. The order of the sequence is reversed so that the last trait is
// the one that invokes body, then the second-to-last invokes the last, etc.
// and ultimately the first trait is the first one to be invoked.
let executeAllTraits = step.test.traits.lazy
let executeAllTraits = test.traits.lazy
.reversed()
.compactMap { $0 as? any CustomExecutionTrait }
.compactMap { $0.execute(_:for:testCase:) }
.reduce(body) { executeAllTraits, traitExecutor in
.compactMap { $0.scopeProvider(for: test, testCase: testCase) }
.map { $0.provideScope(for:testCase:performing:) }
.reduce(body) { executeAllTraits, provideScope in
{
try await traitExecutor(executeAllTraits, step.test, testCase)
try await provideScope(test, testCase, executeAllTraits)
}
}

Expand Down Expand Up @@ -200,7 +197,7 @@ extension Runner {
if let step = stepGraph.value, case .run = step.action {
await Test.withCurrent(step.test) {
_ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) {
try await _executeTraits(for: step, testCase: nil) {
try await _applyScopingTraits(for: step.test, testCase: nil) {
// Run the test function at this step (if one is present.)
if let testCases = step.test.testCases {
try await _runTestCases(testCases, within: step)
Expand Down Expand Up @@ -336,7 +333,7 @@ extension Runner {
let sourceLocation = step.test.sourceLocation
await Issue.withErrorRecording(at: sourceLocation, configuration: configuration) {
try await withTimeLimit(for: step.test, configuration: configuration) {
try await _executeTraits(for: step, testCase: testCase) {
try await _applyScopingTraits(for: step.test, testCase: testCase) {
try await testCase.body()
}
} timeoutHandler: { timeLimit in
Expand Down
1 change: 1 addition & 0 deletions Sources/Testing/Testing.docc/Traits.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ behavior of test functions.
- ``Trait``
- ``TestTrait``
- ``SuiteTrait``
- ``TestScoping``

### Supporting types

Expand Down
7 changes: 7 additions & 0 deletions Sources/Testing/Testing.docc/Traits/Trait.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,15 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors
- ``Trait/bug(_:id:_:)-3vtpl``

### Adding information to tests

- ``Trait/comments``

### Preparing internal state

- ``Trait/prepare(for:)-3s3zo``

### Providing custom execution scope for tests

- ``TestScoping``
- ``Trait/scopeProvider(for:testCase:)-cjmg``
- ``Trait/TestScopeProvider``
161 changes: 126 additions & 35 deletions Sources/Testing/Traits/Trait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,126 @@ public protocol Trait: Sendable {
///
/// By default, the value of this property is an empty array.
var comments: [Comment] { get }

/// The type of the test scope provider for this trait.
///
/// The default type is `Never`, which cannot be instantiated. The
/// ``scopeProvider(for:testCase:)-cjmg`` method for any trait with this
/// default type must return `nil`, meaning that trait will not provide a
/// custom scope for the tests it's applied to.
associatedtype TestScopeProvider: TestScoping = Never

/// Get this trait's scope provider for the specified test and/or test case,
/// if any.
///
/// - Parameters:
/// - test: The test for which a scope provider is being requested.
/// - testCase: The test case for which a scope provider is being requested,
/// if any. When `test` represents a suite, the value of this argument is
/// `nil`.
///
/// - Returns: A value conforming to ``Trait/TestScopeProvider`` which may be
/// used to provide custom scoping for `test` and/or `testCase`, or `nil` if
/// they should not have any custom scope.
///
/// If this trait's type conforms to ``TestScoping``, the default value
/// returned by this method depends on `test` and/or `testCase`:
///
/// - If `test` represents a suite, this trait must conform to ``SuiteTrait``.
/// If the value of this suite trait's ``SuiteTrait/isRecursive`` property
/// is `true`, then this method returns `nil`; otherwise, it returns `self`.
/// This means that by default, a suite trait will _either_ provide its
/// custom scope once for the entire suite, or once per-test function it
/// contains.
/// - Otherwise `test` represents a test function. If `testCase` is `nil`,
/// this method returns `nil`; otherwise, it returns `self`. This means that
/// by default, a trait which is applied to or inherited by a test function
/// will provide its custom scope once for each of that function's cases.
///
/// A trait may explicitly implement this method to further customize the
/// default behaviors above. For example, if a trait should provide custom
/// test scope both once per-suite and once per-test function in that suite,
/// it may implement the method and return a non-`nil` scope provider under
/// those conditions.
///
/// A trait may also implement this method and return `nil` if it determines
/// that it does not need to provide a custom scope for a particular test at
/// runtime, even if the test has the trait applied. This can improve
/// performance and make diagnostics clearer by avoiding an unnecessary call
/// to ``TestScoping/provideScope(for:testCase:performing:)``.
///
/// If this trait's type does not conform to ``TestScoping`` and its
/// associated ``Trait/TestScopeProvider`` type is the default `Never`, then
/// this method returns `nil` by default. This means that instances of this
/// trait will not provide a custom scope for tests to which they're applied.
func scopeProvider(for test: Test, testCase: Test.Case?) -> TestScopeProvider?
}

/// A protocol that allows providing a custom execution scope for a test
/// function (and each of its cases) or a test suite by performing custom code
/// before or after it runs.
///
/// Types conforming to this protocol may be used in conjunction with a
/// ``Trait``-conforming type by implementing the
/// ``Trait/scopeProvider(for:testCase:)-cjmg`` method, allowing custom traits
/// to provide custom scope for tests. Consolidating common set-up and tear-down
/// logic for tests which have similar needs allows each test function to be
/// more succinct with less repetitive boilerplate so it can focus on what makes
/// it unique.
public protocol TestScoping: Sendable {
/// Provide custom execution scope for a function call which is related to the
/// specified test and/or test case.
///
/// - Parameters:
/// - test: The test under which `function` is being performed.
/// - testCase: The test case, if any, under which `function` is being
/// performed. When invoked on a suite, the value of this argument is
/// `nil`.
/// - function: The function to perform. If `test` represents a test suite,
/// this function encapsulates running all the tests in that suite. If
/// `test` represents a test function, this function is the body of that
/// test function (including all cases if it is parameterized.)
///
/// - Throws: Whatever is thrown by `function`, or an error preventing this
/// type from providing a custom scope correctly. An error thrown from this
/// method is recorded as an issue associated with `test`. If an error is
/// thrown before `function` is called, the corresponding test will not run.
///
/// When the testing library is preparing to run a test, it starts by finding
/// all traits applied to that test, including those inherited from containing
/// suites. It begins with inherited suite traits, sorting them
/// outermost-to-innermost, and if the test is a function, it then adds all
/// traits applied directly to that functions in the order they were applied
/// (left-to-right). It then asks each trait for its scope provider (if any)
/// by calling ``Trait/scopeProvider(for:testCase:)-cjmg``. Finally, it calls
/// this method on all non-`nil` scope providers, giving each an opportunity
/// to perform arbitrary work before or after invoking `function`.
///
/// This method should either invoke `function` once before returning or throw
/// an error if it is unable to provide a custom scope.
///
/// Issues recorded by this method are associated with `test`.
func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws
}

extension Trait where Self: TestScoping {
public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? {
testCase == nil ? nil : self
}
}

extension SuiteTrait where Self: TestScoping {
public func scopeProvider(for test: Test, testCase: Test.Case?) -> Self? {
if test.isSuite {
isRecursive ? nil : self
} else {
testCase == nil ? nil : self
}
}
}

extension Never: TestScoping {
public func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws {}
}

/// A protocol describing traits that can be added to a test function.
Expand Down Expand Up @@ -72,43 +192,14 @@ extension Trait {
}
}

extension Trait where TestScopeProvider == Never {
stmontgomery marked this conversation as resolved.
Show resolved Hide resolved
public func scopeProvider(for test: Test, testCase: Test.Case?) -> Never? {
nil
}
}

extension SuiteTrait {
public var isRecursive: Bool {
false
}
}

/// A protocol extending ``Trait`` that offers an additional customization point
/// for trait authors to execute code before and after each test function (if
/// added to the traits of a test function), or before and after each test suite
/// (if added to the traits of a test suite).
@_spi(Experimental)
public protocol CustomExecutionTrait: Trait {

/// Execute a function with the effects of this trait applied.
///
/// - Parameters:
/// - function: The function to perform. If `test` represents a test suite,
/// this function encapsulates running all the tests in that suite. If
/// `test` represents a test function, this function is the body of that
/// test function (including all cases if it is parameterized.)
/// - test: The test under which `function` is being performed.
/// - testCase: The test case, if any, under which `function` is being
/// performed. This is `nil` when invoked on a suite.
///
/// - Throws: Whatever is thrown by `function`, or an error preventing the
/// trait from running correctly.
///
/// This function is called for each ``CustomExecutionTrait`` on a test suite
/// or test function and allows additional work to be performed before and
/// after the test runs.
///
/// This function is invoked once for the test it is applied to, and then once
/// for each test case in that test, if applicable.
///
/// Issues recorded by this function are recorded against `test`.
///
/// - Note: If a test function or test suite is skipped, this function does
/// not get invoked by the runner.
func execute(_ function: @Sendable () async throws -> Void, for test: Test, testCase: Test.Case?) async throws
}
104 changes: 0 additions & 104 deletions Tests/TestingTests/Traits/CustomExecutionTraitTests.swift

This file was deleted.

Loading