Kolos65 / Mockable

A Swift macro driven auto-mocking library.
MIT License
199 stars 14 forks source link

Cannot find 'Type' in scope and lack of typing despite mock generation #46

Closed kelvinharron closed 1 month ago

kelvinharron commented 2 months ago

Hey there. I've recently adopted Mockable as an alternative to Mockolo in our project as we scale up our use of modules. My first impressions are super positive, thank you for sharing this! I have just found an issue I can't reliably get around.

If I have declared a Protocol in my dynamic Framework target called APIKit, and I declare MockKeychainServiceType in my tests target called APIKitTests, I can't get any typing on it even though it actually does compile the mock correctly and I can run my tests against it.

@Mockable
public protocol KeychainServiceType {
    func store(data: Data, for key: SecureKey) throws
    func retrieve(with key: SecureKey) -> Data?
    func clear()
}

and I can see the declaration when expanding the macro, which is correct.

#if MOCKING
public final class MockKeychainServiceType: KeychainServiceType, MockService {
    private var mocker = Mocker<MockKeychainServiceType>()
    @available(*, deprecated, message: "Use given(_ service:) of Mockable instead. ")

    public func given() -> ReturnBuilder {
        .init(mocker: mocker)
    }
    @available(*, deprecated, message: "Use when(_ service:) of Mockable instead. ")

    public func when() -> ActionBuilder {
        .init(mocker: mocker)
    }
    @available(*, deprecated, message: "Use verify(_ service:) of MockableTest instead. ")

    public func verify(with assertion: @escaping MockableAssertion) -> VerifyBuilder {
        .init(mocker: mocker, assertion: assertion)
    }
    public func reset(_ scopes: Set<MockerScope> = .all) {
        mocker.reset(scopes: scopes)
    }
    public init(policy: MockerPolicy? = nil) {
        if let policy {
            mocker.policy = policy
        }
    }
    public func store(data: Data, for key: SecureKey) throws {
        let member: Member = .m1_store(data: .value(data), for: .value(key))
        try mocker.mockThrowing(member) { producer in
            let producer = try cast(producer) as (Data, SecureKey) throws -> Void
            return try producer(data, key)
        }
    }
    public func retrieve(with key: SecureKey) -> Data? {
        let member: Member = .m2_retrieve(with: .value(key))
        return mocker.mock(member) { producer in
            let producer = try cast(producer) as (SecureKey) -> Data?
            return producer(key)
        }
    }
    public func clear() {
        let member: Member = .m3_clear
        mocker.mock(member) { producer in
            let producer = try cast(producer) as () -> Void
            return producer()
        }
    }
    public enum Member: Matchable, CaseIdentifiable {
        case m1_store(data: Parameter<Data>, for: Parameter<SecureKey>)
        case m2_retrieve(with: Parameter<SecureKey>)
        case m3_clear
        public func match(_ other: Member) -> Bool {
            switch (self, other) {
            case (.m1_store(data: let leftData, for: let leftFor), .m1_store(data: let rightData, for: let rightFor)):
                return leftData.match(rightData) && leftFor.match(rightFor)
            case (.m2_retrieve(with: let leftWith), .m2_retrieve(with: let rightWith)):
                return leftWith.match(rightWith)
            case (.m3_clear, .m3_clear):
                return true
            default:
                return false
            }
        }
    }
    public struct ReturnBuilder: EffectBuilder {
        private let mocker: Mocker<MockKeychainServiceType>
        public init(mocker: Mocker<MockKeychainServiceType>) {
            self.mocker = mocker
        }
        public func store(data: Parameter<Data>, for key: Parameter<SecureKey>) -> ThrowingFunctionReturnBuilder<MockKeychainServiceType, ReturnBuilder, Void, (Data, SecureKey) throws -> Void> {
            .init(mocker, kind: .m1_store(data: data, for: key))
        }
        public func retrieve(with key: Parameter<SecureKey>) -> FunctionReturnBuilder<MockKeychainServiceType, ReturnBuilder, Data?, (SecureKey) -> Data?> {
            .init(mocker, kind: .m2_retrieve(with: key))
        }
        public func clear() -> FunctionReturnBuilder<MockKeychainServiceType, ReturnBuilder, Void, () -> Void> {
            .init(mocker, kind: .m3_clear)
        }
    }
    public struct ActionBuilder: EffectBuilder {
        private let mocker: Mocker<MockKeychainServiceType>
        public init(mocker: Mocker<MockKeychainServiceType>) {
            self.mocker = mocker
        }
        public func store(data: Parameter<Data>, for key: Parameter<SecureKey>) -> ThrowingFunctionActionBuilder<MockKeychainServiceType, ActionBuilder> {
            .init(mocker, kind: .m1_store(data: data, for: key))
        }
        public func retrieve(with key: Parameter<SecureKey>) -> FunctionActionBuilder<MockKeychainServiceType, ActionBuilder> {
            .init(mocker, kind: .m2_retrieve(with: key))
        }
        public func clear() -> FunctionActionBuilder<MockKeychainServiceType, ActionBuilder> {
            .init(mocker, kind: .m3_clear)
        }
    }
    public struct VerifyBuilder: AssertionBuilder {
        private let mocker: Mocker<MockKeychainServiceType>
        private let assertion: MockableAssertion
        public init(mocker: Mocker<MockKeychainServiceType>, assertion: @escaping MockableAssertion) {
            self.mocker = mocker
            self.assertion = assertion
        }
        public func store(data: Parameter<Data>, for key: Parameter<SecureKey>) -> ThrowingFunctionVerifyBuilder<MockKeychainServiceType, VerifyBuilder> {
            .init(mocker, kind: .m1_store(data: data, for: key), assertion: assertion)
        }
        public func retrieve(with key: Parameter<SecureKey>) -> FunctionVerifyBuilder<MockKeychainServiceType, VerifyBuilder> {
            .init(mocker, kind: .m2_retrieve(with: key), assertion: assertion)
        }
        public func clear() -> FunctionVerifyBuilder<MockKeychainServiceType, VerifyBuilder> {
            .init(mocker, kind: .m3_clear, assertion: assertion)
        }
    }
}
#endif

An example of how I use it in the test is as follows.

@testable import APIKit
import Foundation
import MockableTest
import Nimble
import Quick

class AuthenticationServiceSpec: AsyncSpec {
    override class func spec() {
        let keychainService = MockKeychainServiceType() // compiler does not know what this is
        ...
Screenshot 2024-05-01 at 07 39 56

Do you have any recommendations on how to debug this? I am going to restart my modularisation effort to see if I can source the cause. I've tried clearing derived data and reboot.

Xcode 15.3 macOS 14.4.1 M1 Macbook Pro

Kolos65 commented 2 months ago

Did you define the MOCKING flag for your framework? Check out the configuration guide.

kelvinharron commented 2 months ago

Hey @Kolos65 thank you for the swift reply! Yep, with the lates tTuist version, I have defined it as required in the doc. Does this only need to be applied to the target I want to use @Mockable for? and given that the Test target depends on the actual target, will we inherit it that way?

settings: .settings(
                configurations: [
                    .debug(name: "Debug",
                           settings: [
                               "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) MOCKING",
                               "ENABLE_MODULE_VERIFIER": true
                           ]),
                    .release(name: "Release",
                             settings: [
                                 "ENABLE_MODULE_VERIFIER": true
                             ])
                ]
            )
Kolos65 commented 2 months ago

You only need to define it in modules where you want to attach the macro.

Im still suspecting the flag to be the cause of this..

Can you check if it works if you remove @ Mockable from your protocol and copy the contents of the macro expansion next to it (also remove the #if MOCKING)?

kelvinharron commented 2 months ago

When I copy the contents out, it works perfectly, indexing for my test target, and I can access it in my tests.

I suspect that because I am telling tuist to treat "Mockable" as a dynamic framework instead of static, to fix some warnings, this could be upsetting the implementation as I used it as a static framework for a day without any problems. I did get the typing back briefly there before I tried your suggestion of copying the contents, leave it with me and I'll see what I need to do. Thanks Kolos!

kelvinharron commented 2 months ago

I can't reproduce the issue now so I'll close this unless my team members have the same issue and the steps I've walked through do not help.

For anyone with the same issue and using Tuist, I found this setup to help. In your root Tuist/Package.swift file, define Mockable and MockableTest as dynamic frameworks with the following.

#if TUIST
    import ProjectDescription
    let packageSettings = PackageSettings(
        productTypes: [
            "Mockable": .framework,
            "MockableTest": .framework,
        ],

    )
#endif

Thanks again Kolos! ๐Ÿ˜„

kelvinharron commented 2 months ago

@Kolos65 To revisit this, I asked a member of the team to check out the branch I've been working on, where Mockable and its flag are applied to both a framework and an app target, and MockableTest is applied to both the framework's test target and the app target. While the app and framework targets compile and run correctly, even for testing, we get Xcode errors that prevent our generated mocks from being found even though they're compiled in the macro.

We used your workaround to copy the macro's contents, remove the @Mockable macro, build the target, and then reset it to use the macro. Voila, all generated Mocks are now accessible and have typing access.

Let me put together a project for you. Tuist may have some quirks, but they could be due to our modular setup. Leave it with me! ๐Ÿ˜„

Kolos65 commented 2 months ago

"We used your workaround to copy the macro's contents"

That was not a workaround, just an idea to test what the issue might be... If it compiles with the #if MOCKING flags removed, then the issue must be with the definition of the 'MOCKING' compile condition.

An other way to test this is to define a type like this (next to where your protocols are):

#if MOCKING
public struct TestStruct {
    public init() {}
}
#endif

And try to use it in your unit test:

func test() {
    let test = TestStruct()
}

I think Xcode 15 and above will even grey out the struct definition if the compile condition is false.

Kolos65 commented 2 months ago

If you put together an example project, I can take a look!

kelvinharron commented 1 month ago

Hey @Kolos65 , apologies for no follow up here. I haven't been able to reproduce this since and until I can with the refactor of the rest of our legacy mocks into Mockable, I'd say it's time to close this. ๐Ÿ‘ Thanks.