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

[RFC] Add support for Obj-C and partial mocks #217

Merged

Conversation

andrewchang-bird
Copy link
Contributor

@andrewchang-bird andrewchang-bird commented Aug 2, 2021

⚠️ This PR uses the commit history as the stack due to the large number of changes ⚠️

There are two notable gaps on the framework side that significantly impact the developer experience which this PR aims to address.

1. Mocking Objective-C Types

Motivation

The current recommendation for mocking Objective-C types (or any external types, really) with Mockingbird is to add a protocol extension and refactor existing references. Not only does it require changes to production code, but also results in significant boilerplate for types with delegates. Overall, it’s a sub-par developer experience.

Considerations

Rather than relying on protocol extensions, a better approach is to bridge the gap between Objective-C and Swift by mocking Objective-C types at runtime with NSProxy. This is how traditional Objective-C mocking frameworks work such as OCMock and OCMockito, although their APIs aren’t designed to be used from Swift.

One way of implementing Objective-C support is to add a Swift compatibility layer to OCMock or OCMockito that Mockingbird’s testing runtime can interface with in a type safe manner. Cuckoo uses this method by embedding OCMock and adding new APIs for Objective-C mocking.

After evaluating both OCMock and OCMockito, it was clear that this wasn’t a good fit for Mockingbird. Both frameworks are relatively thin around the actual mocking implementation which just uses the Objective-C runtime APIs, but much thicker around storing stubs, answering invocations, matching arguments, and so on. In addition, the testing APIs were similar—especially for OCMockito—but they weren’t a one-to-one match with what Mockingbird provides. Having a separate API flavor just for Objective-C would create more noise for developers, most of whom would only be mocking Swift types.

Writing a custom Objective-C mocking layer does come with some tradeoffs. Although the added complexity is less than the overhead of wrapping a third-party dependency, OCMock and OCMockito do have a more mature codebase simply from being around for longer. Mockingbird won’t have parity with more esoteric features like dynamic properties and singleton swizzling out of the gate, but it’s not unreasonable to expect the gap to shrink over time.

Implementation

At a high level, Mockingbird’s Objective-C mocking implementation is simply a thin Objective-C translation layer that forwards type erased invocations to the existing Swift testing runtime. Just like OCMock and OCMockito, the Objective-C translation layer uses the Objective-C runtime APIs to introspect the underlying mocked type and NSProxy to handle received invocations. One minor improvement over OCMock and OCMockito is unifying class and protocol mocking into a single MKBMock function that routes to the correct mock type.

Moving to the Swift layer, invocations received from Objective-C are treated the same as Swift invocations. The key difference is how mocked expressions are handled during stubbing and verification.

given(<expression>).willReturn()
verify(<expression>).wasCalled()

Unlike the generated Swift mock types which synthesize an overload for tests, Objective-C expressions must be evaluated inline without side effects. OCMock uses a macro expansion that wraps the expression and stores a bit of state on the current thread dictionary. Macros aren’t available in Swift, so Mockingbird achieves a similar result with an autoclosure and dispatch queue for thread safety—not unlike how type facades work.

Type facades also pose a few problems when used in Objective-C expressions. For generated Swift mocks, each argument has its own execution context which allows type facades to be resolved to either a real value or an argument matcher. Objective-C type facades are evaluated in the same execution context as the expression and must return a real value. Additionally, although Swift’s calling convention is well documented, it’s not possible to determine the argument positions when some arguments are type facades and others are real values.

bird.method("a", "b")     // Trivial case
bird.method(any(), any()) // Inferable
bird.method("a", any())   // Cannot be inferred

The workaround is to allow developers to annotate the argument positions, which is somewhat similar to but much less cumbersome than OCMockito’s syntax.

bird.method("a", secondArg(any()))

Limitations

It’s not possible to have automatic type-safe closure stubs for Objective-C without parsing the Swift interfaces and doing some codegen. Instead, developers must add explicit parameter types to the closure—which isn’t the most ergonomic—but a step up from receiving an unsafe array of arguments (untyped and a non-fixed size).

given(bird.method(any())).will { (param: String) in
  print(param)
}

2. Creating Partial Mocks

Motivation

Mockingbird historically hasn’t allowed partial mocks in order to dissuade developers from writing brittle tests that depend on the underlying type’s behavior. That said, there are occasionally good reasons to use them, such as when mocking externally defined types, and most frameworks do include some level support of creating partial mocks.

Considerations

Partial mock terminology can be confusing when coming from other frameworks. Mockito calls them “spies” in reference to how the mock observes (and then mimics) the behavior of a real object. Mockito spies make an internal copy of the object it’s observing, so any interaction on the real object is completely sandboxed from the spy and vice versa. Contrast that to OCMock which mutates the real object instead of wrapping it, such that stubs affect both the mock and the real object.

The partial mock implementations of both Mockito and OCMock are surprising for their side effects or lack thereof. It’s simpler to think about partial mocks as a kind of proxy that simply forwards invocations to the real object. It guarantees a one-way relation where adding stubs to the mock doesn’t affect the real object, but mutating the real object automatically propagates those changes to the mock.

let realObject = Array<Int>()
let partialMock = mock(realObject)
given(partialMock.removeAll()).will { /* no-op */ }

// Mockito
realObject.append(1).  // realObject: [1], partialMock: []
partialMock.append(2)  // realObject: [1], partialMock: [2]
realObject.removeAll() // realObject: [], partialMock: [2]

// OCMock
realObject.append(1)   // realObject: [1], partialMock: [1]
partialMock.append(2)  // realObject: [1,2], partialMock: [1,2]
realObject.removeAll() // realObject: [1,2], partialMock: [1,2]

// Mockingbird
realObject.append(1)   // realObject: [1], partialMock: [1]
partialMock.append(2)  // realObject: [1,2], partialMock: [1,2]
realObject.removeAll() // realObject: [], partialMock: []

Implementation

Partial mocks were a direct result of the Objective-C NSProxy mocking implementation. The original idea was to abstract the thunks by bundling the invocation with its functional calling context (e.g. self.method) and forwarding that to the framework to apply. Unfortunately it’s not always possible to forward arguments due to rethrowing methods and closure types, so partial mock invocation forwarding is applied inline within the thunk.

The API is designed to make it clear that calls will be forwarded to the passed object, referred to as the “forwarding target.” It’s possible to create a forwarding chain by adding multiple forwarding targets to a mock, but for now only the last (valid) target will receive invocations.

// Object
let bird = mock(Bird.self)
let crow = Crow(name: "Ryan")
bird.forwardCalls(to: crow) // Global
given(bird.name).willForward(to: crow) // Scoped

// Superclass
let crow = mock(Crow.self)
crow.initialize(name: "Ryan").forwardCallsToSuper() // Global
given(crow.name).willForwardToSuper() // Scoped

Limitations

Due to overloading given and verify for Objective-C mocks, it’s not possible to restrict forwarding targets to inherit from the mocked type. Adding forwarding targets that are unrelated to the mocked type is safe and a no-op, but not ideal. In addition, generic protocols are not yet supported as it requires generating a separate mock type that allows developers to specify a base forwarding target type.

Deprecates `matcher` property for `any()` to allow Obj-C overloading.
Enables better composition between various wildcard argument matchers.

Old: `any(at: <index>)`
New: `arg(any(), at: <index>)
The previous method of halting tests on a fatal error by setting
`continueAfterFailure` to `true` no longer works in Xcode 12. Switches
to raising a synthetic Obj-C exception which causes the underlying
`XCTest` Objective-C runner to unwind back to the call site.
Obj-C exceptions can be raised and caught on the same execution context,
which means that declaration expressions no longer need to be marked as
`@escaping` with an explicit `self` capture.
@andrewchang-bird andrewchang-bird added this to the Release 0.18 milestone Aug 2, 2021
@andrewchang-bird andrewchang-bird force-pushed the objc-support-clean branch 5 times, most recently from 69ab631 to 65f5d3a Compare August 2, 2021 20:44
@andrewchang-bird andrewchang-bird force-pushed the objc-support-clean branch 2 times, most recently from 4afac0d to 4e0ead8 Compare August 5, 2021 07:27
Discontinue support for Swift 5.2.4 due to Obj-C overloading ambiguity
which isn't fixed with `@_disfavoredOverload`.
@andrewchang-bird andrewchang-bird merged commit d8399b6 into typealiased:master Aug 5, 2021
@andrewchang-bird andrewchang-bird deleted the objc-support-clean branch August 5, 2021 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature-Request: Enable calling original implementation if no stub is provided.
2 participants