Skip to content

Commit

Permalink
Add aroundEach (#1132)
Browse files Browse the repository at this point in the history
This is a reopening of #820, brought up to date with the latest main. Here is an updated summary; full historical discussion is in the original PR. I’d sure love to get this merged before it hits the 4-year mark!

---

This PR adds an `aroundEach` decoration, which accepts the individual example as a callback:

```swift
aroundEach { example in
    ...
    example()
    ...
}
```

See the similar features in [Rspec](https://relishapp.com/rspec/rspec-core/v/3-0/docs/hooks/around-hooks) and [JUnit](https://junit.org/junit4/javadoc/4.12/org/junit/rules/RuleChain.html#around(org.junit.rules.TestRule)).


## Motivation

There are a few test setup/cleanup scenarios the existing `beforeEach`/`afterEach` don’t handle, or don’t handle well.

Some test decorations can’t split into a separate `beforeEach` and `afterEach`. The original motivation for adding `aroundEach` is [Siesta checking for leaks in specs](https://github.com/bustoutsolutions/siesta/blob/2b7bff709e0938a593de1f08415e905b4eece3ab/Tests/Functional/ResourceSpecBase.swift#L115-L145). To do this, it needs to wrap each spec in its own autorelease pool, then verify that objects are no longer retained _after_ the pool is drained:

```swift
it("does this") {
    autoreleasepool {
        doThis()
    }
    checkObjectsNoLongerRetained()
}

it("does that") {
    autoreleasepool {
        doThat()
    }
    checkObjectsNoLongerRetained()
}
```

I’d like to pull out both the `autoreleasepool` block _and_ `checkObjectsNoLongerRetained()` so they’re not repeated in each spec. (The two are tightly coupled, and it makes sense to factor them out together.) This is not possible with `beforeEach`/`afterEach`, but with `aroundEach` it is:

```swift
aroundEach { example in
    autoreleasepool {
        example()
    }
    checkObjectsNoLongerRetained()
}

it("does this") {
    doThis()
}

it("does that") {
    doThat()
}
```

Other methods that take a closure/block present a similar problem: XCTest’s `measure`, for example.

Even in cases where it is possible to split the decoration into `beforeEach` and `afterEach`, it’s not always desirable. Grouping tightly coupled before and after behavior in a single `aroundEach` can help ensure proper nesting of operations.


## Implementation notes

This PR works to preserve Quick’s somewhat illogical before/after hook execution order. (See #989.)

This PR reimplements _before_ and _after_ hooks using the new feature. Internally, there is now only _around_. This appears to work; all existing specs still pass!


## Merge checklist

 - [x] Tests
 - [x] Documentation
 - [x] Objective-C support
 - [x] Ensure new implementation of before/after does not break existing public behavior
 - [x] Appease SwiftLint
 - [x] Is this a new feature (Requires minor version bump)?
  • Loading branch information
pcantrell committed Apr 14, 2022
1 parent f3231d4 commit 13a4ac9
Show file tree
Hide file tree
Showing 12 changed files with 418 additions and 68 deletions.
16 changes: 16 additions & 0 deletions Quick.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@
DA3124EB19FCAEE8002858A7 /* QCKDSL.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3124E419FCAEE8002858A7 /* QCKDSL.m */; };
DA3124EC19FCAEE8002858A7 /* World+DSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3124E519FCAEE8002858A7 /* World+DSL.swift */; };
DA3124ED19FCAEE8002858A7 /* World+DSL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3124E519FCAEE8002858A7 /* World+DSL.swift */; };
DA3E1686243F8C23001F7CCA /* AroundEachTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E1685243F8C23001F7CCA /* AroundEachTests.swift */; };
DA3E1689243F8C23001F7CCA /* AroundEachTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E1685243F8C23001F7CCA /* AroundEachTests.swift */; };
DA3E168C243F8C23001F7CCA /* AroundEachTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3E1685243F8C23001F7CCA /* AroundEachTests.swift */; };
DA3E1690243FEDBE001F7CCA /* AroundEachTests+ObjC.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3E168F243FEDAD001F7CCA /* AroundEachTests+ObjC.m */; };
DA3E1691243FEDC0001F7CCA /* AroundEachTests+ObjC.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3E168F243FEDAD001F7CCA /* AroundEachTests+ObjC.m */; };
DA3E1692243FEDC1001F7CCA /* AroundEachTests+ObjC.m in Sources */ = {isa = PBXBuildFile; fileRef = DA3E168F243FEDAD001F7CCA /* AroundEachTests+ObjC.m */; };
DA3E7A341A1E66C600CCE408 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8100E901A1E4447007595ED /* Nimble.framework */; };
DA3E7A351A1E66CB00CCE408 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8100E901A1E4447007595ED /* Nimble.framework */; };
DA408BE219FF5599005DF92A /* Closures.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA408BDF19FF5599005DF92A /* Closures.swift */; };
Expand Down Expand Up @@ -394,6 +400,8 @@
DA3124E319FCAEE8002858A7 /* QCKDSL.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QCKDSL.h; sourceTree = "<group>"; };
DA3124E419FCAEE8002858A7 /* QCKDSL.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QCKDSL.m; sourceTree = "<group>"; };
DA3124E519FCAEE8002858A7 /* World+DSL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "World+DSL.swift"; sourceTree = "<group>"; };
DA3E1685243F8C23001F7CCA /* AroundEachTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AroundEachTests.swift; sourceTree = "<group>"; };
DA3E168F243FEDAD001F7CCA /* AroundEachTests+ObjC.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "AroundEachTests+ObjC.m"; sourceTree = "<group>"; };
DA408BDF19FF5599005DF92A /* Closures.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Closures.swift; sourceTree = "<group>"; };
DA408BE019FF5599005DF92A /* ExampleHooks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleHooks.swift; sourceTree = "<group>"; };
DA408BE119FF5599005DF92A /* SuiteHooks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SuiteHooks.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -613,6 +621,7 @@
children = (
470D6EC91A43409600043E50 /* AfterEachTests+ObjC.m */,
47FAEA341A3F45ED005A1D2F /* BeforeEachTests+ObjC.m */,
DA3E168F243FEDAD001F7CCA /* AroundEachTests+ObjC.m */,
47876F7B1A4999B0002575C7 /* BeforeSuiteTests+ObjC.m */,
DA8F919C19F31921006F6675 /* FailureTests+ObjC.m */,
DA8940EF1B35B1FA00161061 /* FailureUsingXCTAssertTests+ObjC.m */,
Expand Down Expand Up @@ -676,6 +685,7 @@
8D010A561C11726F00633E2B /* DescribeTests.swift */,
DA87078219F48775008C04AC /* BeforeEachTests.swift */,
DA05D60F19F73A3800771050 /* AfterEachTests.swift */,
DA3E1685243F8C23001F7CCA /* AroundEachTests.swift */,
DAA63EA219F7637300CD0A3B /* PendingTests.swift */,
DA8F91A419F3208B006F6675 /* BeforeSuiteTests.swift */,
DA8F91AA19F3299E006F6675 /* SharedExamplesTests.swift */,
Expand Down Expand Up @@ -1372,6 +1382,7 @@
1F118D231BDCA556005013A2 /* SharedExamples+BeforeEachTests+ObjC.m in Sources */,
1F118D151BDCA556005013A2 /* FailureUsingXCTAssertTests+ObjC.m in Sources */,
1F118D131BDCA556005013A2 /* ItTests+ObjC.m in Sources */,
DA3E1692243FEDC1001F7CCA /* AroundEachTests+ObjC.m in Sources */,
1F118D191BDCA556005013A2 /* AfterEachTests+ObjC.m in Sources */,
1F118D221BDCA556005013A2 /* SharedExamples+BeforeEachTests.swift in Sources */,
AED9C8651CC8A7BD00432F62 /* CrossReferencingSpecs.swift in Sources */,
Expand All @@ -1384,6 +1395,7 @@
CD582D772264C137008F7CE6 /* QuickSpecRunner.swift in Sources */,
1F118D1B1BDCA556005013A2 /* PendingTests+ObjC.m in Sources */,
34C586051C4ABD4100D4F057 /* XCTestCaseProvider.swift in Sources */,
DA3E168C243F8C23001F7CCA /* AroundEachTests.swift in Sources */,
8D010A591C11726F00633E2B /* DescribeTests.swift in Sources */,
1F118D111BDCA556005013A2 /* Configuration+AfterEachTests.swift in Sources */,
1F118D161BDCA556005013A2 /* BeforeEachTests.swift in Sources */,
Expand Down Expand Up @@ -1460,6 +1472,7 @@
DA8F91AC19F3299E006F6675 /* SharedExamplesTests.swift in Sources */,
DA7AE6F219FC493F000AFDCE /* ItTests.swift in Sources */,
4748E8951A6AEBB3009EC992 /* SharedExamples+BeforeEachTests+ObjC.m in Sources */,
DA3E1691243FEDC0001F7CCA /* AroundEachTests+ObjC.m in Sources */,
CE4A578E1EA7251C0063C0D4 /* FunctionalTests_BehaviorTests_Behaviors.swift in Sources */,
DA8F91AF19F32CE2006F6675 /* FunctionalTests_SharedExamplesTests_SharedExamples.swift in Sources */,
DAE714FB19FF682A005905B8 /* Configuration+AfterEachTests.swift in Sources */,
Expand All @@ -1472,6 +1485,7 @@
CD582D752264C137008F7CE6 /* QuickSpecRunner.swift in Sources */,
34C586031C4ABD4000D4F057 /* XCTestCaseProvider.swift in Sources */,
8D010A581C11726F00633E2B /* DescribeTests.swift in Sources */,
DA3E1689243F8C23001F7CCA /* AroundEachTests.swift in Sources */,
47FAEA371A3F49EB005A1D2F /* BeforeEachTests+ObjC.m in Sources */,
470D6ECC1A43442900043E50 /* AfterEachTests+ObjC.m in Sources */,
47876F7E1A49AD71002575C7 /* BeforeSuiteTests+ObjC.m in Sources */,
Expand Down Expand Up @@ -1596,6 +1610,7 @@
DAA63EA319F7637300CD0A3B /* PendingTests.swift in Sources */,
DA8F91AB19F3299E006F6675 /* SharedExamplesTests.swift in Sources */,
DA7AE6F119FC493F000AFDCE /* ItTests.swift in Sources */,
DA3E1690243FEDBE001F7CCA /* AroundEachTests+ObjC.m in Sources */,
4748E8941A6AEBB3009EC992 /* SharedExamples+BeforeEachTests+ObjC.m in Sources */,
DA8F91AE19F32CE2006F6675 /* FunctionalTests_SharedExamplesTests_SharedExamples.swift in Sources */,
DAE714FA19FF682A005905B8 /* Configuration+AfterEachTests.swift in Sources */,
Expand All @@ -1608,6 +1623,7 @@
CD582D732264C137008F7CE6 /* QuickSpecRunner.swift in Sources */,
34C586011C4ABD3F00D4F057 /* XCTestCaseProvider.swift in Sources */,
8D010A571C11726F00633E2B /* DescribeTests.swift in Sources */,
DA3E1686243F8C23001F7CCA /* AroundEachTests.swift in Sources */,
47FAEA361A3F49E6005A1D2F /* BeforeEachTests+ObjC.m in Sources */,
470D6ECB1A43442400043E50 /* AfterEachTests+ObjC.m in Sources */,
47876F7D1A49AD63002575C7 /* BeforeSuiteTests+ObjC.m in Sources */,
Expand Down
29 changes: 29 additions & 0 deletions Sources/Quick/Configuration/QCKConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ final public class QCKConfiguration: NSObject {
exampleHooks.appendAfter(closure)
}

/**
Like Quick.DSL.aroundEach, this configures Quick to wrap each example
with the given closure. The closure passed to this method will wrap
all examples globally across the test suite. You may call this method
multiple times across multiple +[QuickConfigure configure:] methods in
order to define several closures to wrap all examples.
Note that, since Quick makes no guarantee as to the order in which
+[QuickConfiguration configure:] methods are evaluated, there is no
guarantee as to the order in which aroundEach closures are evaluated.
However, aroundEach does always guarantee proper nesting of operations:
cleanup within aroundEach closures will always happen in the reverse order
of setup.
- parameter closure: The closure to be executed before each example
in the test suite.
*/
public func aroundEach(_ closure: @escaping AroundExampleClosure) {
exampleHooks.appendAround(closure)
}

/**
Identical to Quick.QCKConfiguration.aroundEach, except the closure receives
metadata about the example that the closure wraps.
*/
public func aroundEach(_ closure: @escaping AroundExampleWithMetadataClosure) {
exampleHooks.appendAround(closure)
}

/**
Like Quick.DSL.beforeSuite, this configures Quick to execute
the given closure prior to any and all examples that are run.
Expand Down
45 changes: 45 additions & 0 deletions Sources/Quick/DSL/DSL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,51 @@ public func afterEach(_ closure: @escaping AfterExampleWithMetadataClosure) {
World.sharedWorld.afterEach(closure: closure)
}

/**
Defines a closure to that wraps each example in the current example
group. This closure is not run for pending or otherwise disabled examples.
The closure you pass to aroundEach receives a callback as its argument, which
it MUST call exactly one for the example to run properly:
aroundEach { runExample in
doSomeSetup()
runExample()
doSomeCleanup()
}
This callback is particularly useful for test decartions that can’t split
into a separate beforeEach and afterEach. For example, running each example
in its own autorelease pool requires aroundEach:
aroundEach { runExample in
autoreleasepool {
runExample()
}
checkObjectsNoLongerRetained()
}
You can also use aroundEach to guarantee proper nesting of setup and cleanup
operations in situations where their relative order matters.
An example group may contain an unlimited number of aroundEach callbacks.
They will nest inside each other, with the first declared in the group
nested at the outermost level.
- parameter closure: The closure that wraps around each example.
*/
public func aroundEach(_ closure: @escaping AroundExampleClosure) {
World.sharedWorld.aroundEach(closure)
}

/**
Identical to Quick.DSL.aroundEach, except the closure receives metadata
about the example that the closure wraps.
*/
public func aroundEach(_ closure: @escaping AroundExampleWithMetadataClosure) {
World.sharedWorld.aroundEach(closure)
}

/**
Defines an example. Examples use assertions to demonstrate how code should
behave. These are like "tests" in XCTest.
Expand Down
18 changes: 18 additions & 0 deletions Sources/Quick/DSL/World+DSL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ extension World {
}
#endif

internal func aroundEach(_ closure: @escaping AroundExampleClosure) {
guard currentExampleMetadata == nil else {
raiseError("'aroundEach' cannot be used inside '\(currentPhase)', 'aroundEach' may only be used inside 'context' or 'describe'. ")
}
currentExampleGroup.hooks.appendAround(closure)
}

#if canImport(Darwin)
@objc(aroundEachWithMetadata:)
internal func aroundEach(_ closure: @escaping AroundExampleWithMetadataClosure) {
currentExampleGroup.hooks.appendAround(closure)
}
#else
internal func aroundEach(_ closure: @escaping AroundExampleWithMetadataClosure) {
currentExampleGroup.hooks.appendAround(closure)
}
#endif

@nonobjc
internal func it(_ description: String, flags: FilterFlags = [:], file: FileString, line: UInt, closure: @escaping () throws -> Void) {
if beforesCurrentlyExecuting {
Expand Down
78 changes: 40 additions & 38 deletions Sources/Quick/Example.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,49 +75,51 @@ final public class Example: _ExampleBase {
world.currentExampleMetadata = nil
}

world.exampleHooks.executeBefores(exampleMetadata)
group!.phase = .beforesExecuting
for before in group!.befores {
before(exampleMetadata)
}
group!.phase = .beforesFinished

do {
try closure()
} catch {
let description = "Test \(name) threw unexpected error: \(error.localizedDescription)"
#if SWIFT_PACKAGE
let file = callsite.file.description
#else
let file = callsite.file
#endif

// XCTIssue is unavailable (not implemented yet) on swift-corelibs-xctest (for non-Apple platforms)
#if canImport(Darwin)
let location = XCTSourceCodeLocation(filePath: file, lineNumber: Int(callsite.line))
let sourceCodeContext = XCTSourceCodeContext(location: location)
let issue = XCTIssue(
type: .thrownError,
compactDescription: description,
sourceCodeContext: sourceCodeContext
)
QuickSpec.current.record(issue)
#else
QuickSpec.current.recordFailure(
withDescription: description,
inFile: file,
atLine: Int(callsite.line),
expected: false
)
#endif

let runExample = { [closure, name, callsite] in
self.group!.phase = .beforesFinished

do {
try closure()
} catch {
let description = "Test \(name) threw unexpected error: \(error.localizedDescription)"
#if SWIFT_PACKAGE
let file = callsite.file.description
#else
let file = callsite.file
#endif

// XCTIssue is unavailable (not implemented yet) on swift-corelibs-xctest (for non-Apple platforms)
#if canImport(Darwin)
let location = XCTSourceCodeLocation(filePath: file, lineNumber: Int(callsite.line))
let sourceCodeContext = XCTSourceCodeContext(location: location)
let issue = XCTIssue(
type: .thrownError,
compactDescription: description,
sourceCodeContext: sourceCodeContext
)
QuickSpec.current.record(issue)
#else
QuickSpec.current.recordFailure(
withDescription: description,
inFile: file,
atLine: Int(callsite.line),
expected: false
)
#endif
}

self.group!.phase = .aftersExecuting
}

group!.phase = .aftersExecuting
for after in group!.afters {
after(exampleMetadata)
let allWrappers = group!.wrappers + world.exampleHooks.wrappers
let wrappedExample = allWrappers.reduce(runExample) { closure, wrapper in
return { wrapper(exampleMetadata, closure) }
}
wrappedExample()

group!.phase = .aftersFinished
world.exampleHooks.executeAfters(exampleMetadata)

world.numberOfExamplesRun += 1

Expand Down
14 changes: 3 additions & 11 deletions Sources/Quick/ExampleGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,10 @@ final public class ExampleGroup: NSObject {
return aggregateFlags
}

internal var befores: [BeforeExampleWithMetadataClosure] {
var closures = Array(hooks.befores.reversed())
internal var wrappers: [AroundExampleWithMetadataClosure] {
var closures = Array(hooks.wrappers.reversed())
walkUp { group in
closures.append(contentsOf: Array(group.hooks.befores.reversed()))
}
return Array(closures.reversed())
}

internal var afters: [AfterExampleWithMetadataClosure] {
var closures = hooks.afters
walkUp { group in
closures.append(contentsOf: group.hooks.afters)
closures.append(contentsOf: group.hooks.wrappers.reversed())
}
return closures
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/Quick/Hooks/Closures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ public typealias AfterExampleClosure = BeforeExampleClosure
*/
public typealias AfterExampleWithMetadataClosure = BeforeExampleWithMetadataClosure

/**
A closure which wraps an example. The closure must call runExample() exactly once.
*/
public typealias AroundExampleClosure = (_ runExample: @escaping () -> Void) -> Void

/**
A closure which wraps an example. The closure is given example metadata,
which contains information about the example that the wrapper will run.
The closure must call runExample() exactly once.
*/
public typealias AroundExampleWithMetadataClosure =
(_ exampleMetadata: ExampleMetadata, _ runExample: @escaping () -> Void) -> Void

// MARK: Suite Hooks

/**
Expand Down
45 changes: 26 additions & 19 deletions Sources/Quick/Hooks/ExampleHooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,48 @@
A container for closures to be executed before and after each example.
*/
final internal class ExampleHooks {
internal var befores: [BeforeExampleWithMetadataClosure] = []
internal var afters: [AfterExampleWithMetadataClosure] = []
internal var wrappers: [AroundExampleWithMetadataClosure] = []
internal var phase: HooksPhase = .nothingExecuted

internal func appendBefore(_ closure: @escaping BeforeExampleWithMetadataClosure) {
befores.append(closure)
wrappers.append { exampleMetadata, runExample in
closure(exampleMetadata)
runExample()
}
}

internal func appendBefore(_ closure: @escaping BeforeExampleClosure) {
befores.append { (_: ExampleMetadata) in closure() }
wrappers.append { _, runExample in
closure()
runExample()
}
}

internal func appendAfter(_ closure: @escaping AfterExampleWithMetadataClosure) {
afters.append(closure)
wrappers.prepend { exampleMetadata, runExample in
runExample()
closure(exampleMetadata)
}
}

internal func appendAfter(_ closure: @escaping AfterExampleClosure) {
afters.append { (_: ExampleMetadata) in closure() }
}

internal func executeBefores(_ exampleMetadata: ExampleMetadata) {
phase = .beforesExecuting
for before in befores {
before(exampleMetadata)
wrappers.prepend { _, runExample in
runExample()
closure()
}
}

phase = .beforesFinished
internal func appendAround(_ closure: @escaping AroundExampleWithMetadataClosure) {
wrappers.append(closure)
}

internal func executeAfters(_ exampleMetadata: ExampleMetadata) {
phase = .aftersExecuting
for after in afters {
after(exampleMetadata)
}
internal func appendAround(_ closure: @escaping AroundExampleClosure) {
wrappers.append { _, runExample in closure(runExample) }
}
}

phase = .aftersFinished
extension Array {
mutating func prepend(_ element: Element) {
insert(element, at: 0)
}
}
Loading

0 comments on commit 13a4ac9

Please sign in to comment.