Skip to content

Commit

Permalink
Fix stubbing nil values in Obj-C mocks
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewchang-bird committed Jan 4, 2022
1 parent de2cd95 commit 40134ad
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 1 deletion.
4 changes: 4 additions & 0 deletions Mockingbird.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
28C8E5DB26A64D6C00C68A1D /* MKBInvocationHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = 28C8E5D926A64D6C00C68A1D /* MKBInvocationHandler.h */; };
28C8E5DC26A64D6C00C68A1D /* MKBInvocationHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 28C8E5DA26A64D6C00C68A1D /* MKBInvocationHandler.m */; };
28D08CD62775338100AE7C39 /* OptionGroupArgumentEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D08CD52775338100AE7C39 /* OptionGroupArgumentEncoding.swift */; };
28D08CCE2774247C00AE7C39 /* OptionalsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28D08CCD2774247C00AE7C39 /* OptionalsTests.swift */; };
28DAD96E251BDD66001A0B3F /* Project.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DAD96D251BDD66001A0B3F /* Project.swift */; };
28DDDFC126B8571D002556C7 /* DynamicCast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DDDFC026B8571D002556C7 /* DynamicCast.swift */; };
8356225C26A94CBE005CD5C5 /* TargetDescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8356225B26A94CBE005CD5C5 /* TargetDescriptionTests.swift */; };
Expand Down Expand Up @@ -623,6 +624,7 @@
28C8E5D926A64D6C00C68A1D /* MKBInvocationHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MKBInvocationHandler.h; sourceTree = "<group>"; };
28C8E5DA26A64D6C00C68A1D /* MKBInvocationHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MKBInvocationHandler.m; sourceTree = "<group>"; };
28D08CD52775338100AE7C39 /* OptionGroupArgumentEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionGroupArgumentEncoding.swift; sourceTree = "<group>"; };
28D08CCD2774247C00AE7C39 /* OptionalsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalsTests.swift; sourceTree = "<group>"; };
28DAD96D251BDD66001A0B3F /* Project.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Project.swift; sourceTree = "<group>"; };
28DDDFC026B8571D002556C7 /* DynamicCast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicCast.swift; sourceTree = "<group>"; };
8356225B26A94CBE005CD5C5 /* TargetDescriptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetDescriptionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1524,6 +1526,7 @@
OBJ_260 /* InitializerTests.swift */,
OBJ_261 /* LastSetValueStubTests.swift */,
28719AF426B23AB200C38C2C /* ObjectiveCTests.swift */,
28D08CCD2774247C00AE7C39 /* OptionalsTests.swift */,
OBJ_262 /* OrderedVerificationTests.swift */,
OBJ_263 /* OverloadedMethodTests.swift */,
28A1F3BF26ADA2A8002F282D /* PartialMockTests.swift */,
Expand Down Expand Up @@ -2179,6 +2182,7 @@
OBJ_1033 /* ExtensionsStubbableTests.swift in Sources */,
OBJ_1034 /* ExternalModuleClassScopedTypesMockableTests.swift in Sources */,
OBJ_1035 /* ExternalModuleClassScopedTypesStubbableTests.swift in Sources */,
28D08CCE2774247C00AE7C39 /* OptionalsTests.swift in Sources */,
287C4F5426A36FFF00A7E0D9 /* DynamicSwiftTests.swift in Sources */,
OBJ_1036 /* ExternalModuleTypesMockableTests.swift in Sources */,
OBJ_1037 /* ExternalModuleTypesStubbableTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,24 @@ void MKBThrowException(NSException *reason);

NSException *_Nullable MKBTryBlock(void(^_Nonnull NS_NOESCAPE block)(void));

/// Returns `true` if the value is equal to `NSNull`.
///
/// @param value The value to check.
///
/// Fully type erased optionals in Swift causes typical `nil` checks to fail. For example:
///
/// func erase<T>(_ value: T) {
/// print(value == nil) // false
/// print(value as Optional<Any> == nil) // false
/// print(value as? Optional<String> == nil) // false
/// print(value as! Optional<String> == nil) // true
/// }
/// erase(Optional<String>(nil))
///
/// Since Objective-C implicitly bridges to `NSNull`, an easy (albeit hacky) way to check if the
/// value is both an `Optional` and `nil` at runtime is to pass it Objective-C. Swift does support
/// referencing the `NSNull` instance, so callers need to check if the value is actually `NSNull` on
/// the Swift side.
bool MKBCheckIfTypeErasedNil(id _Nullable value);

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ void MKBThrowException(NSException *exception)
}
return nil;
}

bool MKBCheckIfTypeErasedNil(id _Nullable value)
{
return value == [NSNull null];
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ - (MKBArgumentMatcher *)serializeArgumentAtIndex:(NSUInteger)index

- (void)deserializeReturnValue:(id)returnValue forInvocation:(NSInvocation *)invocation
{
// Handle nil values from Swift.
if ([returnValue isKindOfClass:[MKBNilValue class]]) {
id _Nullable nilReturnValue = nil;
[invocation setReturnValue:&nilReturnValue];
return;
}

[invocation setReturnValue:&returnValue];
}

Expand Down
14 changes: 13 additions & 1 deletion Sources/MockingbirdFramework/Stubbing/StubbingContext+ObjC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import Foundation
// There's some weird bridging errors with Swift errors, so ErrorBox is just an abstract class
// that the Obj-C runtime can (responsibly) pull errors from using `performSelector:`.
}

/// Holds Swift errors which are bridged to `NSErrors`.
@objc(MKBSwiftErrorBox) public class SwiftErrorBox: ErrorBox {
@objc public let error: Error
init(_ error: Error) {
self.error = error
}
}

/// Holds Objective-C `NSError` objects.
@objc(MKBObjCErrorBox) public class ObjCErrorBox: ErrorBox {
@objc public let error: NSError?
Expand All @@ -27,6 +29,9 @@ import Foundation
}
}

/// Represents `nil` return values to prevent Swift from implicitly bridging to `NSNull`.
@objc(MKBNilValue) public class NilValue: NSObject {}

extension StubbingContext {
/// Used to indicate that no implementation exists for a given invocation.
@objc public static let noImplementation = NSObject()
Expand All @@ -40,9 +45,16 @@ extension StubbingContext {
@objc public func evaluateReturnValue(for invocation: ObjCInvocation) -> Any? {
let impl = implementation(for: invocation as Invocation)
do {
return try applyInvocation(invocation, to: impl)
let value = try applyInvocation(invocation, to: impl)
?? applyThrowingInvocation(invocation, to: impl)
?? Self.noImplementation
// It's possible to stub `NSNull` as a return value, so we need to check that this is an
// actual nil Swift value before creating a `NilValue` representation for Obj-C.
if !(value is NSNull) && MKBCheckIfTypeErasedNil(value) {
return NilValue()
} else {
return value
}
} catch let err as NSError {
return ObjCErrorBox(err)
} catch let err {
Expand Down
3 changes: 3 additions & 0 deletions Sources/MockingbirdTestsHost/Optionals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ protocol OptionalsProtocol {
func methodWithOptionalParameter(param: Bool?)
func methodWithOptionalVariadicParameter(param: Bool?...)
func methodWithOptionalReturn() -> Bool?
func methodWithOptionalBridgedReturn() -> NSString?

func methodWithMultiOptionalParameter(param: Bool???)
func methodWithMultiOptionalVariadicParameter(param: Bool???...)
func methodWithMultiOptionalReturn() -> Bool???
func methodWithMultiOptionalBridgedReturn() -> NSString???

func methodWithUnwrappedParameter(param: Bool!)
func methodWithUnwrappedReturn() -> Bool!
Expand All @@ -29,6 +31,7 @@ protocol OptionalsProtocol {
func methodWithMultiUnwrappedOptionalCompoundReturn() -> (Bool?, Int)???!

var optionalVariable: Bool? { get }
var optionalBridgedVariable: NSString? { get }
var unwrappedOptionalVariable: Bool! { get }
var multiUnwrappedOptionalVariable: Bool???! { get }
}
30 changes: 30 additions & 0 deletions Tests/MockingbirdTests/Framework/DynamicSwiftTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import XCTest
@objc dynamic func throwingMethod() throws {}
@objc dynamic public func trivialMethod() {}

@objc dynamic func methodReturningOptionalValue() -> String? { fatalError() }
@objc dynamic var optionalProperty: String? = nil

@objc dynamic var valueTypeProperty = false
@objc dynamic var bridgedTypeProperty = ""
@objc dynamic var referenceTypeProperty = Foundation.NSObject()
Expand Down Expand Up @@ -525,4 +528,31 @@ class DynamicSwiftTests: BaseTestCase {
verify(subclassMock.method(valueType: any())).wasCalled(twice)
wait(for: [expectation], timeout: 2)
}


// MARK: - Optionals

func testMethodReturningOptionalNilValue() throws {
given(classMock.methodReturningOptionalValue()).willReturn(nil)
XCTAssertNil(classMock.methodReturningOptionalValue())
verify(classMock.methodReturningOptionalValue()).wasCalled()
}

func testMethodReturningOptionalNonNilValue() throws {
given(classMock.methodReturningOptionalValue()).willReturn("foobar")
XCTAssertEqual(classMock.methodReturningOptionalValue(), "foobar")
verify(classMock.methodReturningOptionalValue()).wasCalled()
}

func testOptionalPropertyWithNilValue() throws {
given(classMock.optionalProperty).willReturn(nil)
XCTAssertNil(classMock.optionalProperty)
verify(classMock.optionalProperty).wasCalled()
}

func testOptionalPropertyWithNonNilValue() throws {
given(classMock.optionalProperty).willReturn("foobar")
XCTAssertEqual(classMock.optionalProperty, "foobar")
verify(classMock.optionalProperty).wasCalled()
}
}
56 changes: 56 additions & 0 deletions Tests/MockingbirdTests/Framework/OptionalsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// OptionalsTests.swift
// MockingbirdTests
//
// Created by typealias on 12/22/21.
//

import Mockingbird
@testable import MockingbirdTestsHost
import XCTest

class OptionalsTests: BaseTestCase {

var optionalsMock: OptionalsProtocolMock!
var optionalsInstance: OptionalsProtocol { optionalsMock }

override func setUpWithError() throws {
self.optionalsMock = mock(OptionalsProtocol.self)
}

func testStubNonNilReturnValue() {
given(optionalsMock.methodWithOptionalReturn()).willReturn(true)
XCTAssertEqual(optionalsInstance.methodWithOptionalReturn(), true)
verify(optionalsMock.methodWithOptionalReturn()).wasCalled()
}

func testStubNilReturnValue() {
given(optionalsMock.methodWithOptionalReturn()).willReturn(nil)
XCTAssertNil(optionalsInstance.methodWithOptionalReturn())
verify(optionalsMock.methodWithOptionalReturn()).wasCalled()
}

func testStubNonNilBridgedReturnValue() {
given(optionalsMock.methodWithOptionalBridgedReturn()).willReturn("foobar")
XCTAssertEqual(optionalsInstance.methodWithOptionalBridgedReturn(), "foobar")
verify(optionalsMock.methodWithOptionalBridgedReturn()).wasCalled()
}

func testStubNilBridgedReturnValue() {
given(optionalsMock.methodWithOptionalBridgedReturn()).willReturn(nil)
XCTAssertNil(optionalsInstance.methodWithOptionalBridgedReturn())
verify(optionalsMock.methodWithOptionalBridgedReturn()).wasCalled()
}

func testStubNonNilBridgedProperty() {
given(optionalsMock.optionalBridgedVariable).willReturn("foobar")
XCTAssertEqual(optionalsInstance.optionalBridgedVariable, "foobar")
verify(optionalsMock.optionalBridgedVariable).wasCalled()
}

func testStubNilBridgedProperty() {
given(optionalsMock.optionalBridgedVariable).willReturn(nil)
XCTAssertNil(optionalsInstance.optionalBridgedVariable)
verify(optionalsMock.optionalBridgedVariable).wasCalled()
}
}

0 comments on commit 40134ad

Please sign in to comment.