Getting started with unit testing your Swift code on iOS – part 2

Published on: October 2, 2019

In part 1 of this two-part blog post, you’ve learned how to write synchronous unit tests for a login view model. As a reminder, you saw how to implement tests for the following requirements:

  • When both login fields are empty, pressing the login button should trigger an error that informs the user that both fields are mandatory.
  • When one of the two fields is empty, pressing the login button should trigger an error that informs the user that the empty field is mandatory.
  • When the user’s email address does not contain an @ symbol, pressing the login button should trigger an error that informs the user that they must provide a valid email address.

You implemented these requirements using a LoginFormViewModel that has two properties for the username and password and a login method. And of course, you wrote tests for every requirement listed. If you want to start following this blog post with the final code of the previous part, check out the HelloUnitTest-PartOne folder in this blog post’s GitHub repository.

In this second part, you will learn how to refactor the current test suite to support asynchronous code. You will also implement a networking abstraction that will act as a fake server for login requests.

The objective of this blog post

Just like in the previous post, you will implement tests for a couple of requirements. This time, there are just two requirements:

  • When the user’s credentials are filled in, but the server doesn’t recognize the email address or the password is wrong, an error should be triggered that informs the user that their credentials were incorrect.
  • When the user’s credentials are filled in and they are valid, the login method should invoke the completion handler with a User object.

So, without further ado, let’s dive right in and begin refactoring and writing some more test code!

Getting to work

The first requirement we’re going to implement is the following:

  • When the user’s credentials are filled in, but the server doesn’t recognize the email address or the password is wrong, an error should be triggered that informs the user that their credentials were incorrect.

Note that this requirement speaks of a server. In this post, I will not focus on the nitty-gritty details and many possibilities of setting up a mock networking layer. Instead, I will show you a basic form of abstracting a network layer so you can do testing with it. First, let’s refactor the existing code from part 1 so it’s ready for the new asynchronous nature of the tests you’re going to write in this post.

In order to make the form view model asynchronous, we’re going to change the code first and update the existing tests after. I will assume that your code looks identical to the code that’s in the HelloUnitTests-PartOne folder of this blog post’s GitHub repository. The login method on the LoginFormViewModel should be changed so it supports asynchronous execution. This means that the method should no longer throw errors and instead invoke a completion handler with a result of type Result<User, Error>. You will define user as an empty struct since we’re not going to actually decode and create User objects later. The definition of User should look as follows:

struct User {

}

Completely empty. Simple right? You can either put it in its own file or throw it in the LoginFormViewModel.swift file since it’s just an empty struct. Next, rewrite the login method as follows:

func login(_ completion: @escaping (Result<User, LoginError>) -> Void) {
  guard let password = password, let username = username else {
    if self.username == nil && self.password == nil {
      completion(Result.failure(LoginError.formIsEmpty))
    } else if self.username == nil {
      completion(Result.failure(LoginError.usernameIsEmpty))
    } else {
      completion(Result.failure(LoginError.passwordIsEmpty))
    }
    return
  }

  guard username.contains("@") else {
    completion(Result.failure(LoginError.emailIsInvalid))
    return
  }
}

If at this point you’re thinking: “Hey! This isn’t right. TDD dictates that we change our tests first”, you would be absolutely right. However, I find that sometimes it’s simpler to have a bunch of passing tests, then refactor your logic, and refactor the tests to match afterwards. It’s really a preference of mine and I wanted to make sure you see this method of writing code and tests too.

Let’s refactor the tests so they compile and pass again. I will only show you how to refactor a single test case, all the other test cases should be refactored in a similar way. Again, if you get stuck, refer to this blogpost’s GitHub repository. The solutions for this part of the post are in the HelloUnitTests-PartTwo folder. Refactor the testEmptyLoginFormThrowsError method in your test file as follows:

func testEmptyLoginFormThrowsError() {
  XCTAssertNil(loginFormViewModel.username)
  XCTAssertNil(loginFormViewModel.password)

  // 1
  let testExpectation = expectation(description: "Expected login completion handler to be called")

  loginFormViewModel.login { result in
    guard case let .failure(error) = result, case error = LoginError.formIsEmpty else {
      XCTFail("Expected completion to be called with formIsEmpty")
      testExpectation.fulfill()
      return
    }

    // 3
    testExpectation.fulfill()
  }

  // 2
  waitForExpectations(timeout: 1, handler: nil)
}

This test contains a couple of statements that you haven’t seen before. I have added comments with numbers in the code so we can go over them one by one.

  1. This line of code creates a so-called test expectation. An expectation is an object that is used often when testing asynchronous code. Expectations start out in an unfulfilled state and remain like that until you call fulfill on them. If you never call fulfill, your test will eventually be considered failed.
  2. At the end of the test method, waitForExpectation is called. This method instructs the test that even though the end of the control flow has been reached, the test is not done executing yet. In this case, the test will wait for one second to see if all expectations are eventually fulfilled. If after one second there are one or more unfulfilled expectations in this test, the test is considered to be failed.
  3. Since the login method is now asynchronous, it received a callback closure that uses Swift’s Result type. Once the result has been unpacked and we find the error that we expect, the expectation that was created earlier is fulfilled. If the expected error is not found, the test is marked as failed and the expectation is also fulfilled because the completion handler was called. There is no need to wait a full second for this test to fail since the XCTFail invocation already marks the test as failed.

Now that login supports callbacks, we can write a new test that makes sure that the LoginFormViewModel handles server errors as expected. To do this, we’re going to introduce a dependency for LoginFormViewModel. Using a specialized object for the networking in an application allows you to create a mock or fake version of the object in your test, which provides a high level of control of what data the fake network object responds with.

This time, we’re going to jump from implementation code into tests and then back into implementation code. First, add the following protocol definition to LoginFormViewModel.swift:

protocol LoginService {
  func login(_ completion: @escaping (Result<User, LoginError>) -> Void)
}

This protocol is fairly straightforward. It dictates that any networking object acting as a login service must have a login method that takes a completion handler that takes a Result<User, LoginError> object. This method signature is identical to the one you’ve already seen on the LoginFormViewModel. Next, add a property to the LoginFormViewModel to hold the new login service:

let loginService: LoginService

Finally, call the login method on the login service at the end of LoginFormViewModel’s login method:

func login(_ completion: @escaping (Result<User, LoginError>) -> Void) {
  // existing code

  guard username.contains("@") else {
    completion(Result.failure(LoginError.emailIsInvalid))
    return
  }

  loginService.login(completion)
}

So far so good, now let’s make the tests compile again. Add a property for the login service to your test class and update the setUp method as shown below:

var loginService: MockLoginService!

override func setUp() {
  loginService = MockLoginService()
  loginFormViewModel = LoginFormViewModel(loginService: loginService)
}

Note that this code uses a type that you haven’t defined yet; MockLoginService. Let’s implement that type now. Select your test suite’s folder in Xcode’s File Navigator and create a new Swift file. When naming the file, double check that the file will be added to your test target and not to your application target. Name it MockLoginService and press enter. Next, add the following implementation code to this file:

import Foundation
@testable import HelloUnitTests

class MockLoginService: LoginService {
  var result: Result<User, LoginError>?

  func login(_ completion: @escaping (Result<User, LoginError>) -> Void) {
    if let result = result {
      completion(result)
    }
  }
}

The code for this file is pretty simple. The mock login service has an optional result property that we’re going to give a value later in the test. The login method that will be called from the login view model immediately calls the completion block with the result that is defined by the test. This setup is a cool way to fake very basic network responses because your application code can now use any object that conforms to LoginService. You create and inject a real networking object if you’re starting your app normally, and you create and inject a mock networking object when you’re running tests. Neat!

Note that the MockLoginService is a class and not a struct. Making this object a class ensures that any assignments to result are applied to all objects that have a reference to the MockLoginService. If you’d make this object a struct, every object that receives a MockLoginService will make a copy and assignments to result would not carry over to other places where it’s used.

Your tests should still run and pass at this point. We are now ready to finally add that test we set out to write in the first place:

func testServerRespondsWithUnkownCredentials() {
  loginFormViewModel.username = "[email protected]"
  loginFormViewModel.password = "password"
  XCTAssertNotNil(loginFormViewModel.username)
  XCTAssertNotNil(loginFormViewModel.password)

  loginService.result = .failure(LoginError.incorrectCredentials)

  let testExpectation = expectation(description: "Expected login completion handler to be called")

  loginFormViewModel.login { result in
    guard case let .failure(error) = result, case error = LoginError.incorrectCredentials else {
      XCTFail("Expected completion to be called with incorrectCredentials")
      testExpectation.fulfill()
      return
    }

    testExpectation.fulfill()
  }

  waitForExpectations(timeout: 1, handler: nil)
}

The above code looks very similar to the asynchronous test code you’ve already written. The major difference here is on line 7: loginService.result = .failure(LoginError.incorrectCredentials). This line of code assigns a failure response with a specific error to the mock login service’s result property. All other code is pretty much unchanged when you compare it to your previous tests. Now add the incorrectCredentials error case to your LoginError enum and run the tests. They should pass, which indicates that the LoginFormViewModel uses a networking object and that it forwards errors that it received from the networking object to the calling object.

You now have enough information to implement the final test case yourself:

  • When the user’s credentials are filled in and they are valid, the login method should invoke the completion handler with a User object.

When you’re done and want to check your solution, or if you’re stuck, don’t hesitate to check out the GitHub repository for this post.

Next steps

First of all, congratulations on making it this far! You’ve achieved a lot by working through all of the examples shown in this two-part post. Part one showed you how to write simple unit tests, and with the information from this second part, you can even write tests for asynchronous code. Moreover, you now even have an idea of how you can architect your application code to be easily testable by using protocols as an abstraction layer.

Of course, there is much more to learn about testing. For now, you should have plenty of information to stay busy, but if you’re eager to start learning more, I can recommend taking a look at some other blog posts I wrote about testing:

Alternatively, you can try to refactor the test suite a little bit by removing some of the code duplication you’ll currently find in there. After all, test code is code too, and we should aim to make it as DRY and high-quality as possible.

Categories

Testing

Subscribe to my newsletter