Warm tip: This article is reproduced from stackoverflow.com, please click
design-patterns inheritance ios mocking oop

How to mock classes of external framework with delegates in iOS?

发布于 2020-04-11 11:45:49

I am working in an iOS application called ConnectApp and I am using a framework called Connector. Now, Connector framework completes actual connection task with BLE devices and let my caller app (i.e. ConnectApp) know the connection request results through ConnectionDelegate. Let's see example code,

ConnectApp - host app

class ConnectionService: ConnectionDelegate {

    func connect(){
        var connector = Connector()
        connector.setDelegate(self)
        connector.connect()
    }

    func onConnected(result: ConnectionResult) {
        //connection result
    }
}

Connector Framework

public class ConnectionResult {
    // many complicated custom variables
}

public protocol ConnectionDelegate {
      func onConnected(result: ConnectionResult)
}

public class Connector {

   var delegate: ConnectionDelegate?

   func setDelegate(delegate: ConnectionDelegate) {
       self.delegate = delegate
   }

   func connect() {
        //…..
        // result = prepared from framework
        delegate?.onConnected(result)
   }
}

Problem

Sometimes developers have no BLE device and we need to mock the Connector layer of framework. In case of simple classes (i.e. with simpler methods) we could have used inheritance and mock the Connector with a MockConnector which might override the lower tasks and return status from MockConnector class. But when I need to deal with a ConnectionDelegate which returns complicated object. How can I resolve this issue?

Note that framework does not provide interfaces of the classes rather we need to find way around for concrete objects like, Connector, ConnectionDelegate etc.

Update 1:

Trying to apply Skwiggs's answer so I created protocol like,

protocol ConnectorProtocol: Connector {
    associatedType MockResult: ConnectionResult
}

And then injecting real/mock using strategy pattern like,

class ConnectionService: ConnectionDelegate {

    var connector: ConnectorProtocol? // Getting compiler error
    init(conn: ConnectorProtocol){
        connector = conn
    }

    func connect(){
        connector.setDelegate(self)
        connector.connect()
    }

    func onConnected(result: ConnectionResult) {
        //connection result
    }
}

Now I am getting compiler error,

Protocol 'ConnectorProtocol' can only be used as a generic constraint because it has Self or associated type requirements

What am I doing wrong?

Questioner
Sazzad Hissain Khan
Viewed
64
Jon Reid 2020-02-02 03:24

In Swift, the cleanest way to create a Seam (a separation that allows us to substitute different implementations) is to define a protocol. This requires changing the production code to talk to the protocol, instead of a hard-coded dependency like Connector().

First, create the protocol. Swift lets us attach new protocols to existing types.

protocol ConnectorProtocol {}

extension Connector: ConnectorProtocol {}

This defines a protocol, initially empty. And it says that Connector conforms to this protocol.

What belongs in the protocol? You can discover this by changing the type of var connector from the implicit Connector to an explicit ConnectorProtocol:

var connector: ConnectorProtocol = Connector()

Xcode will complain about unknown methods. Satisfy it by copying the signature of each method it needs into the protocol. Judging from your code sample, it may be:

protocol ConnectorProtocol {
    func setDelegate(delegate: ConnectionDelegate)
    func connect()
}

Because Connector already implements these methods, the protocol extension is satisfied.

Next, we need a way for the production code to use Connector, but for test code to substitute a different implementation of the protocol. Since ConnectionService creates a new instance when connect() is called, we can use a closure as a simple Factory Method. The production code can supply a default closure (creating a Connector) like with a closure property:

private let makeConnector: () -> ConnectorProtocol

Set its value by passing an argument to the initializer. The initializer can specify a default value, so that it makes a real Connector unless told otherwise:

init(makeConnector: (() -> ConnectorProtocol) = { Connector() }) {
    self.makeConnector = makeConnector
    super.init()
}

In connect(), call makeConnector() instead of Connector(). Since we don't have unit tests for this change, do a manual test to confirm we didn't break anything.

Now our Seam is in place, so we can begin writing tests. There are two types of tests to write:

  1. Are we calling Connector correctly?
  2. What happens when the delegate method is called?

Let's make a Mock Object to check the first part. It's important that we call setDelegate(delegate:) before calling connect(), so let's have the mock record all calls in an array. The array gives us a way to check the call order. Instead of having the test code examine the array of calls (acting as a Test Spy which just records stuff), your test will be cleaner if we make this a full-fledged Mock Object — meaning it will do its own verification.

final class MockConnector: ConnectorProtocol {
    private enum Methods {
        case setDelegate(ConnectionDelegate)
        case connect
    }

    private var calls: [Methods] = []

    func setDelegate(delegate: ConnectionDelegate) {
        calls.append(.setDelegate(delegate))
    }

    func connect() {
        calls.append(.connect)
    }

    func verifySetDelegateThenConnect(
        expectedDelegate: ConnectionDelegate,
        file: StaticString = #file,
        line: UInt = #line
    ) {
        if calls.count != 2 {
            fail(file: file, line: line)
            return
        }
        guard case let .setDelegate(delegate) = calls[0] else {
            fail(file: file, line: line)
            return
        }
        guard case .connect = calls[1] else {
            fail(file: file, line: line)
            return
        }
        if expectedDelegate !== delegate {
            XCTFail(
                "Expected setDelegate(delegate:) with \(expectedDelegate), but was \(delegate)",
                file: file,
                line: line
            )
        }
    }

    private func fail(file: StaticString, line: UInt) {
        XCTFail("Expected setDelegate(delegate:) followed by connect(), but was \(calls)", file: file, line: line)
    }
}

(That business with passing around file and line? This makes it so that any test failure will report the line that calls verifySetDelegateThenConnect(expectedDelegate:), instead of the line that calls XCTFail(_).)

Here's how you'd use this in ConnectionServiceTests:

func test_connect_shouldMakeConnectorSettingSelfAsDelegateThenConnecting() {
    let mockConnector = MockConnector()
    let service = ConnectionService(makeConnector: { mockConnector })

    service.connect()

    mockConnector.verifySetDelegateThenConnect(expectedDelegate: service)
}

That takes care of the first type of test. For the second type, there's no need to test that Connector calls the delegate. You know it does, and it's outside your control. Instead, write a test to call the delegate method directly. (You'll still want it to make a MockConnector to prevent any calls to the real Connector).

func test_onConnected_withCertainResult_shouldDoSomething() {
    let service = ConnectionService(makeConnector: { MockConnector() })
    let result = ConnectionResult(…) // Whatever you need

    service.onConnected(result: result)

    // Whatever you want to verify
}

These techniques are described in greater detail in iOS Unit Testing by Example: XCTest Tips and Techniques Using Swift https://pragprog.com/book/jrlegios/ios-unit-testing-by-example