MakeAWishFoundation/SwiftyMocky

Feature request: Allow partial mocks for testable subclasses

mapierce opened this issue · 0 comments

How we use SwiftyMocky

Normally when building out new components or modules, we adopt the paradigm of a protocol as an interface. This lends itself really nicely to mocking with SwiftyMocky. As part of this, we prescribe to the idea that public functions in a class are only public if they are also in the protocol the class is conforming to. Other functions in the class that aren't in the protocol are still technically public, but as we'll use an instance of the class that has a type of the protocol, these methods won't be accessible. This means they can be interpreted as private, but can still be called during testing.

Example

// sourcery: AutoMockable
protocol HomeViewInterface {
    func showLoading(show: Bool)
    func updateData(someData: [Any])
}

protocol HomeViewModelInterface {
    func setupView()
    func buttonTapped()
}

class HomeViewController: UIViewController, HomeViewInterface {

    var viewModel: HomeViewModelInterface!

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.setupView()
    }

    // MARK: - HomeViewInterface methods

    func showLoading(show: Bool) {
        // Hide / show loading...
    }

    func updateData(someData: [Any]) {
        // Update data...
    }

}

class HomeViewModel: HomeViewModelInterface {

    private unowned let view: HomeViewInterface

    init(view: HomeViewInterface) {
        self.view = view
    }

    // MARK: - HomeViewModelInterface methods

    func setupView() {
        view.updateData(someData: [])
    }

    func buttonTapped() {
        view.showLoading(true)
    }

}

With this setup, it's really easy for us to test setupView() and buttonTapped() with SwiftyMocky by just doing

Verify(view, .updateData(.value([])))

The problem

If we update our HomeViewModel to perform so sorting or mutating in the setupView function before we call out to the view.updateData function, things get a little more complicated. Here's an updated example of the HomeViewModel with that change:

class HomeViewModel: HomeViewModelInterface {

    private unowned let view: HomeViewInterface

    init(view: HomeViewInterface) {
        self.view = view
    }

    // MARK: - HomeViewModelInterface methods

    func setupView() {
        let data = []
        let updatedData = injectAdditionalParts(to: data)
        view.updateData(someData: updatedData)
    }

    func buttonTapped() {
        view.showLoading(true)
    }

    // MARK: - Private methods

    func injectAdditionalParts(to data: [Any]) -> [Any] {
        // Add some new items to `data`
        return data
    }

}

Now when we go to test our setupView function, things have changed a bit. We can update our Verify call and edit the value we expect to get. Because injectAdditionalParts is also public, we can create a test for it and pass data into it and confirm it's returning what it should be. With those two updates we have things fairly well covered. But we only have them covered because injectAdditionalParts is returning a value. If this function had a method signature like func trackMetricForViewDidLoad() with no return value, how do we test that? We could expose it to the protocol and call it directly from the view. This isn't great though because we're now moving business logic decisions into our view. If we just want to check that a method has been called from another method, we have no easy way of doing this.

Proposal

When we setup our tests for the HomeViewModel it would look something like this:

class HomeViewModelTests: XCTestCase {

    private var view: HomeViewInterfaceMock!
    private var viewModel: HomeViewModel!

    override func setUp() {
        super.setUp()
        view = HomeViewInterfaceMock()
        viewModel = HomeViewModel(view: view)
    }

}

My proposal is that we can mark the class HomeViewModel with another annotation like:

// sourcery: AutoMockablePartial

which would create a new HomeViewModelPartialMock, this could then be used in the following way:

class HomeViewModelTests: XCTestCase {

    private var view: HomeViewInterfaceMock!
    private var viewModel: HomeViewModelPartialMock!

    override func setUp() {
        super.setUp()
        view = HomeViewInterfaceMock()
        viewModel = HomeViewModelPartialMock(view: view, mockedMethods: [.injectAdditionalParts])
    }

}

Here HomeViewModelPartialMock would subclass HomeViewModel and override only the injectAdditionalParts function, or any others passed in during init. We could therefore have a partial mock of the HomeViewModel.