Swift - using XCTest to test function containing closure

The call to Auth is an architectural boundary. Unit tests are faster and more reliable if they go up to such boundaries, but don't cross them. We can do this by isolating the Auth singleton behind a protocol.

I'm guessing at the signature of signIn. Whatever it is, copy and paste it into a protocol:

protocol AuthProtocol {
    func signIn(withEmail email: String, password: String, completion: @escaping (String, NSError?) -> Void)
}

This acts as a thin slice of the full Auth interface, taking only the part you want. This is an example of the Interface Segregation Principle.

Then extend Auth to conform to this protocol. It already does, so the conformance is empty.

extension Auth: AuthProtocol {}

Now in your view controller, extract the direct call to Auth.auth() into a property with a default value:

var auth: AuthProtocol = Auth.auth()

Talk to this property instead of directly to Auth.auth():

auth.signIn(withEmail: email, …etc…

This introduces a Seam. A test can replace auth with an implementation that is a Test Spy, recording how signIn is called.

final class SpyAuth: AuthProtocol {
    private(set) var signInCallCount = 0
    private(set) var signInArgsEmail: [String] = []
    private(set) var signInArgsPassword: [String] = []
    private(set) var signInArgsCompletion: [(String, Foundation.NSError?) -> Void] = []

    func signIn(withEmail email: String, password: String, completion: @escaping (String, Foundation.NSError?) -> Void) {
        signInCallCount += 1
        signInArgsEmail.append(email)
        signInArgsPassword.append(password)
        signInArgsCompletion.append(completion)
    }
}

A test can inject the SpyAuth into the view controller, intercepting everything that would normally go to Auth. As you can see, this includes the completion closure. I would write

  • One test to confirm the call count and the non-closure arguments
  • Another test to get the captured closure and call it with success.
  • I'd also call it with failure, if your code didn't have a print(_) statement.

Finally, there's the matter of segues. Apple hasn't given us any way to unit test them. As a workaround, you can make a partial mock. Something like this:

final class TestableLoginViewController: LoginViewController {
    private(set) var performSegueCallCount = 0
    private(set) var performSegueArgsIdentifier: [String] = []
    private(set) var performSegueArgsSender: [Any?] = []

    override func performSegue(withIdentifier identifier: String, sender: Any?) {
        performSegueCallCount += 1
        performSegueArgsIdentifier.append(identifier)
        performSegueArgsSender.append(sender)
    }
}

With this, you can intercept calls to performSegue. This isn't ideal, because it's a legacy code technique. But it should get you started.

final class LoginViewControllerTests: XCTestCase {
    private var sut: TestableLoginViewController!
    private var spyAuth: SpyAuth!

    override func setUp() {
        super.setUp()
        sut = TestableLoginViewController()
        spyAuth = SpyAuth()
        sut.auth = spyAuth
    }

    override func tearDown() {
        sut = nil
        spyAuth = nil
        super.tearDown()
    }

    func test_login_shouldCallAuthSignIn() {
        sut.login(email: "EMAIL", password: "PASSWORD")
        
        XCTAssertEqual(spyAuth.signInCallCount, 1, "call count")
        XCTAssertEqual(spyAuth.signInArgsEmail.first, "EMAIL", "email")
        XCTAssertEqual(spyAuth.signInArgsPassword.first, "PASSWORD", "password")
    }

    func test_login_withSuccess_shouldPerformSegue() {
        sut.login(email: "EMAIL", password: "PASSWORD")
        let completion = spyAuth.signInArgsCompletion.first
        
        completion?("DUMMY", nil)
        
        XCTAssertEqual(sut.performSegueCallCount, 1, "call count")
        XCTAssertEqual(sut.performSegueArgsIdentifier.first, "loginSeg", "identifier")
        let sender = sut.performSegueArgsSender.first
        XCTAssertTrue(sender as? TestableLoginViewController === sut,
            "Expected sender \(sut!), but was \(String(describing: sender))")
    }
}

Absolutely nothing asynchronous here, so no waitForExpectations. We capture the closure, we call the closure.


Jon's answer is excellent, I can't add comments yet, so I'll add my advice here. For those who have (for any reason) a static/class function instead of a singleton or an instance function, this could help you:

For example, if you have Auth.signIn(withEmail: emai... where signIn is a static function. Instead of use:

var auth: AuthProtocol = Auth.auth()

Use:

var auth: AuthProtocol.Type = Auth.self

And assign it like this

sut.auth = SpyAuth.self