Implementing a login request

Let's assume that a colleague is developing a web service, but it is not finished yet. However, we already know what the API will look like. There will be an endpoint for the login. The URL will be https://awesometodos.com/login; it will take two parameters: a username and password, and it will return a token that has to be used with each further call to the API.

We need a test that asserts that the token that is returned from the login call is put into a token struct.

Add a new iOS | Source | Unit Test Case Class, and call it APIClientTests. Import the main module so that it can be tested (@testable import ToDo), and remove the two template tests.

We will split the login feature into several micro features. As mentioned previously, the login should make an HTTPS request to https://awesometodos.com/login with the username and password as query parameters. Let's write a test for this.

Add the following code to APIClientTests:

func test_Login_UsesExpectedHost() { 
 

  let sut = APIClient() 
} 

The static analyzer tells us that we need an APIClient class. Add an iOS | Source | Swift File to the main target, and call it APIClient.swift. Add the following code to it:

class APIClient { 
} 

This is enough to make the static analyzer happy.

You need to be able to inject a fake URL session that fakes the network call because the server side isn't finished yet. Add the following code to test_Login_UsesExpectedHost():

let mockURLSession = MockURLSession() 

This code does not compile because the mock class is missing. Add the following code to APIClientTests.swift, but outside of the class definition:

extension APIClientTests { 
   

  class MockURLSession { 
   
  
    var url: URL? 
     
    func dataTask( 
      with url: URL, 
      completionHandler: @escaping  
      (Data?, URLResponse?, Error?) -> Void) 
      -> URLSessionDataTask { 
      
 
      self.url = url 
      return URLSession.shared.dataTask(with: url) 
    } 
  } 
} 

This mock class implements the method dataTask(with:completionHandler:), because this is the method we want to use in the implementation of the network requests. The mock class catches the URL. This enables us to check the URL in the test. Next, we want to inject the mock class into the implementation. Add the following code at the end of test_Login_UsesExpectedHost():

sut.session = mockURLSession

To make this code compilable, you need to add a session property. Open APIClient and add this property:

 lazy var session: URLSession = URLSession.shared

Try to run the tests. The test will still not compile. The reason for this is that it complains cannot assign value of type 'APIClientTests.MockURLSession' to type 'URLSession'. This makes sense. You have to change the type of session in order to be able to set it either as an instance of URLSession or an instance of our mock class. The key is to use protocol. Add the following code in APIClient.swift but outside of APIClient:

protocol SessionProtocol { 
  func dataTask( 
    with url: URL, 
    completionHandler: @escaping  
    (Data?, URLResponse?, Error?) -> Void) 
    -> URLSessionDataTask 
} 

URLSession already implements the protocol method. To make it conform to protocol, add the following extension in APIClient.swift (but outside of the class definition):

extension URLSession: SessionProtocol {} 

Next, you have to tell the compiler that the mock class conforms to that protocol as well. Change the definition of the mock class to the following one:

class MockURLSession: SessionProtocol { 
  // ... 
} 

Finally, you have to change the type of the session property. In APIClient, replace the URLSession type with SessionProtocol like this:

lazy var session: SessionProtocol = URLSession.shared 

Run the tests. Now, the test compiles, and you can continue. APIClient needs a method that does the login. Add the following code to test_Login_UsesExpectedHost():

let completion = { (token: Token?, error: Error?) in } 
sut.loginUser(withName:"dasdom", 
              password: "1234", 
              completion: completion)

This does not compile because of the method loginUser(withName:password:completion:) and the Token struct are missing. Open APIClient, and add the following code:

func loginUser(withName username: String, 
               password: String, 
               completion: @escaping (Token?, Error?) -> Void) {
 
   
} 

Next, add an iOS | Source | Swift File to the main target and call it Token.swift. Add the following code:

struct Token { 
 
  
} 

This is enough to make the test compilable again.

To make sure that the login method uses the expected host, add the following code at the end of test_Login_UsesExpectedHost():

guard let url = mockURLSession.url else { XCTFail(); return } 
let urlComponents = URLComponents(url: url, 
                                  resolvingAgainstBaseURL: true) 
XCTAssertEqual(urlComponents?.host, "awesometodos.com") 

This code gets the URL components from mockURLSession (remember that our session mock catches the URL) and asserts that the host of the URL is awesometodos.com.

Run this test. It fails. To make it pass, add the following code to loginUser(withName:password:completion:):

guard let url = URL(string: "https://awesometodos.com") else {  
  fatalError()  
} 
session.dataTask(with: url) { (data, response, error) in 

   
} 

Run the tests again. Now all the tests pass, and there is nothing to refactor. Next, let's add a test for the path of the URL. Copy the test method test_Login_UsesExpectedHost(), change the method name of the copy to test_Login_UsesExpectedPath() and replace the assertion with the following:

XCTAssertEqual(urlComponents?.path, "/login")

To make the test pass again, replace the definition of the URL with this:

guard let url = URL(string: "https://awesometodos.com/login") else { 
  fatalError() 
} 

Run the tests to make sure that all the tests pass. The two tests in APIClientTests share a lot of code. Let's refactor the tests to be more readable.

Add the following two properties to APIClientTests:

var sut: APIClient!
var mockURLSession: MockURLSession!

Next, add these lines to set up after super.setUp():

sut = APIClient()
mockURLSession = MockURLSession()
sut.session = mockURLSession

With these changes, the setup of the system under test is done in setup(). Remove the following lines from the two test methods:

let sut = APIClient()
let mockURLSession = MockURLSession()
sut.session = mockURLSession

Run the tests. All tests still pass. But wait a minute. How do we know that the tests still work? We have changed the tests. What if we changed the tests in a way that they always pass? Sometimes it's good to be paranoid about the tests. So, let's test the tests.

In APIClient change the generated URL to:

guard let url = URL(string: "https://example.com") else {
fatalError()
}

Run the tests. Both tests in APIClientTests fail. Good. So, they still work. Fix the code that the tests pass again.

There is still room for improvement in the tests. Before we can assert that the host and the path are as expected, we have to create an instance of URLComponents with the caught url. The test would be much cleaner if we put that code into the MockURLSession. So let's do exactly this. Add the following code to MockURLSession:

var urlComponents: URLComponents? {
guard let url = url else { return nil }
return URLComponents(url: url,
resolvingAgainstBaseURL: true)
}

Then we can replace the assertions in the test methods with this:

func test_Login_UsesExpectedHost() {


// ...


XCTAssertEqual(
mockURLSession.urlComponents?.host,
"awesometodos.com")
}


func test_Login_UsesExpectedPath() {


// ...

XCTAssertEqual(
mockURLSession.urlComponents?.path,
"/login")
}

Much better! Run the tests. All tests still pass. Now we can move on.

Next, you need to make sure that username and password are passed as parameters in the URL query. Copy the test method test_Login_UsesExpectedPath(), change the name of the copy to test_Login_UsesExpectedQuery() and replace the assertion with this:

XCTAssertEqual(
mockURLSession.urlComponents?.query,
"username=dasdom&password=1234")

Run this test. The test fails because you do not use username and password to construct the URL. To make the test pass, replace the URL with the following one:

let query = "username=(username)&password=(password)" 
guard let url = URL(string: 
  "https://awesometodos.com/login?(query)") else { 
  fatalError() 
} 

Now, the tests pass again. But if you have worked with a web service before, you might have realized that there is a problem with your code. Some characters have a special meaning when they are used in a URL. For example, the character & splits the URL query into query items. But the user could use this character in their password. We need to encode the query items. Let's change the test to drive the change of the implementation code. First, change the call of loginUser(withName:password:completion:) in test_Login_UsesExpectedQuery() to use special characters in username and password:

sut.loginUser(withName:"dasdöm", 
              password: "%&34", 
              completion: completion) 

Next, replace the assertion for the query with the following code:

XCTAssertEqual(
mockURLSession.urlComponents?
.percentEncodedQuery,
"username=dasd%C3%B6m&password=%25%2634")

With these changes, you assert that username and password are properly encoded to be used in a URL query. Note that we are now using the percentEncodedQuery of URLComponents. Go ahead, look up the difference between query and percentEncodedQuery.

Run the tests. The test crashes because you chose to call fatalError() in case the URL cannot be constructed for the string. To remove the crash and make the test pass, replace the contents of loginUser(withName:password:completion:) with the following lines of code:

let allowedCharacters = CharacterSet( 
  charactersIn: 
"/%&=?$#+-~@<>|\*,.()[]{}^!").inverted guard let encodedUsername = username.addingPercentEncoding( withAllowedCharacters: allowedCharacters) else { fatalError() } guard let encodedPassword = password.addingPercentEncoding( withAllowedCharacters: allowedCharacters) else { fatalError() } let query = "username=(encodedUsername)&password=(encodedPassword)" guard let url = URL(string: "https://awesometodos.com/login?(query)") else { fatalError() } session.dataTask(with: url) { (data, response, error) in }

With this code, you encode username and password before you construct the URL. Run the tests. Now, all the tests pass again.

The encoding makes the method loginUser(withName:password:completion:) hard to read. It would be easier to read if we could encode with code like this:

username.percentEncode()

So let's add an extension to String. Add the following code in APIClient.swift, but outside of the class APIClient:

extension String {


var percentEncoded: String {


let allowedCharacters = CharacterSet(
charactersIn:
"/%&=?$#+-~@<>|\*,.()[]{}^!").inverted


guard let encoded = self.addingPercentEncoding(
withAllowedCharacters: allowedCharacters) else { fatalError() }


return encoded
}
}

With this change, we can write the method loginUser(withName:password:completion:) like this:

func loginUser(withName username: String,
password: String,
completion: @escaping (Token?, Error?) -> Void)


let query = "username=(username.percentEncoded)&password=
(password.percentEncoded)"
guard let url = URL(string:
"https://awesometodos.com/login?(query)") else {
fatalError()
}


session.dataTask(with: url) { (data, response, error) in
}
}

Right now the test depends on the order of the query items. This is not a good idea because in a URL, the order is irrelevant. This means the test could fail even if the URL is correct. So you should refactor the test before you proceed with the next test.

URLComponents has a property called queryItems. This should help.

Right now, MockURLSession only catches the URL of the request. To test the login code, we need to be able to call the completion handler of the data task in the test. This way, we can ensure that the login code processes the returned data in the way we expect. We will accomplish this task by catching the completion handler and calling it when resume on the dataTask is called.

To do this, you need to create a mock for the data task first. Add the following mock class to the extension on APIClientTests:

class MockTask: URLSessionDataTask { 
  private let data: Data? 
  private let urlResponse: URLResponse? 
  private let responseError: Error? 
   

  typealias CompletionHandler = (Data?, URLResponse?, Error?)  
    -> Void 
  var completionHandler: CompletionHandler? 
   

  init(data: Data?, urlResponse: URLResponse?, error: Error?) { 
    self.data = data 
    self.urlResponse = urlResponse 
    self.responseError = error 
  } 
 
  
  override func resume() { 
    DispatchQueue.main.async() { 
      self.completionHandler?(self.data, 
                              self.urlResponse, 
                              self.responseError) 
    } 
  } 
} 

This code defines four properties. The first three properties are used to set the values to be fed into the completion handler. The fourth property is the completion handler to be executed when resume() gets called.

In addition, this mock has two methods: an init method that takes the values for the completion handler and the overridden resume method. In the resume method, the completion handler is dispatched to the main queue. This is done to make sure that the completion handler is asynchronous to the surrounding code.

MockURLSession has to create a mock data task and return it when dataTask(with:completionHandler:) is called. Replace the MockURLSession class with the following implementation:

class MockURLSession: SessionProtocol {


var url: URL?
private let dataTask: MockTask


var urlComponents: URLComponents? {
guard let url = url else { return nil }
return URLComponents(url: url,
resolvingAgainstBaseURL: true)
}

init(data: Data?, urlResponse: URLResponse?, error: Error?) {
dataTask = MockTask(data: data,
urlResponse: urlResponse,
error: error)
}


func dataTask(
with url: URL,
completionHandler: @escaping
(Data?, URLResponse?, Error?) -> Void)
-> URLSessionDataTask {


self.url = url
print(url)
dataTask.completionHandler = completionHandler
return dataTask
}
}

You have added an init method to create and store the data task. The parameters in the init method take the values to be used when calling the completion handler. In dataTask(with:completionHandler:), you store the completion handler in the mock data task and return the mock data task.

With all this preparation, the completion handler of the request gets executed when the resume of the mock data task is called. The parameters of the completion handler are set when an instance of MockURLSession is instantiated.

Because you have added an init method to the MockURLSession class, the initialization of the mock URL session in the previous test does not compile anymore. Replace the line let mockURLSession = MockURLSession() with let mockURLSession = MockURLSession(data: nil, urlResponse: nil, error: nil) to make it compilable again.

With these changes made, you are ready for the test. Add the following test to APIClientTests:

func test_Login_WhenSuccessful_CreatesToken() {

let jsonData =
"{"token": "1234567890"}"
.data(using: .utf8)
mockURLSession = MockURLSession(data: jsonData,
urlResponse: nil,
error: nil)
sut.session = mockURLSession


let tokenExpectation = expectation(description: "Token")
var caughtToken: Token? = nil
sut.loginUser(withName: "Foo", password: "Bar") { token, _
in
caughtToken = token
tokenExpectation.fulfill()
}


waitForExpectations(timeout: 1) { _ in
XCTAssertEqual(caughtToken?.id, "1234567890")
}
}

First, you set up sut with a mock URL session prepared to return a simple JSON. Then, you create an expectation and call the login method. The username and password are irrelevant this time, because you return the simple JSON in the completion handler anyways. At the end of the test, you wait for the expectation to be fulfilled and assert that the token has the expected id.

This does not compile because Token does not have an id property yet. Add the property in the Token struct:

let id: String 

Run the test. The test fails because the completion handler in the implementation does nothing right now. Replace the session.dataTask(with:completionHandler:) call with the following:

session.dataTask(with: url) { (data, response, error) in 
  guard let data = data else { return } 
  let dict = try! JSONSerialization.jsonObject( 
    with: data, 
    options: []) as? [String:String] 
 

  let token: Token? 
  if let tokenString = dict?["token"] { 
     token = Token(id: tokenString) 
  } else { 
    token = nil 
  } 
  completion(token, nil) 
}.resume() 

Note that you now call resume() on the created data task. Otherwise, the test would not pass because the completion handler would not get called in the test.

This code gets the dictionary from the response data and creates a Token instance with the string from the "token" key. The created token is then passed to the completion handler of the login method.

Run the tests. All the tests pass. There is nothing to refactor even though the code looks bad. Whenever you see an exclamation mark (!) in Swift code, you need to figure out whether it is really needed or if the developer (in this case, us) has just been lazy. In the preceding code, you used try! to bypass the need for proper error handling. Let's refactor this code using tests to guide the implementation instead.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.15.175.101