NSpry is a framework that allows spying and stubbing in Apple's Swift language. Also included is a Nimble matcher for the spied objects.
Table of Contents
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? NSpry 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? NSpry 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
Spyable
and Stubbable
at the same time.resetCallsAndStubs()
Spryable
spryify()
passing in all arguments (if any)
subscript
stubbedValue()
in the get {}
and use recordCall()
in the set {}
// The Real Thing can be a protocol
protocol StringService: class {
var readonlyProperty: String { get }
var readwriteProperty: String { set get }
func doThings()
func giveMeAString(arg1: Bool, arg2: String) -> String
class func giveMeAString(arg1: Bool, arg2: String) -> String
}
// The Real Thing can be a class
class 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 ""
}
}
// The Fake Class (If the fake is from a class then `override` will be required for each function and property)
class FakeStringService: StringService, Spryable {
enum ClassFunction: String, StringRepresentable { // <-- **REQUIRED**
case giveMeAString = "giveMeAString(arg1:arg2:)"
}
enum Function: String, StringRepresentable { // <-- **REQUIRED**
case readonlyProperty = "readonlyProperty"
case readwriteProperty = "readwriteProperty"
case doThings = "doThings()"
case giveMeAString = "giveMeAString(arg1:arg2:)"
}
var readonlyProperty: String {
return stubbedValue()
}
var readwriteProperty: String {
set {
recordCall(arguments: newValue)
}
get {
return stubbedValue()
}
}
func doThings() {
return spryify() // <-- **REQUIRED**
}
func giveMeAString(arg1: Bool, arg2: String) -> String {
return spryify(arguments: arg1, arg2) // <-- **REQUIRED**
}
class func giveMeAString(arg1: Bool, arg2: String) -> String {
return spryify(arguments: arg1, arg2) // <-- **REQUIRED**
}
}
_Spryable conforms to Stubbable.
Abilities
.andReturn()
.andDo()
.andDo()
takes in a closure that passes in an Array
containing the parameters and should return the stubbed value.with()
(see Argument Enum for alternate specifications)fatalError()
messages that include a detailed list of all stubbed functions when no stub is found (or the arguments received didn't pass validation)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
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
Have Received Matcher is made to be used with Nimble.
All Call Matchers can be used with to()
, toNot()
, toEventually()
, and toEventuallyNot()
// passes if the function was called
expect(fake).to(haveReceived(.functionName)
// passes if the function was called a number of times
expect(fake).to(haveReceived(.functionName, countSpecifier: .exactly(1)))
// passes if the function was called at least a number of times
expect(fake).to(haveReceived(.functionName, countSpecifier: .atLeast(2)))
// passes if the function was called at most a number of times
expect(fake).to(haveReceived(.functionName, countSpecifier: .atMost(1)))
// passes if the function was called with equivalent arguments
expect(fake).to(haveReceived(.functionName, with: "firstArg", "secondArg"))
// passes if the function was called with arguments that pass the specified options
expect(fake).to(haveReceived(.functionName, with: Argument.nonNil, Argument.anything, "thirdArg"))
// 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
})
expect(fake).to(haveReceived(.functionName, with: customArgumentValidation))
// passes if the function was called with equivalent arguments a number of times
expect(fake).to(haveReceived(.functionName, with: "firstArg", "secondArg", countSpecifier: .exactly(1)))
// passes if the property was set to the specified value
expect(fake).to(haveReceived(.propertyName, with "value"))
// passes if the class function was called
expect(Fake).to(haveReceived(.functionName))
// passes if the class property was set
expect(Fake).to(haveReceived(.propertyName))
// do not forget to reset calls on class objects (since Class objects are essentially singletons)
Fake.resetCalls()
NSpry uses SpryEquatable
protocol to equate arguments
SpryEquatable
using only a single line to declare conformance and one of the following
class
s are AnyObject
enum
s and struct
s are NOT AnyObject
Equatable
Protocol
Equatable
, see Apple's Documentation: EquatableEquatable
, the compiler will only tell you that you are not conforming to SpryEquatable
(You should never implement methods declared in SpryEquatable
)AnyObject
and conform to Equatable
, will use Equatable
's' ==(lhs:rhs:)
function and not pointer comparision.Defaulted Conformance List
fatalError()
at runtime if the wrapped type does not conform to SpryEquatable)// custom type
extension Person: Equatable, SpryEquatable {
public state func == (lhs: Person, rhs: Person) -> Bool {
return lhs.name == rhs.name
&& lhs.age == rhs.age
}
}
// existing type that is already Equatable
extension String: SpryEquatable {}
AutoEquatable is a library to automatically conform to Equatable which turns the above into.
extension Person: AutoEquatable, SpryEquatable { }
Use when the exact comparison of an argument using the Equatable
protocol is not desired, needed, or possible.
case anything
case nonNil
case nil
case pass((Any?) -> Bool)
func captor() -> ArgumentCaptor
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)
If you have an idea that can make NSpry better, please don't hesitate to submit a pull request! Based on Spry
link |
Stars: 1 |
Last commit: 4 days ago |
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics