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 :)
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
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With