How to test controllers by mocking dependencies in Vapor 3 and Swift

March 19, 2019

Testing controllers with external dependencies, especially ones involving HTTP requests, is very tricky. That’s because sending actual requests would create a test that you could not rely on and is very slow. But there is a very simple technique that you are probably familiar with: using protocols and injecting dependencies into controllers. Recently I ran into an issue when I was trying to get parameters from routes inside unit tests. So today I would like to show you how easily you can also pass proper routes into testing functions in Vapor 3.

Let’s get started 🚀

Why do you even want to test controllers? 🤔

Imagine an online store. On the product details page users can check how many items are available. The thing is that availability system is an external service, so each time a user wants to check product status, an external service has to be called.

Because the external service may be down or items that users are looking for are no longer available, the controller has to return the proper information.

Mocking external dependencies ⚙️

You can’t check if an external service is working or not, but you can check if the controller is returning proper status codes by mocking the external service.

You’ve probably done this before, the idea is simple:

  • Use protocol for external dependency
  • Inject it inside controller, ex: inside init method
  • Inside test suite use mock object that conforms to the protocol

Let’s define simple protocol - AvailabilityCheckerProtocol:

protocol AvailabilityCheckerProtocol {
  func checkProduct(id: UUID, quantity: Int) throws -> ProductDetailsResponse
  var req: Request? { get set }
}

I have added var req: Request? to the protocol just because it’s easy to use client().get() method and get status from external service inside the implementation.

Now create a controller that will have checkAvailability function:

import Vapor
import Foundation

final class AvailabilityController {
  
  var availabilityChecker: AvailabilityCheckerProtocol
  
  init(availabilityChecker: AvailabilityCheckerProtocol) {
    self.availabilityChecker = availabilityChecker
  }
  
  func checkAvailability(_ req: Request) throws -> Future<Response> {
    let promise = req.eventLoop.newPromise(Response.self)
    availabilityChecker.req = req
    
    let productID = try req.parameters.next(UUID.self)
    let quantity = try req.parameters.next(Int.self)
    
    DispatchQueue.global().async {
      do {
        let productDetails = try self.availabilityChecker.checkProduct(id: productID, quantity: quantity)
        
        _ = productDetails.encode(status: (productDetails.quantity >= quantity ? .ok : .notFound), for: req).map {
          promise.succeed(result: $0)
        }
      }
      catch {
        promise.fail(error: error)
      }
    }
    
    return promise.futureResult
  }
}

Standard route for this will be routes.swift:

import Vapor

public func routes(_ router: Router) throws {
  let availabilityController = AvailabilityController(availabilityChecker: AvailabilityChecker())
  router.get("status", UUID.parameter, Int.parameter, use: availabilityController.checkAvailability)
}

If you would like, you can check the implementation of AvailabilityChecker() here. It is a simple class that conforms to AvailabilityCheckerProtocol protocol and gets data using req.client().get().

Writing the test 🛠

The trick is that when writing the test you have to initialize controller using Vapor app instance, otherwise the router will not be available. Since the parameters are taken from req.parameters, you need to inject a special test router into the Application instance.

To do this you can use some of the extensions that are used to test Vapor itself. You can find those ➡️ here. Don’t forget to make the extension public so other tests can see it.

Take a look at the makeTest(configure:routes:) function. This function is getting Router as an input parameter. Instead of providing routes(_ router: Router) from routes.swift file, all you need to do is to create your own routes with mocked dependencies and pass it just for testing.

Create XCTest file AvailabilityTests.swift:

import XCTest
@testable import Vapor
@testable import App

final class AvailabilityTests: XCTestCase {
  
  var app: Application?
  
  override func setUp() {
    super.setUp()
    
    app = try! Application.makeTest(routes: testRoutes)
  }
  
  override func tearDown() {
    super.tearDown()
    
    app = nil
  }
  
  private func testRoutes(_ router: Router) throws {
    let availabilityVC = AvailabilityController(availabilityChecker: AvailabilityCheckerMock())
    router.get("status", UUID.parameter, Int.parameter,
               use: availabilityVC.checkAvailability)
  }
}

As you can see AvailabilityController now has injected mock object AvailabilityCheckerMock(). It is simulating responses from server:

import XCTest
@testable import Vapor
@testable import App

class AvailabilityCheckerMock: AvailabilityCheckerProtocol {
  var req: Request?
  
  private let products = [
    ProductDetails(id: UUID(uuidString: "32AAEE05-C84C-4B6D-94F8-78648323807E")!, quantity: 10),
    ProductDetails(id: UUID(uuidString: "596CFCC7-63D8-4123-BF8B-2C598739DB53")!, quantity: 50)
  ]
  
  func checkProduct(id: UUID, quantity: Int) throws -> ProductDetailsResponse {
    guard let product = products.filter({$0.id == id}).first else {
      return ProductDetailsResponse(quantity: 0, status: .unavailable)
    }
    
    guard product.quantity >= quantity else {
      return ProductDetailsResponse(quantity: product.quantity, status: .unavailable)
    }
    
    return ProductDetailsResponse(quantity: product.quantity, status: .available)
  }
}

Now let’s say you would like to check if the controller is returning 404 code and status .unavailable when user asks for too many products with id 596CFCC7-63D8-4123-BF8B-2C598739DB53.

func testCheckProductAvailabilityNotEnough() throws {
  
  let expectation = self.expectation(description: "Availability")
  var responseData: Response?
  var productDetails: ProductDetailsResponse?
  
  try app?.test(.GET, "/status/596CFCC7-63D8-4123-BF8B-2C598739DB53/51") { response in
    responseData = response
    let decoder = JSONDecoder()
    productDetails = try decoder.decode(ProductDetailsResponse.self, from: response.http.body.data!)
    
    expectation.fulfill()
  }
  
  waitForExpectations(timeout: 5, handler: nil)
  
  XCTAssertEqual(responseData?.http.status, .notFound)
  XCTAssertEqual(responseData?.http.contentType, MediaType.json)
  XCTAssertEqual(productDetails?.quantity, 50)
  XCTAssertEqual(productDetails?.status, .unavailable)
}

⚠️ Don’t forget to use expectations and fulfill it inside the closure.

This test will try to reach the url: /status/596CFCC7-63D8-4123-BF8B-2C598739DB53/51. Still, AvailabilityController will respond to this call, but instead of AvailabilityChecker() the mocked version AvailabilityCheckerMock() will be used.

For more tests and the overall picture take a look at ➡️AvailabilityTests.swift file.

Summary ✅

Remember, you are not checking whether an external service is working, you are checking if controller is responding correctly. By providing the data from a different source, you can simply test the behavior of the controller. Think of this as switching the datasource in iOS 😊

Please keep in mind that this is a very simple example just to show how easily you can inject different routes in Vapor. In a normal situation you may consider moving logic from controllers to models and testing those models.

The whole sample app is available ➡️here.

What do you think? Have you been testing controllers in your projects? Do you have any tips or advice that you would like to share? Let me know - along with your questions, comments or feedback - on Twitter @mikemikina

Happy coding 😊