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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Refactoring Moya's stubbing API #1502

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open

Conversation

SD10
Copy link
Member

@SD10 SD10 commented Dec 12, 2017

Summary

This is purely experimental but I was dabbling with the idea of defining the stubbing API in terms of TestTargetType as opposed to configuring a MoyaProvider.

@Moya/contributors I would appreciate if any of you could review this and provide feedback 馃檹

How?

We add a protocol that allows Moya users to provide the sample response and stub behavior:

protocol TestTargetType {
    var sampleResponse: EndpointSampleResponse { get }
    var stubBehavior: Moya.StubBehavior { get }
}

Then internally, we use a where clause on MoyaProvider to determine if the Target we are handling is capable of stubbing requests.

extension MoyaProvider where Target: TestTargetType {
/// ... stubbing methods here
}

It all comes down to this method:

func performRequest(_ target: Target, request: URLRequest, callbackQueue: DispatchQueue?, progress: Moya.ProgressBlock?, completion: @escaping Moya.Completion, endpoint: Endpoint<Target>) -> Cancellable

When Target != TestTargetType this version is called:

extension MoyaProvider {
    private func performRequest(_ target: Target, request: URLRequest, callbackQueue: DispatchQueue?, progress: Moya.ProgressBlock?, completion: @escaping Moya.Completion, endpoint: Endpoint<Target>) -> Cancellable {
        return self.performNormalRequest(target, request: request, callbackQueue: callbackQueue, progress: progress, completion: completion, endpoint: endpoint)
    }
}

When Target == TestTargetType this version is called:

extension MoyaProvider where Target: TestTargetType {
    private func performRequest(_ target: Target, request: URLRequest, callbackQueue: DispatchQueue?, progress: Moya.ProgressBlock?, completion: @escaping Moya.Completion, endpoint: Endpoint<Target>) -> Cancellable {
        switch target.stubBehavior {
        case .never:
            return self.performNormalRequest(target, request: request, callbackQueue: callbackQueue, progress: progress, completion: completion, endpoint: endpoint)
        default:
            return self.stubRequest(target, request: request, callbackQueue: callbackQueue, completion: completion, endpoint: endpoint, stubBehavior: target.stubBehavior)
        }
    }
}

Pros:

  • Removes stubClosure from MoyaProvider because you can control behavior in TestTargetType
  • Moves all stubbing behavior to MoyaProvider+Stubbing.swift
  • Providers not using a TestTargetType need not be concerned with anything related to stubbing and perform no checks regarding stubbing at runtime

Cons:

  • That's what I want to hear from you all 馃槄 I'm biased and blind to my own system

Final thoughts

This breaks a lot of tests, I have not fixed them because it will take some work and I didn't want to start if people think this may not be a good change for the project. However, I think that fixing those tests would help me better understand any drawbacks to this approach.

// TODO: - Not sure how this would affect sample data
// public var sampleData: Data {
// return target.sampleData
// }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also the only thing I haven't been able to get around with this PR. I don't use the MultiTarget so I don't really understand it's purpose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is tricky. I will think about this one, but two ideas that come to my mind right now are:

  1. Create two MultiTargets, one that stubs and one that does not (like with target types)
  2. If target is not a Testing one, use empty data/crash, but this is not the best solution I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is very low priority IMO. It won't make it in Moya 12.0 for sure and I'd like a few Moya members to discuss if they even like this change.

Copy link
Member Author

@SD10 SD10 Mar 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisited this today... I have a feeling it "just works" now that I understand what MultiTarget is doing.

If the multi-target type is a TestTargetType the user will have to specify where to get sampleData from.

Following the pattern of the existing properties, they would have to extract the associated value of the target case. Then they would have to cast to TestTargetType to access the underlying sampleData.

If casting fails they still have to return something because sampleData is non-optional. Really hard to go unnoticed unless you're already writing bad code to begin with.

So as the other properties are doing, they would unwrap the associated value of the enum and return the data from the nested target.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SD10 Sorry, I'm not sure I follow... Are you saying that we won't provide any support for MultiTarget with TestTargetType so the users would have to do it themselves?

Copy link
Member Author

@SD10 SD10 Mar 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sunshinejr I wouldn't say "we won't provide any support" but I was going to suggest that they extend MultiTarget to conform to TestTargetType inside their test target. We could add a MultiTestTarget if you believe this is expecting too much.

I guess the latter also has some added type safety, because we can ensure that the associated value in the enum case is a TestTargetType as well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SD10 Oh, sorry if it sounded like that, I was just trying to understand if that's what you would do. I don't think there is a 100% good solution for this, MultiTestTarget is okay, but still doesn't cover all cases (like mixed targets & test targets). I wonder if making few examples instead would be enough... What do you think?

Copy link
Member Author

@SD10 SD10 Mar 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MultiTestTarget would cover all cases because the enum associated value would have to be TestTargetType as well.

An edge case would be a MultiTarget with an inner TestTargetType. Stubbing would be determined by the type of the root target, MultiTarget, so it would never stub.

We could document this. Even provide an assertion/error if the inner target is a TestTargetType but MultiTarget is not? The only way to fully prevent this is to check every target at runtime by trying to cast it to TestTargetType.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, the edge case allows you to opt in or out of testing by wrapping your TargetType or TestTargetType. It's valuable behavior in a way 馃槀

Let's sit on this a bit more for me to explore casting. To be honest, I really liked the code separation that the where clause provides, but if I can protect people from their own design decisions then that should be explored.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realized last night in a dream that nothing can be done with casting. The MultiTarget abstracts away any sense of an underlying TargetType.

@BasThomas
Copy link
Contributor

This is super cool - thanks for sharing your thoughts and code @SD10; much appreciated. I鈥檒l give this a closer inspection soon 馃憤

Sent with GitHawk

Copy link
Member

@freak4pc freak4pc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea in general. I think it provides a less involved approach.

I couldn't exactly understand where you define the headers - since I noticed you removed those everywhere.

Also have a few styling nits / thoughts I'd love some comments on :)

Great work!

notifyPluginsOfImpendingStub(for: request, target: target)
let plugins = self.plugins
let stub: () -> Void = createStubFunction(cancellableToken, forTarget: target, withCompletion: completion, endpoint: endpoint, plugins: plugins, request: request)
switch stubBehavior {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind breaking this up to logical portions with some newlines? (before the switch, before and after the notifyPlugins, etc)

It's a bit hard to chew down this way

case .delayed(let delay):
let killTimeOffset = Int64(CDouble(delay) * CDouble(NSEC_PER_SEC))
let killTime = DispatchTime.now() + Double(killTimeOffset) / Double(NSEC_PER_SEC)
(callbackQueue ?? DispatchQueue.main).asyncAfter(deadline: killTime) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do do .asyncAfter(deadline: .now() + delay) { IIRC

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should work as well. Good clean up 馃憤

/// Creates a function which, when called, executes the appropriate stubbing behavior for the given parameters.
public final func createStubFunction(_ token: CancellableToken, forTarget target: Target, withCompletion completion: @escaping Moya.Completion, endpoint: Endpoint<Target>, plugins: [PluginType], request: URLRequest) -> (() -> Void) { // swiftlint:disable:this function_parameter_count
return {
if token.isCancelled {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would read better as

guard !token.isCancelled else { 
    self.cancelCompletion(completion, target: target)
    return
}

IMO

let expectedData = "sample data".data(using: .utf8)!
expect(target.sampleData).to(equal(expectedData))
}
// it("uses correct sample data") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's commented out, just remove it :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I commented it out temporarily. I should've added a TODO. I don't think I should be reducing test coverage, but rather fix whatever tests I break 馃槄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a TODO is always good :) But if your PR is WIP and you remember to fix this later than its fine :)


/// Return a response after a delay.
case delayed(seconds: TimeInterval)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really great ideas here ! Super nice work @SD10 .

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

@MoyaBot
Copy link

MoyaBot commented Dec 19, 2017

4 Warnings
鈿狅笍 PR is classed as Work in Progress
鈿狅笍 Big PR, try to keep changes smaller if you can
鈿狅笍 Any changes to library code should be reflected in the Changelog. Please consider adding a note there and adhere to the Changelog Guidelines.
鈿狅笍 Consider adding supporting documentation to this change. Documentation can be found in the docs directory.

SwiftLint found issues

Warnings

File Line Reason
MultiTarget.swift 24 TODOs should be avoided (- Not sure how this would affe...).
EndpointSpec.swift 274 Colons should be next to the identifier when specifying a type and next to the key in dictionary literals.
EndpointSpec.swift 275 Colons should be next to the identifier when specifying a type and next to the key in dictionary literals.
MoyaProviderSpec.swift 978 Files should have a single trailing newline.

Generated by 馃毇 Danger

@SD10
Copy link
Member Author

SD10 commented Jan 9, 2018

I'm tagging #1539 because I think it's an issue that will help me better analyze potential cons with this approach.

@SD10
Copy link
Member Author

SD10 commented Mar 8, 2018

Revisited this today with an even better understanding of Moya's internals.

Currently, to stub a request in Moya you have to configure the stubbed request in 3 different places:

  1. You need to provide sampleData in the TargetType protocol
  2. You need to provide a StubBehavior through the stubClosure property of MoyaProvider
  3. You need to provide a new endpointClosure to a MoyaProvider if you want to manipulate the mapping of a TargetType to EndpointSampleResponse

I think the drawback of this approach is it requires so much configuration in many different places throughtout the library. This also results in intertwining testing logic with the business logic of your codebase.

Furthermore, stubClosure and endpointClosure both take in a TargetType as their only parameters.

I think this alludes to the behavior that people really want: to customize behavior per TargetType.

So, this PR aims to address this issue by making all these things configurable in the TargetType directly. Looking for any feedback from @Moya/contributors again

@sunshinejr
Copy link
Member

@SD10 I know that we are still trying to pass this one, so please rebase it when you find time so we can review it again.

@gligorkot
Copy link

Hey @SD10, just wanted to chime in as an outsider as I've been using Moya since v7.0 and love it. I think this would really be a worthwhile change. I've been reading through the issues and I believe this would address #1126 so I thought I'd link it here as well, but correct me if I'm wrong?

One thing I wanted to add, I am big on unit testing my frameworks and have been writing tests for the network layer for a while, so having the stubs really makes this easy; however if it was possible to only include the stubs and TestTargetType in the UnitTest package this would be awesome! Wasn't sure if this was your intention so I thought I'd add it.

Happy to provide help on this matter and jump more into the open source waters.

@SD10
Copy link
Member Author

SD10 commented May 24, 2018

@gligorkot Thanks for the feedback 馃挴 This does in fact address #1126 and I agree it's a worthwhile change.

It allows greater flexibility, a better separation of concerns, and would allow us to remove MoyaProvider's closure based customization in favor of plugins (should we choose to do so) in the future.

I do plan on fixing this up, however, I just started a new job so my time for open source has significantly decreased. Over the next month, I will be moving our apps networking layer to Moya, so after that, I will look at this again.

If the Moya community accepts this proposal, I don't see this change being made until Moya 13.

In the meantime, if you want to move your tests to the test target this work around could work?

// In your app target
extension TargetType {
   var data: Data { return Data() }
}

// In your test target
extension YourObjectThatConformsToTargetType {
    var data: Data { 
          // return your real data here
    }
}

This should work because the extension on your object will override the implementation provided on theTargetType protocol.

@gligorkot
Copy link

@SD10 thanks for getting back to me and providing the workaround, however I just tried it and the extension implementation didn't override the TargetType implementation in this case, so I'm back to including my sampleData in the main target instead of only the test target. Hopefully this makes it in the goals for one of the next versions of Moya.

@SD10
Copy link
Member Author

SD10 commented Jul 12, 2018

@gligorkot This will definitely be in Moya 13.0 if the community agrees it's a good approach.

Regarding your work around, I tried the exact same thing yesterday and figured out you can't change the dynamic dispatch for the Target argument of the defaultEndpointMapping closure in the test target. I got around this by providing my own endpointClosure to the MoyaProvider in the test target. The Endpoint closure may also need it's target argument typed as your specialized TargetType.

// In your test target
let endpointClosure: (MyTargetType) -> Endpoint = { target in
   return Endpoint(...
}

@amaurydavid
Copy link
Contributor

amaurydavid commented Nov 6, 2018

I like the idea of stripping down the stub configuration to only a specific target type and its 2 properties.
If the intention is great, I don't think it is easy enough to use for every stubbing use cases.

As I see it, there are 2 main usages of request stubbing: creating unit tests, and having a placeholder content for a soon-to-be-live route of our favorite API.

For the first case, this solution is functionnal: just replace TargetType by TestTargetType in the mock target, implement the 2 new properties, and if needed, also replace MultiTarget by MultiTestTarget. Nothing more to be done to support stubbing.

As of the second case, it's more a boilerplate. Let's say you start with a target without stub, and you want to support a new route that will be released soon:

  • replace TargetType by TestTargetType
  • implement the 2 new properties
  • replace MultiTarget by MultiTestTarget if needed
    And then, when the API is live, you'll have to remove the stubbing code:
  • replace TestTargetType by TargetType
  • remove the 2 old properties
  • replace MultiTestTarget by MultiTarget if needed

Finally, repeat again when a new route comes up.
It's ok to do all that for one route, but after having to change types twice we'll consider just using TestTargetType and MultiTestTarget permanently to avoid changing types everytime. Or you will juste forget to change the types.
Moreover, if you are a new dev on the project, you will try to make as few changes as possibles, and changing types can be too much a worry.

So great solution for unit tests, not so great for temporary stubbing.

Speaking of temporary stubbing, could you consider making the sampleResponse being Optional? It would prevent having to return Data() for a route using StubBehavior.never. Or even better to me: merging those 2 properties by having the Data as associated value of the StubBehavior (as proposed on #1754 ).

@gligorkot gligorkot mentioned this pull request Nov 14, 2018
@SD10
Copy link
Member Author

SD10 commented Nov 15, 2018

Hey @amaurydavid, thanks so much for taking the time to review this 鉂わ笍 Unfortunately I can only give a brief response.

As I see it, there are 2 main usages of request stubbing: creating unit tests, and having a placeholder content for a soon-to-be-live route of our favorite API.

I think this is a really good way of summarizing things 馃憤

For the first case, this solution is functionnal: just replace TargetType by TestTargetType in the mock target, implement the 2 new properties, and if needed, also replace MultiTarget by MultiTestTarget.

It is a bit concerning that you would need to replace MultiTarget with MultiTestTarget. Unfortunately, that downside is related to the abstraction of MultiTarget itself

As of the second case, it's more a boilerplate. Let's say you start with a target without stub, and you want to support a new route that will be released soon:

  • replace TargetType by TestTargetType
  • implement the 2 new properties
  • replace MultiTarget by MultiTestTarget if needed
  • And then, when the API is live, you'll have to remove the stubbing code:

Good point, but I think there's always some overhead around mocking an API. I would probably leverage some compiler directives so that the TestTargetType conformance is only implemented when using something like a development/staging/qa target

Speaking of temporary stubbing, could you consider making the sampleResponse being Optional? It would prevent having to return Data() for a route using StubBehavior.never. Or even better to me: merging those 2 properties by having the Data as associated value of the StubBehavior (as proposed on #1754 ).

The idea of combining the two is interesting! I briefly reviewed that issue but would have to explore the implications of this further.

Thanks for giving me a bunch of things to think about! 馃憤

@amaurydavid
Copy link
Contributor

Hello :) Is there any update on this matter? The subject is opened since more than 1 year now :|

Base automatically changed from development to master September 4, 2021 12:29
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.

None yet

7 participants