Writing custom test assertions makes your tests more expressive and easier to maintain.
But how do you support both XCTest and Swift Testing?
XCTest uses XCTFail
. Swift Testing uses Issue.record
. You can’t just call one from the other. You could write your assertions twice — or use FailKit.
-
Unified Failure Reporting:
Works with XCTest and Swift Testing, including source location. -
Cleaner Value Descriptions:
Optional values withoutOptional(…)
; strings quoted and escaped. -
Assertion Testing:
UseFailSpy
to test your custom assertions: did they fail, and how?
Let’s say we want a custom equality assertion that’s clearer than XCTestAssertEqual
:
func assertEqual<T: Equatable>(
_ actual: T,
expected: T,
file: StaticString = #filePath,
line: UInt = #line
) {
if actual == expected { return }
XCTFail("Expected \(expected), but was \(actual)", file: file, line: line)
}
This works — until you start migrating to Swift Testing. You’ll need to duplicate the function, rename it, and re-implement the failure logic.
With FailKit, you can write one assertion that works in both worlds:
func assertEqual<T: Equatable>(
_ actual: T,
expected: T,
fileID: String = #fileID,
filePath: StaticString = #filePath,
line: UInt = #line,
column: UInt = #column
) {
if actual == expected { return }
Fail.fail(
message: "Expected \(expected), but was \(actual)",
location: SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)
)
}
Fail.fail
automatically routes to the appropriate testing framework.
Consider this failure message:
"Expected \(expected), but was \(actual)"
Depending on the type, the results may be unclear:
Type | Without FailKit | With describe() |
---|---|---|
Int | Expected 123, but was 456 | Expected 123, but was 456 |
Int? | Expected Optional(123), but was Optional(456) | Expected 123, but was 456 |
String | Expected ab cd, but was de fg | Expected "ab cd", but was "de fg" |
Improve this by using:
"Expected \(describe(expected)), but was (describe(actual))"
Optional values are unwrapped. Strings are quoted and escaped, making special characters visible.
When a test has multiple assertions, it helps to add a short distinguishing message:
let result = 6 * 9
assertEqual(result, 42, "answer to the ultimate question")
To support this, add a message
parameter with a default:
When a test has multiple assertions, it’s often helpful to add a distinguishing message. This helps us identify the point of failure even from raw console output, as you get from a build server.
To separate this distinguishing message from the main message, use FailKit’s messageSuffix
function. First, add a String
parameter with a default value of empty string:
func assertEqual<T: Equatable>(
_ actual: T,
expected: T,
message: String = "",
...
)
And append it using messageSuffix
:
"Expected \(expected), but was \(actual)" + messageSuffix(message)
FailKit will insert a separator if the message is non-empty:
Expected 42, but was 54 - answer to the ultimate question
You can test your assertion helpers using FailSpy
. First, modify your function to take a Failing
parameter:
func assertEqual<T: Equatable>(
_ actual: T,
expected: T,
...,
failure: any Failing = Fail()
)
Then, call failure.fail(…)
instead of Fail.fail(…)
.
To test it:
@Test
func equal() async throws {
let failSpy = FailSpy()
assertEqual(1, expected: 1, failure: failSpy)
#expect(failSpy.callCount == 0)
}
@Test
func mismatch() async throws {
let failSpy = FailSpy()
assertEqual(2, expected: 1, failure: failSpy)
#expect(failSpy.callCount == 1)
#expect(failSpy.messages.first == "Expected 1, but was 2")
}
You can now test your own test helpers — and TDD them, too.
The describe()
function formats values to improve test output:
- Optionals: Removes
Optional(…)
wrapper - Strings: Wraps in quotes and escapes special characters
\"
(quote)\n
,\r
,\t
(newline, carriage return, tab)
- Other types: Use default Swift description
Check out the Demo folder to see:
- A real custom assertion built using FailKit
- How to test that assertion using
FailSpy
It’s a complete, working example you can use as a starting point for your own helpers.
Use Swift Package Manager:
dependencies: [
.package(url: "https://github.com/jonreid/FailKit.git", from: "1.0.0"),
],
And in your target:
dependencies: ["FailKit"]
Jon Reid is the author of iOS Unit Testing by Example.
Find more at Quality Coding.