SpryKit 是一个框架,允许在 Apple 的 Swift 语言中进行间谍 (spying) 和桩 (stubbing) 操作。
重要提示
SpryKit 是线程安全的,可以在多线程环境中使用。
目录
在为一个类编写测试时,建议仅测试该类的行为,而不是它使用的其他对象。在 Swift 中,这可能很困难。如何检查您是否在适当的时间调用了正确的方法并传入了适当的参数?SpryKit 允许您轻松创建一个间谍对象,记录每个被调用的函数和传入的参数。如何确保注入的对象将为给定的测试返回必要的值?SpryKit 允许您轻松创建一个桩对象,可以返回特定的值。这样,您可以从您正在测试的类(被测对象)的角度编写测试,仅此而已。
同时遵循 Stubbable 和 Spyable 协议!有关 Stubbable 和 Spyable 的信息,请参阅下面的相应部分。
能力
Spyable
和 Stubbable
协议。resetCallsAndStubs()
同时重置调用和桩。Spryable
协议的对象spryify()
的结果,并传入所有参数(如果有)。subscript
。get {}
中返回 stubbedValue()
的结果,并在 set {}
中使用 recordCall()
。让我们看一个例子
// The Real Thing can be a protocol
protocol StringService: AnyObject {
var readonlyProperty: String { get }
var readwriteProperty: String { set get }
func doThings()
func giveMeAString(arg1: Bool, arg2: String) -> String
static func giveMeAString(arg1: Bool, arg2: String) -> String
}
// The Real Thing can be a class
class RealStringService: StringService {
var readonlyProperty: String {
return ""
}
var readwriteProperty: String = ""
func doThings() {
// do real things
}
func giveMeAString(arg1: Bool, arg2: String) -> String {
// do real things
return ""
}
class func giveMeAString(arg1: Bool, arg2: String) -> String {
// do real things
return ""
}
}
警告
仅适用于 Swift 6.0 及更高版本。
提示
MacroAvailable - 如何处理破坏性的 API 更改。
@Spryable
final class GeneratedFakeStringService: StringService {
@SpryableVar
var readonlyProperty: String
@SpryableVar(.set)
var readwriteProperty: String
@SpryableFunc
func doThings()
@SpryableFunc
func giveMeAString(arg1: Bool, arg2: String) -> String
@SpryableFunc
static func giveMeAString(arg1: Bool, arg2: String) -> String
}
// The Fake Class (If the fake is from a class then `override` will be required for each function and property)
final class FakeStringService: StringService, Spryable {
enum ClassFunction: String, StringRepresentable {
case giveMeAStringWithArg1_Arg2 = "giveMeAString(arg1:arg2:)"
}
enum Function: String, StringRepresentable {
case readonlyProperty
case readwriteProperty
case doThings = "doThings()"
case giveMeAStringWithArg1_Arg2 = "giveMeAString(arg1:arg2:)"
}
var readonlyProperty: String {
return stubbedValue()
}
var readwriteProperty: String {
set {
recordCall(arguments: newValue)
}
get {
return stubbedValue()
}
}
func doThings() {
return spryify()
}
func giveMeAString(arg1: Bool, arg2: String) -> String {
return spryify(arguments: arg1, arg2)
}
static func giveMeAString(arg1: Bool, arg2: String) -> String {
return spryify(arguments: arg1, arg2)
}
}
Spryable 遵循 Stubbable 协议。
能力
.andReturn()
为类实例或类本身的函数桩一个返回值。.andDo()
为类实例或类本身的函数桩一个实现。.andDo()
接受一个闭包,该闭包传入一个包含参数的 Array
,并应返回桩值。.with()
指定仅在传入正确的参数时才使用的桩(有关备用规范,请参阅 Argument Enum)。fatalError()
消息,当找不到桩(或接收到的参数未通过验证)时,包括所有桩函数的详细列表。resetStubs()
重置桩。// will always return `"stubbed value"`
fakeStringService.stub(.hereAreTwoStrings).andReturn("stubbed value")
// defaults to return Void()
fakeStringService.stub(.hereAreTwoStrings).andReturn()
// specifying all arguments (will only return `true` if the arguments passed in match "first string" and "second string")
fakeStringService.stub(.hereAreTwoStrings).with("first string", "second string").andReturn(true)
// using the Arguement enum (will only return `true` if the second argument is "only this string matters")
fakeStringService.stub(.hereAreTwoStrings).with(Argument.anything, "only this string matters").andReturn(true)
// using custom validation
let customArgumentValidation = Argument.pass({ actualArgument -> Bool in
let passesCustomValidation = // ...
return passesCustomValidation
})
fakeStringService.stub(.hereAreTwoStrings).with(Argument.anything, customArgumentValidation).andReturn("stubbed value")
// using argument captor
let captor = Argument.captor()
fakeStringService.stub(.hereAreTwoStrings).with(Argument.nonNil, captor).andReturn("stubbed value")
captor.getValue(as: String.self) // gets the second argument the first time this function was called where the first argument was also non-nil.
captor.getValue(at: 1, as: String.self) // // gets the second argument the second time this function was called where the first argument was also non-nil.
// using `andDo()` - Also has the ability to specify the arguments!
fakeStringService.stub(.iHaveACompletionClosure).with("correct string", Argument.anything).andDo({ arguments in
// get the passed in argument
let completionClosure = arguments[0] as! () -> Void
// use the argument
completionClosure()
// return an appropriate value
return Void() // <-- will be returned by the stub
})
// can stub class functions as well
FakeStringService.stub(.imAClassFunction).andReturn(Void())
// do not forget to reset class stubs (since Class objects are essentially singletons)
FakeStringService.resetStubs()
Spryable 遵循 Spyable 协议。
能力
resetCalls()
重置调用。结果
// the result
let result = spyable.didCall(.functionName)
// was the function called on the fake?
result.success
// what was called on the fake?
result.recordedCallsDescription
如何使用
// passes if the function was called
fake.didCall(.functionName).success
// passes if the function was called a number of times
fake.didCall(.functionName, countSpecifier: .exactly(1)).success
// passes if the function was called at least a number of times
fake.didCall(.functionName, countSpecifier: .atLeast(1)).success
// passes if the function was called at most a number of times
fake.didCall(.functionName, countSpecifier: .atMost(1)).success
// passes if the function was called with equivalent arguments
fake.didCall(.functionName, withArguments: ["firstArg", "secondArg"]).success
// passes if the function was called with arguments that pass the specified options
fake.didCall(.functionName, withArguments: [Argument.nonNil, Argument.anything, "thirdArg"]).success
// passes if the function was called with an argument that passes the custom validation
let customArgumentValidation = Argument.pass({ argument -> Bool in
let passesCustomValidation = // ...
return passesCustomValidation
})
fake.didCall(.functionName, withArguments: [customArgumentValidation]).success
// passes if the function was called with equivalent arguments a number of times
fake.didCall(.functionName, withArguments: ["firstArg", "secondArg"], countSpecifier: .exactly(1)).success
// passes if the property was set to the right value
fake.didCall(.propertyName, with: "value").success
// passes if the class function was called
Fake.didCall(.functionName).success
SpryKit 提供了一组 XCTAssert
函数,使 SpryKit 的测试更轻松。
Have Received Matcher 旨在与 XCTest 一起使用。
// passes if the function was called
XCTAssertHaveReceived(fake, .functionName)
// passes if the function was called a number of times
XCTAssertHaveReceived(fake, .functionName, countSpecifier: .exactly(1))
// passes if the function was called at least a number of times
XCTAssertHaveReceived(fake, .functionName, countSpecifier: .atLeast(2))
// passes if the function was called at most a number of times
XCTAssertHaveReceived(fake, .functionName, countSpecifier: .atMost(1))
// passes if the function was called with equivalent arguments
XCTAssertHaveReceived(fake, .functionName, with: "firstArg", "secondArg")
// passes if the function was called with arguments that pass the specified options
XCTAssertHaveReceived(fake, .functionName, with: Argument.nonNil, Argument.anything, "thirdArg")
// passes if the function was called with an argument that passes the custom validation
let customArgumentValidation = Argument.validator({ argument -> Bool in
let passesCustomValidation = // ...
return passesCustomValidation
})
XCTAssertHaveReceived(fake, .functionName, with: customArgumentValidation)
// passes if the function was called with equivalent arguments a number of times
XCTAssertHaveReceived(fake, .functionName, with: "firstArg", "secondArg", countSpecifier: .exactly(1))
// passes if the property was set to the specified value
XCTAssertHaveReceived(fake, .propertyName, with: "value")
// passes if the class function was called
XCTAssertHaveReceived(Fake.self, .functionName)
// passes if the class property was set
XCTAssertHaveReceived(Fake.self, .propertyName)
// do not forget to reset calls on class objects (since Class objects are essentially singletons)
Fake.resetCallsAndStubs()
比较任何类型的两个值的函数。当您需要比较类/结构体的两个实例 #FF0000
即使它们不符合
Equatable 协议
#000000
时,这非常有用。
struct User {
let name: String
let age: Int
}
XCTAssertEqualAny(User(name: "John", age: 30), User(name: "John", age: 30))
XCTAssertNotEqualAny(User(name: "Bob", age: 20), User(name: "John", age: 30))
检查代码块是否抛出断言的函数。
XCTAssertThrowsAssertion {
assertionFailure("should catch this assertion failure")
}
检查代码块是否抛出错误的函数。
private func throwError() throws {
throw XCTAssertThrowsErrorTests.Error.one
}
XCTAssertThrowsError(Error.one) {
try throwError()
}
private func notThrowError() throws {
// nothig
}
XCTAssertNoThrowError(try notThrowError())
比较两个错误的函数。
XCTAssertEqualError(Error.one, Error.one)
XCTAssertNotEqualError(Error.one, Error.two)
通过数据表示比较两个图像的函数,即使它们不是同一类型。
提示
使用 UIImage.spry.testImage
使用模拟图像
XCTAssertEqualImage(Image.spry.testImage, Image.spry.testImage)
XCTAssertNotEqualImage(Image.spry.testImage, Image.spry.testImage2)
SpryKit 使用 SpryEquatable 协议来覆盖您测试类中的比较,风险自负。当您需要比较不符合 Equatable
协议的类/结构体的两个实例,和/或您需要跳过比较中的某些属性(例如,闭包)时,这非常有用。仅当您需要非常具体的东西时,才使类型符合 SpryEquatable
协议,否则请使用 Equatable
协议或 XCTAssertEqualAny
。
// custom type
extension Person: SpryEquatable {
public state func == (lhs: Person, rhs: Person) -> Bool {
return lhs.name == rhs.name
&& lhs.age == rhs.age
}
}
当不希望、不需要或不可能使用 Equatable
协议对参数进行精确比较时使用。
case anything(任何内容)
case nonNil(非空)
case nil(空)
case validator(验证器)
func captor(捕获器)
func isType<T>(是类型<T>)
func instanceOf<T>(是实例<T>)
ArgumentCaptor 用于在调用桩函数时捕获特定参数。之后,捕获器可以提供捕获的参数以进行自定义参数检查。每次调用桩函数时,ArgumentCaptor 都会捕获指定的参数。捕获的参数按时间顺序存储在每个函数调用中。获取参数时,您可以指定要获取哪个参数(默认为函数第一次被调用时)。获取捕获的参数时,必须指定类型。如果参数无法强制转换为给定的类型,则会发生 fatalError()
。
let captor = Argument.captor()
fakeStringService.stub(.hereAreTwoStrings).with(Argument.anything, captor).andReturn("stubbed value")
_ = fakeStringService.hereAreTwoStrings(string1: "first arg first call", string2: "second arg first call")
_ = fakeStringService.hereAreTwoStrings(string1: "first arg second call", string2: "second arg second call")
let secondArgFromFirstCall = captor.getValue(as: String.self) // `at:` defaults to `0` or first call
let secondArgFromSecondCall = captor.getValue(at: 1, as: String.self)
// or
let secondArgFromFirstCall: String = captor[0]
let secondArgFromSecondCall: String = captor[1]
以下描述的所有想法都适用于所有依赖于 SpryKit 的软件包,而不仅仅是宏。
为了处理破坏性的 API 更改,客户端可以将此类 API 的使用包装在条件编译子句中,以检查 MacroAvailable。
#if canImport(SpryMacroAvailable)
// code to support @Spryable
#else
// code for SpryKit without Macro
#endif
如果您有任何可以使 SpryKit 变得更好的想法,请随时提交 pull request!