Hi everyone,
This is my first post here and my first pitch for Swift. Looking forward to the feedback
Introduction
Swift 2 has introduced error handling mechanism, which has been widely adopted since. Shortly after that, XCTest
added error catching support to its assert functions. Latest change on this front was introduced in Xcode 8.3 with addition of XCTAssertNoThrow
.
Today there are still use-cases that require boilerplate or custom helpers to test throwing functions, which I believe are fairly common. We should fill the holes in XCTest so tests of code that interacts with Swift error handling is concise and clean.
The main limitation right now appears to be that XCTAssertNoThrow
does not provide access to a produced value.
Motivation
There is a major use case that is currently not covered by any of the available XCTAssert
* functions. If a developer wants to test a complex return value of a function that may throw an error, they have limited options, all of which result in awkward code.
If the return value has a type that conforms to Equatable
, a simple XCTAssertEqual
call would do the trick.
However, there are multiple cases when that is not possible or preferred:
- if the return type does not conform to
Equatable
- the type conforms to
Equatable
, but is so complex that failure message of a singleXCTAssertEqual
call produces unreadable results - the initializer of the type is not accessible
In all these cases the user might want to test the value by spelling out multiple separate assert statements over some of the properties of the return value.
Some common real-life use-cases include:
- model deserialization
- functions returning large collections
- any functions that return types with inaccessible initializers
Currently, a developer has following options to test the return value of a throwing function or an initializer:
-
Use provided
XCTAssertNoThrow
, ignoring the value -
In case all necessary initializers are available, use a single XCTAssertEqual assert
XCTAssertEqual(try BookModel(from: dictionary), BookObject(id: "...", title: "...", authors: [Author(id: "...", name: "..."), Author(id: "...", name: "...")], ...))
-
Use
XCTAssertNoThrow
, followed by a try statement and optional handling.
Something like the following:XCTAssertNoThrow(try BookModel(dictionary: sampleDictionary)) let book = try? BookModel(dictionary: sampleDictionary) XCTAssertEqual(book?.name, "...") XCTAssertEqual(book?.description, "...") XCTAssertEqual(book?.rating, 5) ...
-
Write multiple
XCTAssertEqual
calls, which implicitly already have error catching capabilities:XCTAssertNoThrow(try BookModel(dictionary: sampleDictionary)) XCTAssertEqual(try BookModel(dictionary: sampleDictionary).name, "...") XCTAssertEqual(try BookModel(dictionary: sampleDictionary).description, "...") XCTAssertEqual(try BookModel(dictionary: sampleDictionary).rating, 5) ...
-
Don’t use either of the above, and use do/catch mechanism directly
do { let book = try BookModel(dictionary: sampleDictionary) XCTAssertEqual(book.name, "...") XCTAssertEqual(book.description, "...") XCTAssertEqual(book.rating, 5) } catch { XCTFail(...) }
Option 1 does not really solve the problem, as it doesn’t actually test the value.
Option 2, if available at all, can produce failure messages that are hard to interpret and that make it extremely hard to find what is actually wrong
Options 3 and 4 have multiple downsides, such as: the function in test is executed multiple times, on each assert; copy-paste overhead to write and maintain such asserts; in case an error is thrown, multiple failures are produced:
Option 5 makes the user write boilerplate code, avoiding asserts included in XCTest framework. The boilerplate has to include not only do/catch, but also to correctly record failures.
Additional motivation point is inconsistency with XCTAssertThrowsError
, which already does provide access to the caught error for additional evaluation. Its signature looks like this:
func XCTAssertThrowsError<T>(_ expression: @autoclosure () throws -> T, _ message: @autoclosure () -> String = default, file: StaticString = #file, line: UInt = #line, _ errorHandler: (Error) -> Void = default)
Proposed solution
Add an extra trailing argument to XCTAssertNoThrow
similar to XCTAssertThrowsError
- validation closure that’s executed on a value, in case the function behaves as expected and does not throw. If a functions does throw an error, the validation closure won’t be executed.
The proposed function signature:
func XCTAssertNoThrow<T> (_ expression: @autoclosure () throws -> T, _ message: String = "", file: StaticString = #file, line: UInt = #line, also resultHandler: (T) -> Void)
Having such function available, a return value can be tested as such:
XCTAssertNoThrow(try BookModel(from: testDictionary)) { book in
XCTAssertEqual(book.id, "123")
XCTAssertEqual(model.authors.count, 66)
XCTAssertNil(model.optionalProperty)
…
}
In case a throw happens, a single failure would be recorded on the first line. In case one of the asserts from the validation closure fails, it would be correctly displayed.
Implementation details
I have a working version of this that uses existing XCTAssertNoThrow
under the hood. We’ve had this used in our test suite for the past 1.5 years, and it can be considered a prototype implementation of the proposed feature.
When it comes to an actual PR phase to swift-corelibs-xctest the implementation can be revised and adapted.
public func XCTAssertNoThrow<T> (_ expression: @autoclosure () throws -> T, _ message: String = "", file: StaticString = #file, line: UInt = #line, _ resultHandler: (T) -> Void) {
func executeAndAssignResult (_ expression: @autoclosure () throws -> T, to: inout T?) rethrows {
to = try expression()
}
var result: T?
XCTAssertNoThrow(try executeAndAssignResult(expression, to: &result), message, file: file, line: line)
if let r = result {
resultHandler(r)
}
}
Impact on existing code
The change is purely additive.
Impact on ABI stability / resilience
... tbd
(The change would be (probably?) additive if we were adding an overload, but I'm not entirely sure in case of adding a parameter with a default value. Help here is very welcome)
Alternatives considered
It is an option to do nothing in XCTest and leave it up to individual developers to add this convenience assert function to their test suites.
Note: I've written an article about this and other similar assert functions we use at Storytel. It can be found here on medium. It's not at all necessary to read the article in order to review this pitch.
/ Marina