SpryKit

SpryKit 是一个框架,允许在 Apple 的 Swift 语言中进行间谍 (spying) 和桩 (stubbing) 操作。

重要提示

SpryKit 是线程安全的,可以在多线程环境中使用。

目录

动机

在为一个类编写测试时,建议仅测试该类的行为,而不是它使用的其他对象。在 Swift 中,这可能很困难。如何检查您是否在适当的时间调用了正确的方法并传入了适当的参数?SpryKit 允许您轻松创建一个间谍对象,记录每个被调用的函数和传入的参数。如何确保注入的对象将为给定的测试返回必要的值?SpryKit 允许您轻松创建一个桩对象,可以返回特定的值。这样,您可以从您正在测试的类(被测对象)的角度编写测试,仅此而已。

Spryable

同时遵循 Stubbable 和 Spyable 协议!有关 StubbableSpyable 的信息,请参阅下面的相应部分。

能力

让我们看一个例子

// 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 ""
    }
}

Spryable + 宏

警告

仅适用于 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
}

Spryable + 手动

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

可桩 (Stubbable)

Spryable 遵循 Stubbable 协议。

能力

// 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()

可间谍 (Spyable)

Spryable 遵循 Spyable 协议。

能力

结果

// 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

XCTAssert 断言

SpryKit 提供了一组 XCTAssert 函数,使 SpryKit 的测试更轻松。

XCTAssertHaveReceived / XCTAssertHaveNotReceived

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

XCTAssertEqualAny / XCTAssertNotEqualAny

比较任何类型的两个值的函数。当您需要比较类/结构体的两个实例 #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

检查代码块是否抛出断言的函数。

XCTAssertThrowsAssertion {
    assertionFailure("should catch this assertion failure")
}

XCTAssertThrowsError / XCTAssertNoThrowError

检查代码块是否抛出错误的函数。

private func throwError() throws {
    throw XCTAssertThrowsErrorTests.Error.one
}

XCTAssertThrowsError(Error.one) {
    try throwError()
}

private func notThrowError() throws {
    // nothig
}
XCTAssertNoThrowError(try notThrowError())

XCTAssertEqualError / XCTAssertNotEqualError

比较两个错误的函数。

XCTAssertEqualError(Error.one, Error.one)
XCTAssertNotEqualError(Error.one, Error.two)

XCTAssertEqualImage / XCTAssertNotEqualImage

通过数据表示比较两个图像的函数,即使它们不是同一类型。

提示

使用 UIImage.spry.testImage 使用模拟图像

XCTAssertEqualImage(Image.spry.testImage, Image.spry.testImage)
XCTAssertNotEqualImage(Image.spry.testImage, Image.spry.testImage2)

SpryEquatable

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

Argument(参数)

当不希望、不需要或不可能使用 Equatable 协议对参数进行精确比较时使用。

ArgumentCaptor(参数捕获器)

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]

MacroAvailable(宏可用性)

以下描述的所有想法都适用于所有依赖于 SpryKit 的软件包,而不仅仅是宏。

为了处理破坏性的 API 更改,客户端可以将此类 API 的使用包装在条件编译子句中,以检查 MacroAvailable。

#if canImport(SpryMacroAvailable)
// code to support @Spryable
#else
// code for SpryKit without Macro
#endif

贡献

如果您有任何可以使 SpryKit 变得更好的想法,请随时提交 pull request!