Checking typed errors with swift-testing
This article is part of a series.
It seems reasonable at this point to put all my file-wrangling knowledge in a reusable package so I’m not reinventing the wheel every time. Every project needs something a little bit different, so even if I pirate files or functions out, at least there will be one place to look.
Since in one of my projects I already had some XCTests
, I’m using this as a chance to migrate over to swift-testing
as well. When watching the WWDC video it mentioned that one should also be writing tests for"unhappy paths". An obviously good thing to do which I have not been doing!
So I started playing around with watching for the right error messages with swift-testing
Resources
- https://developer.apple.com/documentation/testing
- 2024 “Go Further” video https://developer.apple.com/videos/play/wwdc2024/10195
- https://oleb.net/2023/catch-error-code/
- https://www.hackingwithswift.com/new-syntax-swift-2-error-handling-try-catch
- https://forums.swift.org/t/swift-testing-and-errors-with-associated-types/79969/
- https://forums.swift.org/t/comparing-enum-cases-while-ignoring-associated-values/15922/24
General Error Catching
First I have my errors that I want to try to catch. They have to conform to equitable in order for me to test for them using the #expect
macro the way I want to.
enum ExampleError:Error {
case unknownError(_ message: String)
case codedError(_ code:Int)
case noExtras
}
extension ExampleError:Equatable {}
Then I have the struct to test. I am using async functions since that is the harder case, but this all works with synchronous functions as well.
struct TestMe {
func throwsMessage(_ message:String) async throws {
throw ExampleError.unknownError(message)
}
func throwsCode(_ codeToThrow:Int) async throws {
throw ExampleError.codedError(codeToThrow)
}
func throwsSimple() async throws {
throw ExampleError.noExtras
}
}
No associated type
The very first test function, one where it catches an error with no associated value is pretty easy:
//use in all the tests as part of a suite
let itemToTest:TestMe = TestMe()
@Test func simpleErrorTest() async throws {
//required explicit equatable
await #expect(throws: ExampleError.noExtras) {
try await itemToTest.throwsSimple()
}
}
Associated values with known values to test against.
Things get a little messier when I want to test against the Error
’s subtype.
When the value is known that is also easy, but to retrieve the value and test requires finding a way to pull it out with a case
statement.
@Test func testForExpectedCode() async throws {
let expectedCode = 42
//when you know the exact code, everything is fine.
await #expect(throws: ExampleError.codedError(expectedCode)) {
try await itemToTest.throwsCode(expectedCode)
}
let allowedRange = (20...49)
await #expect {
try await itemToTest.throwsCode(expectedCode)
} throws: { error in
guard let error = error as? ExampleError else {
return false
}
switch error {
case .codedError(let code):
return allowedRange.contains(code)
default:
return false
}
}
}
That seemed long and like I was missing something, so asked on the forums for some help. I used the advice to formulate:
@Test func testForExpectedCodeV2() async throws {
let expectedCode = 42
//demoing require keyword, not strictly necessary here.
let error = try await #require(throws: ExampleError.self) {
try await itemToTest.throwsCode(expectedCode)
}
#expect(error == .codedError(expectedCode))
#expect(await errorContainedIn(range: 20...49,
with: Int.random(in: 20...49),
from: itemToTest.throwsCode))
func errorContainedIn(range:ClosedRange<Int>, with:Int,
from function:(Int) async throws -> Void) async -> Bool {
//but cannot use require here because has to be failable for guard-case
guard case .codedError(let code) = (await #expect(throws: ExampleError.self) {
try await function(with)
}) else { return false }
return range.contains(code)
}
}
Nice reusable function that I can lift to the top of the suite.
Trying to ignore the associated value
Ignoring the associated type’s value was still pretty verbose my first pass.
@Test func testForAnyCode() async throws {
//Can't find the right way to frame this? Is it possible?
// ExampleError.codedError is not a _Type_
// #expect(throws: ExampleError.codedError(_)) {
// try await throwsCode(Int.random(in: 0...5))
// }
await #expect {
try await itemToTest.throwsCode(Int.random(in: 0...5))
} throws: { error in
guard let error = error as? ExampleError else {
return false
}
switch error {
case .codedError(_):
return true
default:
return false
}
}
}
First thing I did was add a function to the enum that honestly would have been annoying to maintain because it would need to be done for every case with an associated value.
func isCodedError() -> Bool {
switch self {
case .codedError(_):
return true
default:
return false
}
}
Which leads to a much shorter test, but a lot of down the road hassle on the error enum.
@Test func testForAnyCodeV2() async throws {
let error = await #expect(throws: ExampleError.self) {
try await itemToTest.throwsCode(Int.random(in: 0...5))
}
#expect(error?.isCodedError() == true)
}
On a different forum thread someone else posted something along the lines of…
//MARK: Error Extension
import Foundation
extension Error {
var discriminator:Int {
(self as NSError).code
}
}
@Test func testForAnyCodeV3() async throws {
let error = await #expect(throws: ExampleError.self) {
try await itemToTest.throwsCode(Int.random(in: 0...5))
}
//the presence of an actual number could be confusing if it is arbitrary
//let typeCodeForCodedError = ExampleError.codedError(3213).discriminator
let typeCodeForCodedError = 1 //better never change the order of your enum if use this though.
#expect(error?.discriminator == typeCodeForCodedError)
}
… which I have mixed feeling about because it requires Foundation
and to jump through some minor hoops to get the right value for the error (see code comments.)
See the demo repo for all the tests.
Catching errors that don’t belong to me
In the actual FileWrangler
code I want all my missing files to throw the same error, a FileServiceError.noFileAtLocation(_ path: String)
.
So the test for that might look like:
@Test(.tags(.existing)) func tryToDeleteNonExistentFile() async throws {
let fileToTestPath = touchPractice
let fileToTest = FWFile(path: fileToTestPath)
let expectedErrorMessage = "FileID not associated with deletable file."
let error = try await #require(throws: FileServiceError.self) {
try await fileServiceToTest.delete(fileToTest)
}
print(error)
#expect(allowedError(error))
let postExists = try await fileServiceToTest.locationExists(for: fileToTest)
#expect(!postExists)
func allowedError(_ error:FileServiceError) -> Bool {
switch error {
case .noFileAtLocation(_): return true
case .unknownError(let message):
return message == expectedErrorMessage
default: return false
}
}
}
But the problem is that my fileServiceToTest.delete(fileToTest)
doesn’t throw MY error, it throws an error from the system.
I found a great article on catching CocoaErrors so now my delete function re-skins that error:
public func delete(_ id: FWFile) async throws {
let path = id.locationPath
//let url = id.locationURL
//note returns true if file does not exist.
//appears to be essentially a permissions check.
guard fileManager.isDeletableFile(atPath: path) else {
throw FileServiceError("FileID not associated with deletable file.")
}
do {
try fileManager.removeItem(atPath: path)
} catch let error as CocoaError where error.code == .fileNoSuchFile {
throw FileServiceError.noFileAtLocation(path)
} catch {
throw FileServiceError.caughtError(error)
}
}