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 🚀
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.
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:
init
methodLet’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()
.
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.
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 😊