I am trying to test a login method triggered by a IBAction that if the login failed (if the completion hander returned a error), I want to present an alert controller, but the login method is obviously an asynchronous.
The loginUser
method is already mocked and always returns handler(nil, .EmptyData)
on the main thread like so:
func loginUser(from url: URL, with username: String, and password: String, completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
DispatchQueue.main.async {
completionHandler(nil, .EmptyData)
}
}
Here is the IBAction
@IBAction func onLoginButtonTapped(_ sender: Any) {
guard let url = URL(string: USER_LOGIN_ENDPOINT) else { return }
let username = userNameTextField.text
let password = passwordTextField.text
client.loginUser(from: url, with: username!, and: password!) { (data, error) in
if let error = error {
switch error {
case .EmptyData:
DispatchQueue.main.async {
presentAlertVC()
}
case .CannotDecodeJson:
DispatchQueue.main.async {
presentAlertVC()
}
}
}
}
My question is, how do I test to see if the handler returns a .EmptyData
error, the alert controller will show?
Here is my attempt on the test, the set is the viewController in test:
func testLoginButtin_ShouldPresentAlertContollerIfErrorIsNotNil() {
sut.onLoginButtonTapped(sut.loginButton)
let alert = sut.presentingViewController
XCTAssertNotNil(alert)
}
tl;dr Answer originally sought comes at end
Based on your description, I don't think you need to use DispatchQueue.main
in this section of production code. Since Client's loginUser(from:with:and:completionHandler:)
does something asynchronous and then calls the completion handler, it can guarantee that the completion handler is called on the main thread. And this would happen after the response has been decoded in the background.
If that's true, then in the view controller's onLoginButtonTapped(_)
, there's no need for the completion handler to again dispatch to the main queue. We already know that it's running on the main queue, so it can just call self.presentAlertVC()
without any tricks.
This brings us to the test code. Your faked loginUser
doesn't have to schedule anything on DispatchQueue.main
. The real version does, but the fake doesn't have to. We can eliminate that, making the test code even simpler. All the test code can by synchronous, eliminating the need to use XCTestExpectation.
Now your fake client shouldn't call the completion handler with hard-coded values. We want each test to be able to configure what it wants. This will let you test every path. If you're not already doing the substitution of the fake using a protocol, let's introduce one:
protocol ClientProtocol {
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void)
}
You may already be doing this. If not, then you're creating a test-specific subclass of your client right now. Go with the protocol. So in your view controller, you'll have a number of outlets and this client:
@IBOutlet private(set) var loginButton: UIButton!
@IBOutlet private(set) var usernameTextField: UITextField!
@IBOutlet private(set) var passwordTextField: UITextField!
var client: ClientProtocol = Client()
Make the outlets private(set)
instead of private
so that tests can access them.
Here are the tests I'd write. Bear with me, I'll eventually get to the one you asked about. First, let's test that the outlets are set up. For my example, I'm going to assume you're using a storyboard-based view controller.
final class ViewControllerTests: XCTestCase {
private var sut: ViewController!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_outlets_shouldBeConnected() {
sut.loadViewIfNeeded()
XCTAssertNotNil(sut.loginButton, "loginButton")
XCTAssertNotNil(sut.usernameTextField, "usernameTextField")
XCTAssertNotNil(sut.passwordTextField, "passwordTextField")
}
}
Next, I want to test something you haven't mentioned: that when the user taps the login button, it calls loginUser
passing the expected parameters. For this, we can pass a mock object that lets us verify how loginUser
is called. It's going to capture the call count and all parameters. It has a verify method to confirm most of the parameters. A separate method gives tests a way to call the completion handler.
private class MockClient: ClientProtocol {
private var loginUserCallCount = 0
private var loginUserArgsURL: [URL] = []
private var loginUserArgsUsername: [String] = []
private var loginUserArgsPassword: [String] = []
private var loginUserArgsCompletionHandler: [(DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void] = []
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
loginUserCallCount += 1
loginUserArgsURL.append(url)
loginUserArgsUsername.append(username)
loginUserArgsPassword.append(password)
loginUserArgsCompletionHandler.append(completionHandler)
}
func verifyLoginUser(from url: URL,
with username: String,
and password: String,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertEqual(loginUserCallCount, 1, "call count", file: file, line: line)
XCTAssertEqual(url, loginUserArgsURL.first, "url", file: file, line: line)
XCTAssertEqual(username, loginUserArgsUsername.first, "username", file: file, line: line)
XCTAssertEqual(password, loginUserArgsPassword.first, "password", file: file, line: line)
}
func invokeLoginUserCompletionHandler(profile: DrivetimeUserProfile?,
error: DrivetimeAPIError.LoginError?,
file: StaticString = #file,
line: UInt = #line) {
guard let handler = loginUserArgsCompletionHandler.first else {
XCTFail("No loginUser completion handler captured", file: file, line: line)
return
}
handler(profile, error)
}
}
Since we want to use this for several tests, let's put it in the test fixture:
private var sut: ViewController!
private var mockClient: MockClient! // π
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient() // π
sut.client = mockClient // π
}
override func tearDown() {
sut = nil
mockClient = nil // π
super.tearDown()
}
Now I'm ready to write that first test that tapping the login button calls the client:
func test_tappingLoginButton_shouldLoginUserWithEnteredUsernameAndPassword() {
sut.loadViewIfNeeded()
sut.usernameTextField.text = "USER"
sut.passwordTextField.text = "PASS"
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.verifyLoginUser(from: URL(string: "https://your.url")!, with: "USER", and: "PASS")
}
Notice that the test isn't calling sut.onLoginButtonTapped(sut.loginButton)
. Instead, the test is telling the login button to handle a .touchUpInside
. The name of the action method is irrelevant, so the IBAction can be declared private
.
Finally, we come to your original question. Given some result passed to the completion handler, is an alert presented? For this, we can use my library MockUIAlertController. Add it to your test target. It's written in Objective-C, so create a bridging header that imports MockUIAlertController.h
.
The alert verifier captures information about the presented alert, without actually presenting any alerts. The safest way to ensure that no actual alerts are triggered by unit tests is to add it to the test fixture:
private var sut: ViewController!
private var mockClient: MockClient!
private var alertVerifier: QCOMockAlertVerifier! // π
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient()
sut.client = mockClient
alertVerifier = QCOMockAlertVerifier() // π
}
override func tearDown() {
sut = nil
mockClient = nil
alertVerifier = nil // π
super.tearDown()
}
With alerts safely captured, we can now write the test you wanted in the first place:
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}
Because we built up enough infrastructure, the test code is simple. The QCOMockAlertVerifier has many other properties you can test. You can also have it execute button actions.
With this test in place, it's easy to write others. You can invoke the completion handler with the other error case, or with a valid profile. No async testing is needed.
If you really, really need to explicitly schedule the alert on the main thread instead of calling it directly, we can do that. Create an expectation, and ask the alert verifier to fulfill it. Add a short wait before asserting.
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
let expectation = self.expectation(description: "alert presented") // π
alertVerifier.completion = { expectation.fulfill() } // π
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
waitForExpectations(timeout: 0.001) // π
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}