-
Notifications
You must be signed in to change notification settings - Fork 81
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
andrewchang-bird
merged 46 commits into
typealiased:master
from
andrewchang-bird:objc-support-clean
Aug 5, 2021
Merged
[RFC] Add support for Obj-C and partial mocks #217
andrewchang-bird
merged 46 commits into
typealiased:master
from
andrewchang-bird:objc-support-clean
Aug 5, 2021
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This is a small but necessary step to support running the framework on Linux.
69ab631
to
65f5d3a
Compare
4afac0d
to
4e0ead8
Compare
Discontinue support for Swift 5.2.4 due to Obj-C overloading ambiguity which isn't fixed with `@_disfavoredOverload`.
4e0ead8
to
4514a70
Compare
ryanmeisters
approved these changes
Aug 5, 2021
2 tasks
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 singleMKBMock
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.
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.
The workaround is to allow developers to annotate the argument positions, which is somewhat similar to but much less cumbersome than OCMockito’s syntax.
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).
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.
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.
Limitations
Due to overloading
given
andverify
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.