Hello all,
A notification I received in an old thread about furthering the hack we have to enable out-of-target unit testing with @testable
imports got me thinking about what we could do to better the situation. I've drawn up this RFC to kick off a broader discussion about what we can do to get modern testing facilities into the language.
Note: This is not a proposal, this is not final, this is just food for thought.
In-Line Tests
Motivation
Testing is a critical part of the development cycle of reliable software. As languages evolved, they each came to support different modalities and feature sets to better enable testing that aligned with their unique philosophies. For Objective-C, the frameworks of choice for unit testing was SenTestingKit and OCUnit and their integrated test harness. Throughout Xcode's evolution, these frameworks saw little changes from their original APIs. Deeper IDE integration only came with Xcode 5 when SenTest was phased out in favor of XCTest - a nearly identical framework and API with deeper IDE integration.
Fast-forward to today when we have a completely new language, but the same tools. Swift, despite having a vastly different programming philosophy than Objective-C, has nonetheless inherited Objective-C's solution and tried to paint over the cracks to make it work like a bona-fide Swift framework. All of this makes unit testing in Swift feel like a second-class citizen. XCTest forces a number of undesirable patterns just to conform to its interaction model:
- All tests must occur outside of the system under test in a separate testing target
- All test groups must subclass XCTestCase
- All tests must have method names beginning with the word "test"
To further support the first point, that tests occur in a separate testing bundle, Swift allows temporarily breaking the access control model by declaring an @testable import
.
On non-Darwin platforms, because we lose the dynamic introspection that defines XCTest's test harness' discovery process, we have the additional rule:
- All tests must be manually declared in
XCTMain
This additional rule has lead to the current system of having each XCTestCase
subclass define ad-hoc boilerplate to teach the test harness about where to find test functions
#if !os(macOS)
static var allTests = testCase([
("testFoo", testFoo),
])
#endif
And a LinuxMain
that must additionally manually specify all test cases. This quickly and easily leads to mismatches between macOS and Linux test suites: a common mistake encouraged by this situation is to simply forget to add a test suite or a test function to the list.
Solution
Swift deserves a modern Swift-first approach to testing, we believe that solution lies in a literate programming approach to testing. Literate programming advises us that keeping our tests close to the code it is testing leads to richer tests. Better locality of information and reasoning means less cognitive overhead when defining test cases. It also massively lowers the barriers to defining a test suite by not requiring a separate target.
We propose the addition of the @test
attribute. @test
on a function declaration transforms it into a unit testing function then and there. To better support the aggregation of these unit test functions into suites, we allow @test
on classes, structs, and enums (with some caveats) so member functions may be transitively marked @test
. Crucially, @test
declarations are only compiled into Debug builds - they are explicitly excluded from Release buildsâ .
@test
declarations come with some restrictions in order to keep the programming model logical:
-
@test
functions cannot take arguments -
@test
functions are private to their containing aggregate and may not be called by non-@test
code -
@test
structs, classes, and enums must have a no-argumentsinit()
- this may be synthesized
Because @test
functions can appear in-line with application and framework code, they naturally enable the testing of private
and fileprivate
declarations. In fact, they naturally enable the testing of internal
declarations as well. To that end, we propose the deprecation of @testable import
.
@test import XCTest
struct RawSauce {
private let four = 4
private let three = 3
// quickMaffs is only available in a testing build and
// will be invoked by the test harness automatically.
@test func quickMaffs() {
XCTAssert(2 + 2 == four)
XCTAssert(4 - 1 == three)
}
}
This Has Been Done Before
- Lagrangian is an experiment by Rob Rix to enable this directly in Cocoa.
- D, Rust, Groovy, among others have language-level support for this literate testing paradigm. Other languages like JavaScript and Scala have third-party frameworks.
Open Questions
- â You may want to test in a release build, maybe we need an orthogonal scheme for "include/don't include unit tests"
- To better enforce the rules, should we make
@test
on aggregates a protocol conformance in addition/instead?- This may enable better third-party tools
- In replacing/automating the old NSInvocation test-discovery process, can we better enable third party test harnesses too?