3 minute read

Native mock server inside XCTest

Introduction

An automated testing can be a tricky thing, especially when you’re dealing with unstable or constantly changing backend. What if we could get rid of the real backend and use a mock server in our tests instead? Perhaps that would solve a couple of our problems, eh?

Check out this repository to dive into the source code I’ll be playing with in this note.

Precondition

I’ll use Swifter just because I love it and it’s super easy to start hacking right away with it. But really there are several similar tools, so grab any other if Swifter does not suit you.

Create sample project

  1. Open Xcode
  2. Click on Create a new Xcode project
  3. Choose iOS App as a template
  4. Fill the product name and the team name
  5. Choose SwiftUI as an interface
  6. Click on the Next button
  7. Open the ContentView.swift file
  8. Fill it in with this code
  9. Run an app on any iOS Simulator
  10. Tap on the Game controller 🎮

    Preview The app pulls a random user from the backend

Install Swifter

  1. Open Xcode
  2. Go to File => Add Packages...
  3. Type https://github.com/httpswift/swifter.git in the search field
  4. Click on the Add Package button
  5. Open UITests target => Build Phases tab
  6. Click on the plus button under the Link Binary With Libraries section
  7. Find Swifter from the list under the Swift Package section
  8. Click on the Add button
  9. Open a <package name>UITests file
  10. Import Swifter

    import Swifter
    
  11. Create this variable in the test class

    private var server = HttpServer()
    
  12. Create this method in the test class

    private func startServer() {
      do {
        try server.start(4567)
        print("Server status: \(server.state)")
      } catch {
        print("Server start error: \(error)")
      }
    }
    
  13. Run this test to make sure everything goes fine (hopefully, you see a green light ✅)

    func testUsername() {
        startServer()()
        XCUIApplication().launch()
    
        XCUIApplication().images["gamecontroller"].coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)).tap()
    
        let username = XCUIApplication().staticTexts["username"]
    
        XCTAssertTrue(username.exists)
    }
    

Mock production backend

  1. Open the ContentView.swift file
  2. Replace an existed function apiUrl() with:

     func apiUrl() -> URL {
         var urlString = "https://random-data-api.com"
         #if DEBUG
           if ProcessInfo.processInfo.arguments.contains("MOCK_SERVER") {
               urlString = "http://localhost:4567"
           }
         #endif
         let url = URL(string: urlString)!
         return url
     }
    
  3. Run an app on iOS Simulator and tap on the Game controller 🎮

    3.1 The result should remain the same, except for the username of course

  4. Open a <package name>UITests file
  5. Create new methods and variables to mock the required endpoint

     private var username = ""
    
     private func configureServer() {
       server["/api/v2/users"] = { [self] _ in
           var response = readData(fromFile: "users")
           response["first_name"] = username
           print("\nGAME STARTED 🏈\n\(response.toString(prettyPrinted: true))")
           return .ok(.json(response))
       }
     }
    
     private func mockUsername(_ username: String) {
         self.username = username
     }
    
     private func readData(fromFile name: String, ext: String = "json") -> [String: Any] {
         let bundle = Bundle(for: Self.self)
         let url = bundle.url(forResource: name, withExtension: ext)!
         let data = try! Data(contentsOf: url)
         return String(bytes: data, encoding: .utf8)!.json
     }
    
     extension String {
         var json: [String: Any] {
             try! JSONSerialization.jsonObject(with: Data(self.utf8),
                                               options: .mutableContainers) as! [String: Any]
         }
     }
    
  6. Create a json template users.json in your project to mock the response from the backend
  7. Update the test and run it again

     func testUsername() {
         startServer()()
         configureServer()
    
         XCUIApplication().launchArguments = ["MOCK_SERVER"]
         XCUIApplication().launch()
    
         let expectedUsername = "Frodo"
         mockUsername(expectedUsername)
    
         XCUIApplication().images["gamecontroller"].coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)).tap()
    
         let actualUsername = XCUIApplication().staticTexts["username"].label
    
         XCTAssertEqual(expectedUsername, actualUsername)
     }
    
  8. Look at the green light ✅

    Preview The app pulls the expected user from the mock server

Explore the mock server

Get request details

server["/api/v2/users"] = { request in
  let headers = request.headers
  let queryParams = request.queryParams
  let address = request.address
  let body = request.body
  let params = request.params
  let path = request.path
  let method = request.method
  return HttpResponse.ok(.text("""
  { "hello": "world" }
  """)
  )
}

Mock dynamic endpoint

server["api/:version/users"] = { request in
  let apiVersion = request.params[":version"]
  return HttpResponse.ok(.text("""
  { "hello": "world" }
  """)
  )
}

Return internal server error

server["api/:version/users"] = { _ in
  .internalServerError
}

Redirect

server["api/:version/users"] = { _ in
  .movedPermanently("http://www.google.com")
}

Customize response details

server["api/:version/users"] = { _ in
    let body = """
    { "hello": "world" }
    """.data(using: .utf8)

    let statusCode = 33
    let message = "Test message"
    let headers = ["User-Agent": "fake", "Test-Header": "test"]
    return HttpResponse.raw(statusCode, message, headers, { (writer) in
        try writer.write(body!)
    })
}

WebSockets

server["/websocket-echo"] = websocket(text: { session, text in
  session.writeText(text)
}, connected: { [weak self] _ in
  print("WS connected")
}, disconnected: { [weak self] _ in
  print("WS disconnected")
})

Set up a random port for parallel tests

private func startServer(
  port: in_port_t = in_port_t.random(in: 8081..<10000),
  maximumOfAttempts: Int = 10
) {
  guard maximumOfAttempts > 0 else { return }

  do {
      try server.start(port, forceIPv4: true)
  } catch SocketError.bindFailed(let message) where message == "Address already in use" {
      startServer(maximumOfAttempts: maximumOfAttempts - 1)
  } catch {
      print("Server start error: \(error)")
  }
}

Updated: