如何通过模拟Vapor 3和Swift中的依赖关系来测试控制器

测试具有外部依赖关系的控制器(尤其是涉及HTTP请求的控制器)非常棘手。 那是因为发送实际的请求会创建一个您不能依靠的测试,并且测试非常缓慢。 但是,您可能熟悉一种非常简单的技术:使用协议并将依赖项注入控制器。 最近,当我尝试从单元测试中的路由获取参数时遇到了一个问题。 因此,今天我想向您展示如何在Vapor 3中轻松传递正确的路线到测试功能。

让我们开始吧🚀

您为什么还要测试控制器? 🤔

想象一下一个在线商店。 在“产品详细信息”页面上,用户可以检查有多少可用商品。 问题是可用性系统是一项外部服务,因此,每当用户想要检查产品状态时,都必须调用外部服务。

由于外部服务可能已关闭或用户正在寻找的物品不再可用,因此控制器必须返回正确的信息。

模拟外部依赖dependencies️

您无法检查外部服务是否正常运行,但是可以通过模拟外部服务来检查控制器是否正在返回正确的状态代码。

您可能之前已经做过,但很简单:

  • 使用协议进行外部依赖
  • 将其注入控制器内,例如: init方法内
  • 在测试套件内部,使用符合协议的模拟对象

让我们定义一个简单的协议— AvailabilityCheckerProtocol

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

我添加了var req: Request? 只是因为它很容易使用client().get()方法并从实现内部的外部服务获取状态,所以使用该协议。

现在创建一个具有checkAvailability功能的控制器:

 import Vapor 
import Foundation

final class AvailabilityController {

var availabilityChecker: AvailabilityCheckerProtocol

init(availabilityChecker: AvailabilityCheckerProtocol) {
self.availabilityChecker = availabilityChecker
}

func checkAvailability(_ req: Request) throws -> Future {
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
}
}

的标准路线是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)
}

如果愿意,可以在此处检查AvailabilityChecker()的实现。 这是一个简单的类,符合AvailabilityCheckerProtocol协议,并使用req.client().get()获取数据。

编写测试🛠

诀窍在于,编写测试时,您必须使用Vapor应用实例初始化控制器,否则路由器将不可用。 由于参数是从req.parameters ,因此您需要向Application实例中注入特殊的测试路由器。

为此,您可以使用一些用于测试Vapor本身的扩展。 你可以在这里找到那些➡️。 不要忘记将扩展名公开,以便其他测试可以看到它。

看一下makeTest(configure:routes:)函数。 此功能正在将Router作为输入参数。 无需从routes(_ router: Router) .swift文件提供routes(_ router: Router) ,您所需要做的就是创建具有模拟依赖项的自己的路由并将其传递以进行测试。

创建XCTest文件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)
}
}

如您所见, AvailabilityController现在注入了模拟对象AvailabilityCheckerMock() 。 它正在模拟来自服务器的响应:

 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)
}
}

现在,假设您要检查控制器是否返回404代码和状态.unavailable当用户要求输入太多ID为596CFCC7-63D8-4123-BF8B-2C598739DB53产品时,该状态.unavailable用。

 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)
}

⚠️不要忘记使用expectations并在封闭中实现它。

该测试将尝试访问以下网址: /status/596CFCC7-63D8-4123-BF8B-2C598739DB53/51 。 尽管如此, AvailabilityController仍会对此调用做出响应,但是将使用模拟版本的AvailabilityCheckerMock()代替AvailabilityChecker()

有关更多测试和整体情况,请查看➡️AvailabilityTests.swift文件。

请记住,您不是在检查外部服务是否正常运行,而是在检查控制器是否正确响应。 通过提供来自不同来源的数据,您可以简单地测试控制器的行为。 将此视为在iOS中切换datasource 😊

请记住,这是一个非常简单的示例,只是为了说明您可以轻松地在Vapor中注入不同的路线。 在正常情况下,您可以考虑将逻辑从控制器转移到模型并测试这些模型。

整个示例应用程序都可以在➡️这里获得。

你怎么看? 您是否正在测试项目中的控制器? 您有什么要分享的技巧或建议吗? 在Twitter @mikemikina 上让我知道您的问题,评论或反馈

快乐编码😊