SpryKit is a framework that allows spying and stubbing in Apple's Swift language.
Important
SpryKit is thread-safe and can be used in a multi-threaded environment.
Table of Contents
- Motivation
- 'Spryable' protocol conformance
- Stubbable
- Spyable
- XCTAsserts
- SpryEquatable
- Argument
- ArgumentCaptor
- MacroAvailable
When writing tests for a class, it is advised to only test that class's behavior and not the other objects it uses. With Swift this can be difficult. How do you check if you are calling the correct methods at the appropriate times and passing in the appropriate arguments? SpryKit allows you to easily make a spy object that records every called function and the passed-in arguments. How do you ensure that an injected object is going to return the necessary values for a given test? SpryKit allows you to easily make a stub object that can return a specific value. This way you can write tests from the point of view of the class you are testing (the subject under test) and nothing more.
Conform to both Stubbable and Spyable at the same time! For information about Stubbable and Spyable see their respective sections below.
Abilities
- Conform to
Spyable
andStubbable
at the same time. - Reset calls and stubs at the same time with
resetCallsAndStubs()
- Easy to implement
- Create an object that conforms to
Spryable
- In every function (the ones that should be stubbed and spied) return the result of
spryify()
passing in all arguments (if any)- also works for special functions like
subscript
- also works for special functions like
- In every property (the ones that should be stubbed and spied) return the result of
stubbedValue()
in theget {}
and userecordCall()
in theset {}
- Create an object that conforms to
Lets see example
// 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 ""
}
}
Warning
Available only for Swift 6.0 and higher.
Tip
MacroAvailable - how to handle breaking API changes.
- Spryable macro generates Spryable conformance for a class.
- SpryableFunc macro generates body for function with correct name and arguments.
- SpryableVar macro generates body for property with correct name and accessors.
@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 conforms to Stubbable.
Abilities
- Stub a return value for a function on an instance of a class or the class itself using
.andReturn()
- Stub the implementation for a function on an instance of a class or the class itself using
.andDo()
.andDo()
takes in a closure that passes in anArray
containing the parameters and should return the stubbed value
- Specify stubs that only get used if the right arguments are passed in using
.with()
(see Argument Enum for alternate specifications) - Rich
fatalError()
messages that include a detailed list of all stubbed functions when no stub is found (or the arguments received didn't pass validation) - Reset stubs with
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 conforms to Spyable.
Abilities
- Test whether a function was called or a property was set on an instance of a class or the class itself
- Specify the arguments that should have been received along with the call (see Argument Enum for alternate specifications)
- Rich Failure messages that include a detailed list of called functions and arguments
- Reset calls with
resetCalls()
The Result
// the result
let result = spyable.didCall(.functionName)
// was the function called on the fake?
result.success
// what was called on the fake?
result.recordedCallsDescription
How to Use
// 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 provides a set of XCTAssert
functions to make testing with SpryKit easier.
Have Received Matcher is made to be used with 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()
Function that compares two values of any type. This is useful when you need to compare two instances of a class/struct #FF0000
even if they are not conform to
Equatable protocol
#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))
Function that checks if the block throws an assertion.
XCTAssertThrowsAssertion {
assertionFailure("should catch this assertion failure")
}
Function that checks if the block throws an error.
private func throwError() throws {
throw XCTAssertThrowsErrorTests.Error.one
}
XCTAssertThrowsError(Error.one) {
try throwError()
}
private func notThrowError() throws {
// nothig
}
XCTAssertNoThrowError(try notThrowError())
Function that compares two errors.
XCTAssertEqualError(Error.one, Error.one)
XCTAssertNotEqualError(Error.one, Error.two)
Function that compares two images by their data representation even if they are not the same type.
Tip
Use mocked images by UIImage.spry.testImage
XCTAssertEqualImage(Image.spry.testImage, Image.spry.testImage)
XCTAssertNotEqualImage(Image.spry.testImage, Image.spry.testImage2)
SpryKit uses the SpryEquatable protocol to override comparisons in your test classes on your own risk. This is useful when you need to compare two instances of a class/struct that is not conform to Equatable
and/or you need to skip some properties in the comparison (ex. closures).
Make types conform to SpryEquatable
only when you neeed something very specific, otherwise use Equatable
protocol or XCTAssertEqualAny
.
// custom type
extension Person: SpryEquatable {
public state func == (lhs: Person, rhs: Person) -> Bool {
return lhs.name == rhs.name
&& lhs.age == rhs.age
}
}
Use when the exact comparison of an argument using the Equatable
protocol is not desired, needed, or possible.
case anything
- Used to indicate that absolutely anything passed in will be sufficient.
case nonNil
- Used to indicate that anything non-nil passed in will be sufficient.
case nil
- Used to indicate that only nil passed in will be sufficient.
case validator
- Used to provide custom validation for a specific argument.
- The associated value is a closure which takes in the argument and returns a bool to indicate whether or not it passed validation.
func captor
- Used to create a new ArgumentCaptor
- An argument captor is used to capture arguments as the function is called so that they can be accessed at a later point.
func isType<T>
- Type is exactly the type passed in match this qualification (subtypes do NOT qualify).
func instanceOf<T>
- Only objects whose type is exactly the type passed in match this qualification (subtypes do NOT qualify).
ArgumentCaptor is used to capture a specific argument when the stubbed function is called. Afterward the captor can serve up the captured argument for custom argument checking. An ArgumentCaptor will capture the specified argument every time the stubbed function is called.
Captured arguments are stored in chronological order for each function call. When getting an argument you can specify which argument to get (defaults to the first time the function was called)
When getting a captured argument the type must be specified. If the argument can not be cast as the type given then a fatalError()
will occur.
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]
All the ideas described in the following apply to all packages that depend on SpryKit, not only macros.
In order to handle breaking API changes, clients can wrap uses of such APIs in conditional compilation clauses that check MacroAvailable.
#if canImport(SpryMacroAvailable)
// code to support @Spryable
#else
// code for SpryKit without Macro
#endif
If you have an idea that can make SpryKit better, please don't hesitate to submit a pull request!