Skip to content

Commit

Permalink
Fix pre-built library evolution and testability (#265)
Browse files Browse the repository at this point in the history
Library Evolution
Module stability is currently broken despite enabling the
`BUILD_LIBRARIES_FOR_DISTRIBUTION` build setting, due to a known issue
since Xcode 11.2 (FB5863238). Specifically, Mockingbird returns an
`XCTest.XCTestExpectation` for asynchronous testing which is ambiguous
in the module interface since `XCTest` itself exports a class named
`XCTest`. To fix the issue, this change avoids referencing XCTest
symbols in the Swift module interface by bridging them from Objective-C.

Testability
The release version is set to strip debug symbols which prevents
testable imports of Mockingbird. This explicitly sets `COPY_PHASE_STRIP`
to `NO` for both Carthage and CocoaPods.
  • Loading branch information
andrewchang-bird authored Jan 10, 2022
1 parent cc5c43b commit 12b8440
Show file tree
Hide file tree
Showing 14 changed files with 193 additions and 65 deletions.
8 changes: 8 additions & 0 deletions Mockingbird.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@
28887301278532AE001B92CF /* MockingbirdCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 285C8DF72779E2D200DE525A /* MockingbirdCommon.framework */; };
2894622626A2AF6F00044839 /* Mockingbird.h in Headers */ = {isa = PBXBuildFile; fileRef = 2894622526A2AF6F00044839 /* Mockingbird.h */; settings = {ATTRIBUTES = (Public, ); }; };
28950E2D251C4C82008EEE29 /* ProjectDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28950E2C251C4C82008EEE29 /* ProjectDescription.swift */; };
28967D09278BF3E600E523D9 /* MKBTestExpectation.h in Headers */ = {isa = PBXBuildFile; fileRef = 28967D07278BF3E600E523D9 /* MKBTestExpectation.h */; settings = {ATTRIBUTES = (Public, ); }; };
28967D0A278BF3E600E523D9 /* MKBTestExpectation.m in Sources */ = {isa = PBXBuildFile; fileRef = 28967D08278BF3E600E523D9 /* MKBTestExpectation.m */; };
2896E51826A6E93A00124D02 /* StubbingContext+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2896E51726A6E93A00124D02 /* StubbingContext+ObjC.swift */; };
2896E51A26A6E97900124D02 /* DynamicStubbingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2896E51926A6E97900124D02 /* DynamicStubbingManager.swift */; };
2896E51D26A7E94400124D02 /* NSInvocation+MKBErrorObjectType.h in Headers */ = {isa = PBXBuildFile; fileRef = 2896E51B26A7E94400124D02 /* NSInvocation+MKBErrorObjectType.h */; };
Expand Down Expand Up @@ -646,6 +648,8 @@
28874F9226BF828800097529 /* InferableArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InferableArgument.swift; sourceTree = "<group>"; };
2894622526A2AF6F00044839 /* Mockingbird.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Mockingbird.h; sourceTree = "<group>"; };
28950E2C251C4C82008EEE29 /* ProjectDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDescription.swift; sourceTree = "<group>"; };
28967D07278BF3E600E523D9 /* MKBTestExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MKBTestExpectation.h; sourceTree = "<group>"; };
28967D08278BF3E600E523D9 /* MKBTestExpectation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MKBTestExpectation.m; sourceTree = "<group>"; };
2896E51726A6E93A00124D02 /* StubbingContext+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StubbingContext+ObjC.swift"; sourceTree = "<group>"; };
2896E51926A6E97900124D02 /* DynamicStubbingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicStubbingManager.swift; sourceTree = "<group>"; };
2896E51B26A7E94400124D02 /* NSInvocation+MKBErrorObjectType.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSInvocation+MKBErrorObjectType.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1226,6 +1230,7 @@
children = (
2894622526A2AF6F00044839 /* Mockingbird.h */,
287C4F4126A3688100A7E0D9 /* MKBMocking.h */,
28967D07278BF3E600E523D9 /* MKBTestExpectation.h */,
28843B2526AE710400AFB8DF /* MKBTestUtils.h */,
287C4F4D26A36DC000A7E0D9 /* MKBTypeFacade.h */,
);
Expand All @@ -1236,6 +1241,7 @@
isa = PBXGroup;
children = (
287C4F4226A3688100A7E0D9 /* MKBMocking.m */,
28967D08278BF3E600E523D9 /* MKBTestExpectation.m */,
28843B2626AE710400AFB8DF /* MKBTestUtils.m */,
287C4F4E26A36DC000A7E0D9 /* MKBTypeFacade.m */,
);
Expand Down Expand Up @@ -2062,6 +2068,7 @@
buildActionMask = 2147483647;
files = (
2894622626A2AF6F00044839 /* Mockingbird.h in Headers */,
28967D09278BF3E600E523D9 /* MKBTestExpectation.h in Headers */,
28571AB826A666070063AB83 /* MKBUnsignedCharInvocationHandler.h in Headers */,
28571A8C26A65F680063AB83 /* MKBBoolInvocationHandler.h in Headers */,
28843B2726AE710400AFB8DF /* MKBTestUtils.h in Headers */,
Expand Down Expand Up @@ -2743,6 +2750,7 @@
28FB7EC32789C14000125FDA /* Synchronized.swift in Sources */,
28571A8526A65D8A0063AB83 /* MKBLongInvocationHandler.m in Sources */,
OBJ_896 /* OrderedVerification.swift in Sources */,
28967D0A278BF3E600E523D9 /* MKBTestExpectation.m in Sources */,
28571AB526A664F70063AB83 /* MKBStructInvocationHandler.m in Sources */,
OBJ_897 /* TestFailure.swift in Sources */,
2896E51826A6E93A00124D02 /* StubbingContext+ObjC.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Mockingbird.xcodeproj/xcconfigs/FrameworkBase.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ ENABLE_BITCODE = NO
ENABLE_TESTABILITY = YES
DEFINES_MODULE = YES
SKIP_INSTALL = YES
COPY_PHASE_STRIP = NO

// Compatibility
SUPPORTED_PLATFORMS = macosx iphoneos iphonesimulator appletvos appletvsimulator watchos watchsimulator
Expand Down
2 changes: 2 additions & 0 deletions MockingbirdFramework.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Pod::Spec.new do |s|
s.pod_target_xcconfig = {
'ENABLE_BITCODE' => 'NO',
'ENABLE_TESTABILITY' => 'YES',
'COPY_PHASE_STRIP' => 'NO',
'DEFINES_MODULE' => 'YES',
}

s.source_files = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,20 @@ inOrder(with: .noInvocationsAfter) {

### Verify Asynchronous Calls

Mocked methods that are invoked asynchronously can be verified using an `eventually` block which returns an `XCTestExpectation`.
Mocked methods that are invoked asynchronously can be verified using an `eventually` block which creates an `XCTestExpectation` and attaches it to the current `XCTestCase`.

```swift
DispatchQueue.main.async {
guard bird.canFly else { return }
bird.fly()
}

let expectation =
eventually {
verify(bird.canFly).wasCalled()
verify(bird.fly()).wasCalled()
}
eventually {
verify(bird.canFly).wasCalled()
verify(bird.fly()).wasCalled()
}

wait(for: [expectation], timeout: 1.0)
waitForExpectations(timeout: 1.0)
```

### Verify Overloaded Methods
Expand Down
10 changes: 9 additions & 1 deletion Sources/MockingbirdAutomation/Interop/Carthage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public enum Carthage {
case all = "all"
}

public enum BuildConfiguration: String {
case debug = "Debug"
case release = "Release"
}

public static func update(platforms: [Platform] = [.all], project: Path) throws {
try Subprocess("carthage", [
"update",
Expand All @@ -19,10 +24,13 @@ public enum Carthage {
], workingDirectory: project.parent()).run()
}

public static func build(platforms: [Platform] = [.all], project: Path) throws {
public static func build(platforms: [Platform] = [.all],
configuration: BuildConfiguration = .release,
project: Path) throws {
try Subprocess("carthage", [
"build",
"--platform", platforms.map({ $0.rawValue }).joined(separator: ","),
"--configuration", configuration.rawValue,
"--use-xcframeworks",
"--no-skip-current",
"--cache-builds",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "285C8EA4277DA59E00DE525A"
BuildableName = "MockingbirdAutomationTests.xctest"
BlueprintName = "MockingbirdAutomationTests"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand All @@ -28,6 +42,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "285C8EA4277DA59E00DE525A"
BuildableName = "MockingbirdAutomationTests.xctest"
BlueprintName = "MockingbirdAutomationTests"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "28E2A567277E8F43002975B3"
BuildableName = "MockingbirdAutomation"
BuildableName = "MockingbirdAutomationCli"
BlueprintName = "MockingbirdAutomationCli"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
Expand All @@ -28,24 +28,15 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "285C8EA4277DA59E00DE525A"
BuildableName = "MockingbirdAutomationTests.xctest"
BlueprintName = "MockingbirdAutomationTests"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
useCustomWorkingDirectory = "YES"
customWorkingDirectory = "$(SRCROOT)"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
Expand All @@ -55,7 +46,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "28E2A567277E8F43002975B3"
BuildableName = "MockingbirdAutomation"
BuildableName = "MockingbirdAutomationCli"
BlueprintName = "MockingbirdAutomationCli"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
Expand All @@ -72,7 +63,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "28E2A567277E8F43002975B3"
BuildableName = "MockingbirdAutomation"
BuildableName = "MockingbirdAutomationCli"
BlueprintName = "MockingbirdAutomationCli"
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@
ReferencedContainer = "container:Mockingbird.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#import <XCTest/XCTest.h>

NS_ASSUME_NONNULL_BEGIN

/// An expected outcome in an asynchronous test.
///
/// Library evolution as of Swift 5.5.2 breaks when publicly including any types from the `XCTest`
/// framework due to the `XCTest` class declaration. Using a bridged type that can be casted to and
/// from `XCTestExpectation` allows us to avoid a direct reference in the Swift module interface.
/// See https://github.com/birdrides/mockingbird/issues/242 for more information.
NS_SWIFT_NAME(TestExpectation)
@interface MKBTestExpectation : XCTestExpectation

/// Convert an `XCTestExpectation` to a `MKBTestExpectation`.
/// @param expectation An `XCTestExpectation` instance.
+ (instancetype)createFromExpectation:(XCTestExpectation *)expectation;

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#import "MKBMocking.h"
#import "MKBTestExpectation.h"
#import "MKBTestUtils.h"
#import "MKBTypeFacade.h"
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#import "../include/MKBTestExpectation.h"

@implementation MKBTestExpectation

+ (instancetype)createFromExpectation:(XCTestExpectation *)expectation
{
return (MKBTestExpectation *)expectation;
}

@end
94 changes: 59 additions & 35 deletions Sources/MockingbirdFramework/Verification/AsyncVerification.swift
Original file line number Diff line number Diff line change
@@ -1,50 +1,76 @@
import Foundation
import XCTest

/// Create a deferrable test expectation from a block containing verification calls.
///
/// Mocked methods that are invoked asynchronously can be verified using an `eventually` block which
/// returns an `XCTestExpectation`.
///
/// ```swift
/// DispatchQueue.main.async {
/// Tree(with: bird).shake()
/// }
///
/// let expectation =
/// eventually {
/// verify(bird.fly()).wasCalled()
/// verify(bird.chirp()).wasCalled()
/// }
///
/// wait(for: [expectation], timeout: 1.0)
/// ```
///
/// - Parameters:
/// - description: An optional description for the created `XCTestExpectation`.
/// - block: A block containing verification calls.
/// - Returns: An XCTestExpectation that fulfilles once all verifications in the block are met.
public func eventually(_ description: String? = nil,
_ block: () -> Void) -> XCTestExpectation {
return createAsyncContext(description: description, block: block)
public extension NSObject {
/// Waits for the test to satisfy an array of expectations.
///
/// - Parameters:
/// - expectations: An array of expectations that must be fulfilled.
/// - seconds: The number of seconds within which all expectations must be fulfilled.
/// - enforceOrderOfFulfillment: If `true`, the expectations specified by the expectations
/// parameter must be satisfied in the order they appear in the array.
func wait(for expectations: [TestExpectation],
timeout seconds: TimeInterval,
enforceOrder enforceOrderOfFulfillment: Bool = false) {
guard let testCase = self as? XCTestCase else {
fatalError("Should never be called outside of a test case")
}
testCase.wait(for: expectations.map({ $0 as XCTestExpectation }),
timeout: seconds,
enforceOrder: enforceOrderOfFulfillment)
}

/// Create a deferrable test expectation from a block containing verification calls.
///
/// Mocked methods that are invoked asynchronously can be verified using an `eventually` block
/// which creates an `XCTestExpectation` and attaches it to the current `XCTestCase`.
///
/// ```swift
/// DispatchQueue.main.async {
/// Tree(with: bird).shake()
/// }
///
/// eventually {
/// verify(bird.fly()).wasCalled()
/// verify(bird.chirp()).wasCalled()
/// }
///
/// waitForExpectations(timeout: 1.0)
/// ```
///
/// - Parameters:
/// - description: An optional description for the test expectation.
/// - block: A block containing verification calls.
/// - Returns: An XCTestExpectation that fulfilles once all verifications in the block are met.
@discardableResult
func eventually(_ description: String = "Async verification group",
_ block: () -> Void) -> TestExpectation {
let expectation: XCTestExpectation = {
guard let testCase = self as? XCTestCase else {
return XCTestExpectation(description: description)
}
return testCase.expectation(description: description)
}()
createAsyncContext(expectation: expectation, block: block)
return TestExpectation.create(from: expectation)
}
}

/// Internal helper for `eventually` async verification scopes.
/// 1. Creates an attributed `DispatchQueue` scope which collects all verifications.
/// 2. Observes invocations on each mock and fulfills the test expectation if there is a match.
func createAsyncContext(description: String?, block scope: () -> Void) -> XCTestExpectation {
let testExpectation = XCTestExpectation(description: description ?? "Async verification group")
func createAsyncContext(expectation: XCTestExpectation, block scope: () -> Void) {
let group = ExpectationGroup { group in

testExpectation.expectedFulfillmentCount = group.expectations.count + group.subgroups.count

expectation.expectedFulfillmentCount = group.countExpectations()
print(expectation.expectedFulfillmentCount)
print("asdasfdasfd")
group.expectations.forEach({ capturedExpectation in
let observer = InvocationObserver({ (invocation, mockingContext) -> Bool in
do {
try expect(mockingContext,
handled: capturedExpectation.invocation,
using: capturedExpectation.expectation)
testExpectation.fulfill()
expectation.fulfill()
return true
} catch {
return false
Expand All @@ -58,7 +84,7 @@ func createAsyncContext(description: String?, block scope: () -> Void) -> XCTest
let observer = InvocationObserver({ (invocation, mockingContext) -> Bool in
do {
try subgroup.verify()
testExpectation.fulfill()
expectation.fulfill()
return true
} catch {
return false
Expand All @@ -73,6 +99,4 @@ func createAsyncContext(description: String?, block scope: () -> Void) -> XCTest
queue.sync { scope() }

try? group.verify()

return testExpectation
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class ExpectationGroup {
func addSubgroup(_ subgroup: ExpectationGroup) {
subgroups.append(subgroup)
}

func countExpectations() -> Int {
return expectations.count + subgroups.reduce(into: 0) { count, subgroup in
count += subgroup.countExpectations()
}
}
}

extension DispatchQueue {
Expand Down
Loading

0 comments on commit 12b8440

Please sign in to comment.