SpryKit is a powerful Swift testing framework that provides spying and stubbing capabilities, making it easier to write clean and maintainable unit tests. It's designed to help you test classes in isolation by verifying method calls and controlling return values.
Important
Thread-safe: perfect for multi-threaded test environments.
Prefer Macros (Swift 6.0+)
Use macros whenever possible—they reduce boilerplate, eliminate implementation errors, and keep test doubles concise.Reset Test Doubles Between Tests
Always callresetCallsAndStubs()intearDown()to ensure each test starts from a clean state.Use Argument Captors for Detailed Validation
When argument values are complex or dynamic,ArgumentCaptorallows you to assert their content precisely.Take Advantage of Rich Assertion Messages
SpryKit’s assertions provide detailed output on failure, making tests easier to debug and maintain.Stub Error Cases Intentionally
Ensure you test both success and failure scenarios by stubbing functions to throw errors or return unexpected values.Fake naming convention
Fakeclasses (named with theFakeprefix, e.g.,FakeUserService) should be used for all test doubles—both manual and macro-generated—in your tests. This naming convention makes their purpose obvious and keeps your tests clear.
When writing unit tests, it's considered best practice to isolate and test the behavior of a single class—referred to as the Subject Under Test (SUT). Swift makes this difficult when you need to verify interactions with dependencies, such as whether methods are called, what arguments they receive, and what values they return.
SpryKit addresses this challenge by allowing you to create spy objects that record function calls and arguments, and stub objects that return specific values or perform custom behavior. This makes it possible to write focused, deterministic tests that verify the behavior of the SUT in isolation.
Traditional mocking and stubbing in Swift can be verbose and error-prone, especially when verifying method calls and handling dynamic return values. SpryKit streamlines this process with a unified API that:
- Minimizes boilerplate through macro generation
- Provides expressive, readable test assertions
- Ensures thread safety and stability for concurrent tests
- Offers fine-grained argument control with validation and capturing
- 🎯 Spying: Record and verify method calls and their arguments
- 🎭 Stubbing: Control method return values for testing different scenarios
- 🚀 Macro Support: Reduce boilerplate with Swift 6.0+ macros
- 🔒 Thread Safety: Built-in support for multi-threaded environments
- 📱 Cross-Platform: Support for iOS, macOS, tvOS, watchOS, and visionOS
- 🧪 Rich Assertions: Comprehensive set of XCTest and Swift Testing assertions
- 🔍 Argument Capturing: Capture and inspect method arguments
- 🎨 Image Testing: Built-in support for image comparison testing
- 📊 Diff API: Powerful diff functionality for comparing strings, objects, and structures
Table of Contents
- Spying
- Stubbing
- Argument Capturing
- Spryable
- Stubbable
- Spyable
- XCTAsserts
- SpryEquatable
- Argument
- ArgumentCaptor
- MacroAvailable
- XCTAssertHaveReceived / XCTAssertHaveNotReceived
- XCTAssertEqualAny / XCTAssertNotEqualAny
- XCTAssertThrowsAssertion
- XCTAssertThrowsError / XCTAssertNoThrowError
- XCTAssertEqualError / XCTAssertNotEqualError
- XCTAssertEqualImage / XCTAssertNotEqualImage
- Diff API
- Swift Testing Overview
- expectHaveReceived / expectHaveNotReceived
- expectEqualAny / expectNotEqualAny
- expectThrowsAssertion
- expectThrows / expectNoThrow
- expectEqualError / expectNotEqualError
- expectEqualImage / expectNotEqualImage
- Diff API (Swift Testing)
Add the following to your Package.swift:
dependencies:[.package(url:"https://github.com/NikSativa/SpryKit.git", from:"3.2.0")]protocolUserService{func fetchUser(id:String)->UservarcurrentUser:User?{getset}}Note
Macros reduce boilerplate by generating protocol conformance and necessary stubbing/spying code for you. All you need to do is annotate your class or properties/functions.
@SpryablefinalclassFakeUserService:UserService{@SpryableVarvarcurrentUser:User?@SpryableFuncfunc fetchUser(id:String)->User}If you're unable to use macros (e.g., due to Swift version constraints), you can implement Spryable manually as shown:
finalclassFakeUserService:UserService,Spryable{enumClassFunction:String,StringRepresentable{case none
}enumFunction:String,StringRepresentable{case currentUser
case fetchUser ="fetchUser(id:)"}varcurrentUser:User?{get{returnstubbedValue()}set{recordCall(arguments: newValue)}}func fetchUser(id:String)->User{returnspryify(arguments: id)}}classUserViewModelTests:XCTestCase{varsut:UserViewModel!varfakeUserService:FakeUserService!overridefunc setUp(){
super.setUp()
fakeUserService =FakeUserService()
sut =UserViewModel(userService: fakeUserService)}overridefunc tearDown(){
fakeUserService.resetCallsAndStubs()
super.tearDown()}func test_fetchUser_success(){
// Given
letexpectedUser=User(id:"1", name:"John")
fakeUserService.stub(.fetchUser).with("1").andReturn(expectedUser)
// When
sut.fetchUser(id:"1")
// Then
XCTAssertHaveReceived(fakeUserService,.fetchUser)XCTAssertEqual(sut.currentUser, expectedUser)}}Note
SpryKit fully supports Apple's Swift Testing framework. Use expectHaveReceived, expectEqualAny, and other Swift Testing assertions instead of XCTest assertions.
import Testing
@Suite("UserViewModel Tests",.serialized)finalclassUserViewModelTests{@Test("Fetch user success")func test_fetchUser_success(){letfakeUserService=FakeUserService()letsut=UserViewModel(userService: fakeUserService)
// Given
letexpectedUser=User(id:"1", name:"John")
fakeUserService.stub(.fetchUser).with("1").andReturn(expectedUser)
// When
sut.fetchUser(id:"1")
// Then
expectHaveReceived(fakeUserService,.fetchUser)
#expect(sut.currentUser == expectedUser)}}Spying allows you to verify that methods were called with the correct arguments:
// Verify a method was called
XCTAssertHaveReceived(fakeService,.doSomething)
// Verify with specific arguments
XCTAssertHaveReceived(fakeService,.doSomething, with:"expected argument")
// Verify call count
XCTAssertHaveReceived(fakeService,.doSomething, countSpecifier:.exactly(2))// Verify a method was called
fakeService.method()expectHaveReceived(fakeService,.method)
// Verify with specific arguments
fakeService.stub(.doSomethingWith).andReturn("value")
_ = fakeService.doSomething(with:"expected argument")expectHaveReceived(fakeService,.doSomethingWith, with:"expected argument")
// Verify call count
fakeService.method()
fakeService.method()expectHaveReceived(fakeService,.method, countSpecifier:.exactly(2))Stubbing lets you control what methods return:
// Simple return value
fakeService.stub(.doSomething).andReturn("test value")letresult= fakeService.doSomething()
#expect(result =="test value")
// Conditional return based on arguments
fakeService.stub(.doSomethingWith).with("specific arg").andReturn("special value")letresult2= fakeService.doSomething(with:"specific arg")
#expect(result2 =="special value")
// Custom implementation
fakeService.stub(.doSomethingWith).andDo{ arguments inletarg=arguments[0]as!Stringreturn arg.uppercased()}letresult3= fakeService.doSomething(with:"test")
#expect(result3 =="TEST")Capture and inspect arguments passed to methods:
letcaptor=Argument.captor()
fakeService.stub(.doSomethingWith).with(captor).andReturn("value")
_ = fakeService.doSomething(with:"expected value")
// Later in the test
letcapturedArg= captor.getValue(as:String.self)
#expect(capturedArg =="expected value")letcustomValidation=Argument.validator{ actualArgument ->Boolinguardlet string = actualArgument as?Stringelse{returnfalse}return string.hasPrefix("test")}
fakeService.stub(.doSomethingWith).with(customValidation).andReturn("validated")letresult= fakeService.doSomething(with:"test123")
#expect(result =="validated")// Test throwing functions
XCTAssertThrowsError(try sut.riskyOperation())
// Test specific errors
XCTAssertEqualError(try sut.riskyOperation(), expectedError)XCTAssertEqualImage(actualImage, expectedImage)Conform to both Stubbable and Spyable at the same time! For information about Stubbable and Spyable see their respective sections below.
Abilities
- Conform to
SpyableandStubbableat 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
Let’s look at an example
// A real implementation can be a protocol
protocolStringService:AnyObject{varreadonlyProperty:String{get}varreadwriteProperty:String{setget}func doThings()func giveMeAString(arg1:Bool, arg2:String)->Stringstaticfunc giveMeAString(arg1:Bool, arg2:String)->String}
// A real implementation can also be a class
classRealStringService:StringService{varreadonlyProperty:String{return""}varreadwriteProperty:String=""func doThings(){
// do real things
}func giveMeAString(arg1:Bool, arg2:String)->String{
// do real things
return""}classfunc 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.
@SpryableVar accepts any combination of .get (implicit), .set, .async and .throws.
A .set property generates two function names — <name>_get and <name>_set.
@SpryableFunc accepts .asRealClosure (default) or .asArgument. With .asArgument
every @escaping closure parameter is recorded as Argument.closure instead of the closure
itself, which keeps assertions readable when you never need to invoke the closure.
A throws function or a .throws property generates try spryifyThrows(...), so .andThrow()
delivers the error instead of trapping.
@SpryablefinalclassFakeStringService:StringService{@SpryableVarvarreadonlyProperty:String@SpryableVar(.set)varreadwriteProperty:String@SpryableFuncfunc doThings()@SpryableFuncfunc giveMeAString(arg1:Bool, arg2:String)-> String
@SpryableFunc
static func giveMeAString(arg1:Bool, arg2:String)->String}@SpryablefinalclassFakeUserService:UserService{@SpryableVar(.set)varcurrentUser:User?@SpryableVar(.throws)vartoken:String@SpryableFuncfunc fetchUser(id:String)throws-> User
@SpryableFunc(.asArgument)
func observe(changes:@escaping(User)->Void)}Note
@Spryable does not support subscripts, operators, non-escaping closure parameters or
typed throws — write those bodies manually. rethrows functions keep spryify(), because a
rethrows function may not call an unconditionally throwing one. private and fileprivate
members are ignored and get no generated function names.
// The **Fake** Class: Always use the "Fake" prefix to indicate a test double (e.g., `FakeUserService`).
// If the Fake is a subclass, remember that `override` is required for each function and property.
finalclassFakeUserService:UserService,Spryable{enumClassFunction:String,StringRepresentable{case giveMeAStringWithArg1_Arg2 ="giveMeAString(arg1:arg2:)"}enumFunction:String,StringRepresentable{case readonlyProperty
case readwriteProperty
case doThings ="doThings()"case giveMeAStringWithArg1_Arg2 ="giveMeAString(arg1:arg2:)"}varreadonlyProperty:String{returnstubbedValue()}varreadwriteProperty:String{set{recordCall(arguments: newValue)}get{returnstubbedValue()}}func doThings(){returnspryify()}func giveMeAString(arg1:Bool, arg2:String)->String{returnspryify(arguments: arg1, arg2)}staticfunc giveMeAString(arg1:Bool, arg2:String)->String{returnspryify(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 anArraycontaining 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) - Stub a thrown error for a throwing function using
.andThrow() - Replace an existing stub for the same function and arguments using
.stubAgain() - 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 Argument 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
letcustomArgumentValidation=Argument.validator({ actualArgument ->BoolinletpassesCustomValidation= // ...
return passesCustomValidation
})
fakeStringService.stub(.hereAreTwoStrings).with(Argument.anything, customArgumentValidation).andReturn("stubbed value")
// using argument captor
letcaptor=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
letcompletionClosure=arguments[0]as!()->Void
// use the argument
completionClosure()
// return an appropriate value
returnVoid() // <-- will be returned by the stub
})
// throwing functions - the fake must call `spryifyThrows()` / `stubbedValueThrows()`,
// which `@SpryableFunc` generates automatically for any `throws` function
fakeStringService.stub(.loadString).andThrow(StringServiceError.notFound)
// stubbing the same function with the same arguments twice is a fatalError,
// use `stubAgain()` when replacing a stub is intentional
fakeStringService.stub(.hereAreTwoStrings).with("a","b").andReturn(true)
fakeStringService.stubAgain(.hereAreTwoStrings).with("a","b").andReturn(false)
// 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
letresult= spyable.didCall(.functionName)
// was the function called on the fake?
result.success
// what was called on the fake?
result.recordedCallsDescriptionHow 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
letcustomArgumentValidation=Argument.validator({ argument ->BoolinletpassesCustomValidation= // ...
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).successSpryKit 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
letcustomArgumentValidation=Argument.validator({ argument ->BoolinletpassesCustomValidation= // ...
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 or struct even if they do not conform to the Equatable protocol.
structUser{letname:Stringletage: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")}Warning
Simulator and Mac only. Catching a trap relies on Mach exception ports, which are private API on a physical device. Running this assertion on a real iPhone, iPad or Apple Watch aborts the whole test run instead of failing a single test — keep such tests on the simulator or macOS.
Important
Supported on macOS, iOS and visionOS with x86_64 or arm64 only. On tvOS and watchOS the
assertion is skipped: it prints a notice to the console and never fails, so a test that
relies on it silently turns green there.
Function that checks if the block throws an error.
privatefunc throwError()throws{throwXCTAssertThrowsErrorTests.Error.one
}XCTAssertThrowsError(Error.one){trythrowError()}privatefunc notThrowError()throws{
// nothing
}XCTAssertNoThrowError(trynotThrowError())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 provides powerful diff functionality to compare values and generate readable difference reports. The Diff API includes multiple methods for different use cases:
Builds a line-by-line diff between two strings using the Myers algorithm. Perfect for comparing text content, code, or any string-based data.
letdiff=Spry.diffLines("line1\nline2","line1\nline3")
// Returns: " line1\n− line2\n+ line3"
// Empty string if strings are equalBuilds a line-by-line diff between two Encodable values. Values are encoded to JSON strings using the provided encoder and then compared line-by-line.
structUser:Encodable{letid:Intletname:String}letuser1=User(id:1, name:"Alice")letuser2=User(id:2, name:"Bob")letdiff=Spry.diffEncodable(user1, user2)
// Returns formatted JSON diff with line markersBuilds a hierarchical diff between two objects using Mirror reflection. Perfect for comparing complex objects, dictionaries, sets, and collections with detailed structure information.
structUser{letid:Intletname:Stringletaddress:Address}letuser1=User(id:1, name:"Alice", address:Address(street:"Main St"))letuser2=User(id:2, name:"Bob", address:Address(street:"Oak Ave"))letdiff=Spry.diffMirror(user1, user2)
// Returns: ["name:\n|\tReceived: Bob\n|\tExpected: Alice", "id:\n|\tReceived: 2\n|\tExpected: 1", ...]You can customize the diff output:
// Use tab indentation instead of pipe
letdiff=Spry.diffMirror(user1, user2, indentationType:.tab)
// Skip printing when collection count differs
letdiff=Spry.diffMirror(array1, array2, skipPrintingOnDiffCount:true)
// Custom labels
letlabels=SpryDiffNameLabels.comparing
letdiff=Spry.diffMirror(user1, user2, nameLabels: labels)SpryKit uses the SpryEquatable protocol to override comparisons in your test classes at your own risk. This is useful when you need to compare two instances of a class or struct even if they do not conform to the Equatable protocol, or when you need to skip some properties in the comparison (e.g., closures). Make types conform to SpryEquatable only when you need something very specific; otherwise, use the Equatable protocol or XCTAssertEqualAny.
// custom type
extensionPerson:SpryEquatable{publicstaticfunc==(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.
letcaptor=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")letsecondArgFromFirstCall= captor.getValue(as:String.self) // `at:` defaults to `0` or first call
letsecondArgFromSecondCall= captor.getValue(at:1, as:String.self)
// or
letsecondArgFromFirstCall:String=captor[0]letsecondArgFromSecondCall: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. Using Swift 6.0+ macros, SpryKit automatically creates all the plumbing required to spy on function calls and stub return values—eliminating manual boilerplate and reducing the risk of human error.
#if canImport(SpryMacroAvailable)
// code to support @Spryable
#else
// code for SpryKit without Macro
#endifThe diagram below provides a high-level overview of how SpryKit fits into your test suite.
graph TD
A[Test Class] -->|uses| B[Fake Service]
B -->|conforms to| C[Spryable]
C --> D[Spyable]
C --> E[Stubbable]
B -->|macros generate| F[SpryableFunc / SpryableVar]
D -->|records| G[Function Calls & Arguments]
E -->|controls| H[Return Values & Custom Behavior]
G --> I[XCTAssertHaveReceived]
H --> J[Stub Configurations]
This flow illustrates how your test classes interact with fake services, and how SpryKit helps you monitor and control behaviors for precise and effective testing.
The diagram below illustrates how a fake service interacts with a subject under test (SUT) inside a typical unit test, and how the dependency protocol plays a role.
sequenceDiagram
participant T as Test Case
participant S as Subject Under Test (SUT)
participant F as Fake Service (Spryable)
participant P as Dependency Protocol
T->>P: Define protocol (e.g., UserService)
F->>P: Conform to protocol
T->>F: Configure stubs (e.g., .andReturn)
T->>S: Inject Fake Service
S->>F: Call (e.g., fetchUser(id:))
F-->>S: Return stubbed User
T->>F: Verify call using XCTAssertHaveReceived
This sequence highlights how SpryKit helps you define a contract via a protocol, provide a fake that conforms to it, and test the SUT's interaction with that dependency in a controlled way.
SpryKit fully supports Apple's Swift Testing framework (available in Swift 5.9+). All XCTest assertions have Swift Testing equivalents that follow Swift Testing conventions.
Swift Testing provides a modern, type-safe testing API that integrates seamlessly with SpryKit. All Swift Testing assertions are located in Source/SwiftTesting/ and are conditionally compiled with #if canImport(Testing).
Verify that methods were called with the correct arguments:
import Testing
import SpryKit
@Testfunc testMethodCall(){letfakeService=FakeService()
fakeService.doSomething()expectHaveReceived(fakeService,.doSomething)expectHaveReceived(fakeService,.doSomething, with:"expected arg")expectHaveReceived(fakeService,.doSomething, countSpecifier:.exactly(2))expectHaveNotReceived(fakeService,.otherMethod)}Compare values of any type, even if they don't conform to Equatable:
@Testfunc testEquality(){structTestUser{letname:Stringletage:Int}expectEqualAny(TestUser(name:"John", age:30),TestUser(name:"John", age:30))expectNotEqualAny(TestUser(name:"Bob", age:20),TestUser(name:"John", age:30))}Test error handling:
@Testfunc testErrorHandling(){enumTestError:Error,Equatable{case one
case two
}func throwError()throws{throwTestError.one
}func notThrowError()throws{
// nothing
}expectThrows(TestError.one){trythrowError()}expectNoThrow{trynotThrowError()}}Compare specific errors:
@Testfunc testSpecificError(){enumTestError:Error,Equatable{case one
case two
}
// Direct comparison
expectEqualError(TestError.one,TestError.one)expectNotEqualError(TestError.one,TestError.two)
// With closure that returns error
func returnError()throws->TestError?{returnTestError.one
}func returnDifferentError()throws->TestError?{returnTestError.two
}expectEqualError(TestError.one){tryreturnError()}expectNotEqualError(TestError.one){tryreturnDifferentError()}}Compare images:
@Testfunc testImageEquality(){letactualImage=generateImage()letexpectedImage=loadExpectedImage()expectEqualImage(actualImage, expectedImage)}Verify that assertions (preconditions) are thrown:
@Testfunc testAssertion(){expectThrowsAssertion{precondition(false,"Should fail")}}The autoclosure overload requires an explicit message, because a defaulted one would make the trailing-closure form above ambiguous:
expectThrowsAssertion(precondition(false,"Should fail"),"precondition must trap")Warning
Simulator and Mac only. Catching a trap relies on Mach exception ports, which are private API on a physical device. Running this assertion on a real iPhone, iPad or Apple Watch aborts the whole test run instead of failing a single test — keep such tests on the simulator or macOS.
Important
Supported on macOS, iOS and visionOS with x86_64 or arm64 only. On tvOS and watchOS the
assertion is skipped: it prints a notice to the console and never fails, so a test that
relies on it silently turns green there.
The Diff API is available in both XCTest and Swift Testing contexts. All diff methods work the same way:
import Testing
import SpryKit
@Testfunc testDiff(){
// String line-by-line diff
letdiff=Spry.diffLines("line1\nline2","line1\nline3")
#expect(diff ==""" line1 − line2 + line3""")
// Encodable diff
structTestUser:Encodable{letid:Intletname:String}letuser1=TestUser(id:1, name:"Alice")letuser2=TestUser(id:2, name:"Bob")letdiffEncodable=Spry.diffEncodable(user1, user2)
#expect(!diffEncodable.isEmpty)
// Structure diff with reflection
structTestUser{letid:Intletname:String}letuser1=TestUser(id:1, name:"Alice")letuser2=TestUser(id:2, name:"Bob")letstructureDiff=Spry.diffMirror(user1, user2)
#expect(!structureDiff.isEmpty)}When migrating from XCTest to Swift Testing:
- Replace
XCTestCasewith@Suiteand@Testattributes - Replace
XCTAssert*withexpect*functions - Use
#expectfor simple boolean checks
Before (XCTest):
finalclassMyTests:XCTestCase{func testSomething(){letfakeService=FakeService()
// do something that call service method
XCTAssertHaveReceived(fakeService,.method)}}After (Swift Testing):
@Suite("My Tests",.serialized)finalclassMyTests{@Testfunc testSomething(){letfakeService=FakeService()
// do something that call service method
fakeService.method()expectHaveReceived(fakeService,.method)}}- Swift 6.0+
- iOS 15.0+
- macOS 14.0+
- macCatalyst 15.0+
- tvOS 16.0+
- watchOS 9.0+
- visionOS 1.0+
If you have an idea that can make SpryKit better, please don't hesitate to submit a pull request!
SpryKit is available under the MIT license. See the LICENSE file for more info.