Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift - using XCTest to test function containing closure

I am fairly new to Swift and am currently trying to write a unit test (using XCTest) to test the following function:

func login(email: String, password: String)  {

    Auth.auth().signIn(withEmail: email, password: password) { (user, error) in
        if let _error = error {
            print(_error.localizedDescription)
        } else {
            self.performSegue(identifier: "loginSeg")
        }
    }
}

My research has identified that I need to use the XCTestExpectation functionality as XCTest executes synchronously by default meaning it won't wait for the closure to finish running (please correct me if I'm wrong).

Whats throwing me off is how I test the login function as it itself calls the asynchronous function Auth.auth().signIn(). I'm trying to test whether the signIn is successful.

Apologies if this has already been answered but I couldn't find an answer that directly addresses this issue.

Thanks

Update:

With some help from the answers and further research I amended by login function to use an escaping closure:

func login(email: String, password: String, completion: @escaping(Bool)->())  {

    Auth.auth().signIn(withEmail: email, password: password) { (user, error) in
        if let _error = error {
            print(_error.localizedDescription)
            completion(false)
        } else {
            self.performSegue(identifier: "loginSeg")
            completion(true)
        }
    }
}

I then test in the following way:

func testLoginSuccess() {

    // other setup

    let exp = expectation(description: "Check Login is successful")

    let result = login.login(email: email, password: password) { (loginRes) in
        loginResult = loginRes
        exp.fulfill()
    }

    waitForExpectations(timeout: 10) { error in
        if let error = error {
            XCTFail("waitForExpectationsWithTimeout errored: \(error)")
        }
        XCTAssertEqual(loginResult, true)
    }
}

My test function now tests the login functionality successfully.

Hope this helps someone as it left me stumped for a while :)

like image 361
Reebo96 Avatar asked Jan 31 '19 11:01

Reebo96


1 Answers

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.

like image 168
Jon Reid Avatar answered Nov 14 '22 06:11

Jon Reid