Skip to content

Commit 60581fb

Browse files
esnunesclaude
andcommitted
feat: keep tests marked unskippable from being skipped
Coverage only sees the code a test runs, so a test whose real inputs are fixtures, resources, the network or the environment can break without any covered code changing. Test authors can now mark such tests so they always run, even when TEST_COVERAGE_ATTRIBUTION_SKIP_FILE lists them. - Swift Testing: `.unskippable` and `.skippable` traits (SkippabilityTrait), on a test or a suite. The most specific one wins, so a test or a nested suite can opt back in under an unskippable suite. The `.coverageAttribution` trait checks them before it cancels a listed test. - XCTest: class methods the observer looks up by name, so nothing links the package or XCTest: `unskippableTests`, `isUnskippable` and `skippableTests`. They work from Swift (`@objc static let`) and Objective-C, and with the observer linked from a static archive with -ObjC. - Each unskippable test that runs is listed in a new `unskippable.tsv` under $TEST_COVERAGE_ATTRIBUTION_DIR/<pid>/, with whether the skip list named it (a forced run), so the runner that writes the list can stop listing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent af35643 commit 60581fb

14 files changed

Lines changed: 488 additions & 63 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
A Swift package that test targets link to record which coverage counters each test moves (per-test coverage attribution). Tuist's CLI reduces the output to per-test evidence; the record layout is a contract with it (`TuistKit.CoverageObserverOutput` in tuist/tuist).
44

55
## Layout
6-
- `Sources/TestCoverageAttributionObserver` - Objective-C, Foundation only. XCTest is looked up at run time (class, protocol and selectors by name), so nothing links XCTest. It reads each instrumented image's `__llvm_prf_cnts` directly and never calls the LLVM profile runtime. Registers its XCTest observer from a constructor when the test bundle loads, and hooks `-[XCUIApplication launch]`/`-activate` once the bundle starts, to list the app processes UI tests launch. Its constructor also reads the skip list (`TEST_COVERAGE_ATTRIBUTION_SKIP_FILE`) and hooks `+[XCTestCase defaultTestSuite]` to remove the listed tests; it lives in this file because `-ObjC` loads only archive members that define a class.
7-
- `Sources/TestCoverageAttribution` - the Swift Testing trait (`.coverageAttribution`) that calls `test_coverage_attribution_scope_begin/end` around each test, and cancels the tests `test_coverage_attribution_is_skipped` finds in the skip list. Cancelling needs `Test.cancel`, so it's compiled only with Swift 6.3 or later; before, listed tests run, since a test a trait doesn't run is reported as passed.
6+
- `Sources/TestCoverageAttributionObserver` - Objective-C, Foundation only. XCTest is looked up at run time (class, protocol and selectors by name), so nothing links XCTest. It reads each instrumented image's `__llvm_prf_cnts` directly and never calls the LLVM profile runtime. Registers its XCTest observer from a constructor when the test bundle loads, and hooks `-[XCUIApplication launch]`/`-activate` once the bundle starts, to list the app processes UI tests launch. Its constructor also reads the skip list (`TEST_COVERAGE_ATTRIBUTION_SKIP_FILE`) and hooks `+[XCTestCase defaultTestSuite]` to remove the listed tests; it lives in this file because `-ObjC` loads only archive members that define a class. A class keeps listed tests it marks unskippable with class methods looked up by name (`unskippableTests`, `isUnskippable`, `skippableTests`), and the observer lists the unskippable tests that run in `unskippable.tsv`.
7+
- `Sources/TestCoverageAttribution` - the Swift Testing trait (`.coverageAttribution`) that calls `test_coverage_attribution_scope_begin/end` around each test, and cancels the tests `test_coverage_attribution_is_skipped` finds in the skip list, unless the most specific `.unskippable`/`.skippable` trait (`SkippabilityTrait`) says they're unskippable; it reports those through `test_coverage_attribution_report_unskippable`. Cancelling needs `Test.cancel`, so it's compiled only with Swift 6.3 or later; before, listed tests run, since a test a trait doesn't run is reported as passed.
88
- `Tests/TestCoverageAttributionTests` - runs the fixtures with attribution on and parses what the observer wrote: `Records.swift` mirrors the `records.bin` layout and `FunctionTable.swift` maps counters to function names as Tuist's reader does.
99
- `Fixtures/Example` - with SwiftPM, xcodebuild on macOS and xcodebuild on an iOS simulator.
1010
- `Fixtures/Concurrency` - the overlapping, nested-trait and failure cases, in a package of their own since Swift Testing runs suites in parallel across the process.
11-
- `Fixtures/StaticArchive` - links the observer from a static archive, with and without `-ObjC`.
11+
- `Fixtures/StaticArchive` - links the observer from a static archive, with and without `-ObjC`, into a bundle with a Swift and an Objective-C test class.
1212
- `Fixtures/UITests` - an app, its UI tests and its hosted unit tests. It's a Tuist project (`Project.swift`), because a UI test needs an app target and SwiftPM can't build apps; `Launches.swift` reads `launches.tsv`, and `SkipListTests.swift` runs the `SkipList` scheme serially, in parallel and as a shard and reads the result bundle.
1313

1414
## Rules
@@ -17,6 +17,7 @@ A Swift package that test targets link to record which coverage counters each te
1717
- Changing `records.bin`: update `write_record()`, `Records.swift`, the README and Tuist's reader together, and bump `kRecordVersion` when a record gains a field.
1818
- Changing `launches.tsv`: update `write_launch()`, `Launches.swift`, the README and Tuist's reader (`CoverageObserverOutput.parseLaunches`) together.
1919
- Changing the skip list format: update `load_skip_list()`, `SkipListTests.swift`, the README and Tuist's writer together.
20+
- Changing `unskippable.tsv` or the XCTest marker names: update `write_unskippable()`/`xctest_is_unskippable()`, `SkipListTests.swift`, the README and Tuist's reader together.
2021
- No `unsafeFlags` in `Package.swift`: they make the package unusable as a versioned dependency.
2122

2223
## Commands

‎Fixtures/Example/Tests/ExampleTests/MultiplicationTests.swift‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,26 @@ struct MultiplicationTests {
1111
@Test("Multiplying by zero") func multiplyByZero() {
1212
#expect(Calculator.multiply(0, 3) == 0)
1313
}
14+
15+
/// Runs even when the skip list names it.
16+
@Test(.unskippable) func multiplyByOne() {
17+
#expect(Calculator.multiply(1, 3) == 3)
18+
}
19+
20+
/// Its tests run even when the skip list names them, except the one that opts back in. Nested,
21+
/// so `.serialized` keeps it from running at the same time as the other tests.
22+
@Suite(.unskippable)
23+
struct UnskippableMultiplicationTests {
24+
@Test func multiplyNegatives() {
25+
#expect(Calculator.multiply(-2, -3) == 6)
26+
}
27+
28+
@Test func multiplyByMinusOne() {
29+
#expect(Calculator.multiply(-1, 3) == -3)
30+
}
31+
32+
@Test(.skippable) func square() {
33+
#expect(Calculator.multiply(3, 3) == 9)
34+
}
35+
}
1436
}

‎Fixtures/StaticArchive/ArchiveTests.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ func sum(_ lhs: Int, _ rhs: Int) -> Int {
66

77
/// Links the observer from a static archive and references nothing in it.
88
final class ArchiveTests: XCTestCase {
9+
@objc static let unskippableTests = ["testUnskippable"]
10+
911
func testSum() {
1012
XCTAssertEqual(sum(2, 3), 5)
1113
}
@@ -14,4 +16,7 @@ final class ArchiveTests: XCTestCase {
1416
func testSkipped() {
1517
XCTAssertNil(ProcessInfo.processInfo.environment["TEST_COVERAGE_ATTRIBUTION_SKIP_FILE"])
1618
}
19+
20+
/// Listed with `-ObjC` too, but unskippable, so it runs either way.
21+
func testUnskippable() {}
1722
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
@import XCTest;
2+
3+
/// Declares its unskippable tests from Objective-C. With `-ObjC` every test is listed: they run but
4+
/// `testSkippable`, which the class lets the list skip.
5+
@interface ObjCArchiveTests : XCTestCase
6+
@end
7+
8+
@implementation ObjCArchiveTests
9+
10+
+ (BOOL)isUnskippable {
11+
return YES;
12+
}
13+
14+
+ (NSArray<NSString *> *)skippableTests {
15+
return @[@"testSkippable"];
16+
}
17+
18+
- (void)testUnskippable {
19+
}
20+
21+
- (void)testSkippable {
22+
XCTAssertNil(NSProcessInfo.processInfo.environment[@"TEST_COVERAGE_ATTRIBUTION_SKIP_FILE"]);
23+
}
24+
25+
@end

‎Fixtures/StaticArchive/run.sh‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ xcrun clang -c -fobjc-arc -fmodules -isysroot "$SDK" \
2222
-I "$ROOT/Sources/TestCoverageAttributionObserver/include" \
2323
"$ROOT/Sources/TestCoverageAttributionObserver/TestCoverageAttributionObserver.m" -o "$BUILD/observer.o"
2424
ar rcs "$BUILD/libTestCoverageAttributionObserver.a" "$BUILD/observer.o"
25+
# An Objective-C test class, in the bundle itself.
26+
xcrun clang -c -fobjc-arc -fmodules -isysroot "$SDK" -F "$FRAMEWORKS" \
27+
"$FIXTURE/ObjCArchiveTests.m" -o "$BUILD/ObjCArchiveTests.o"
2528
/usr/libexec/PlistBuddy \
2629
-c "Add :CFBundleIdentifier string dev.tuist.ArchiveTests" \
2730
-c "Add :CFBundleExecutable string ArchiveTests" \
2831
-c "Add :CFBundlePackageType string BNDL" \
2932
"$BUILD/ArchiveTests.xctest/Contents/Info.plist" > /dev/null
3033
xcrun swiftc -sdk "$SDK" -F "$FRAMEWORKS" -I "$LIBRARIES" -L "$LIBRARIES" \
3134
-profile-generate -profile-coverage-mapping -emit-library -Xlinker -bundle \
32-
"$FIXTURE/ArchiveTests.swift" -o "$BUILD/ArchiveTests.xctest/Contents/MacOS/ArchiveTests" \
35+
"$FIXTURE/ArchiveTests.swift" "$BUILD/ObjCArchiveTests.o" \
36+
-o "$BUILD/ArchiveTests.xctest/Contents/MacOS/ArchiveTests" \
3337
-framework XCTest -L "$BUILD" -lTestCoverageAttributionObserver "$@" \
3438
-Xlinker -rpath -Xlinker "$FRAMEWORKS" -Xlinker -rpath -Xlinker "$LIBRARIES"
3539
xcrun xctest "$BUILD/ArchiveTests.xctest"

‎Fixtures/UITests/AppTests/SkipListTests.swift‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import Testing
33
import XCTest
44

55
// Unit tests hosted in the app, run by `AttributionTests` with a skip list that names every test
6-
// called `testSkipped…` and every test of `SkippedTests`. The listed tests pass when they run, so
7-
// a run with or without the list succeeds and the result bundle tells which ran; only a class
8-
// setUp that must not run crashes.
6+
// called `testSkipped…`, every test of `SkippedTests` and every test marked unskippable. The listed
7+
// tests pass when they run, so a run with or without the list succeeds and the result bundle tells
8+
// which ran; only a class setUp that must not run crashes.
99

10-
/// Some of its tests are listed: the rest run, after the class setUp.
10+
/// Some of its tests are listed: the rest run, after the class setUp, and so does the listed one it
11+
/// marks unskippable.
1112
final class PartlySkippedTests: XCTestCase {
1213
nonisolated(unsafe) static var classSetUpRan = false
1314

15+
/// Named as the skip list names it, and as its selector.
16+
@objc static let unskippableTests = ["testUnskippable()", "testUnskippableThrowingAndReturnError:"]
17+
1418
override static func setUp() {
1519
super.setUp()
1620
classSetUpRan = true
@@ -27,6 +31,34 @@ final class PartlySkippedTests: XCTestCase {
2731

2832
/// Listed as `testSkippedAsync`; its selector is `testSkippedAsyncWithCompletionHandler:`.
2933
func testSkippedAsync() async {}
34+
35+
func testUnskippable() {
36+
XCTAssertTrue(Self.classSetUpRan)
37+
}
38+
39+
func testUnskippableThrowing() throws {}
40+
}
41+
42+
/// All of its tests are listed, but the class is unskippable: they run, after the class setUp,
43+
/// except the one it lets the list skip.
44+
final class UnskippableTests: XCTestCase {
45+
nonisolated(unsafe) static var classSetUpRan = false
46+
47+
@objc static let isUnskippable = true
48+
@objc static let skippableTests = ["testSkippable"]
49+
50+
override static func setUp() {
51+
super.setUp()
52+
classSetUpRan = true
53+
}
54+
55+
func testFirst() {
56+
XCTAssertTrue(Self.classSetUpRan)
57+
}
58+
59+
func testSecond() {}
60+
61+
func testSkippable() {}
3062
}
3163

3264
/// All of its tests are listed: the class doesn't run, not even its class setUp.
@@ -46,14 +78,32 @@ final class UnlistedTests: XCTestCase {
4678
}
4779

4880
/// The trait cancels the listed tests from Swift 6.3 on, which reports them as skipped; before,
49-
/// they run.
81+
/// they run. A listed test marked unskippable runs.
5082
@Suite(.coverageAttribution)
5183
struct SwiftTestingTests {
5284
@Test func runs() {}
5385

5486
@Test func skipped() {}
5587

5688
@Test(arguments: [1, 2]) func skippedWithArguments(value _: Int) {}
89+
90+
@Test(.unskippable) func unskippable() {}
91+
}
92+
93+
/// Every test is listed. The suite is unskippable, so they run, except the one that opts back in
94+
/// and the tests of the nested suite that does.
95+
@Suite(.coverageAttribution, .unskippable)
96+
struct UnskippableSwiftTestingTests {
97+
@Test func runs() {}
98+
99+
@Test(.skippable) func skipped() {}
100+
101+
@Suite(.skippable)
102+
struct SkippableTests {
103+
@Test func skipped() {}
104+
105+
@Test(.unskippable) func unskippable() {}
106+
}
57107
}
58108

59109
/// Listed too, but without the trait nothing reads the list, so it runs.

‎Fixtures/UITests/SkipUITests/SkipListUITests.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import XCTest
22

33
// UI tests run by `AttributionTests` with a skip list that names every test called `testSkipped`
4-
// and every test of `SkippedUITests`. The listed tests pass when they run, so the result bundle
4+
// or `testUnskippable` and every test of `SkippedUITests`. The listed tests pass when they run, so the result bundle
55
// tells which ran; only a class setUp that must not run crashes.
66

77
final class PartlySkippedUITests: XCTestCase {
8+
@objc static let unskippableTests = ["testUnskippable"]
9+
810
func testLaunch() {
911
let app = XCUIApplication()
1012
app.launch()
@@ -13,6 +15,8 @@ final class PartlySkippedUITests: XCTestCase {
1315
}
1416

1517
func testSkipped() {}
18+
19+
func testUnskippable() {}
1620
}
1721

1822
/// All of its tests are listed: the class doesn't run, not even its class setUp.

‎README.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,49 @@ TEST_RUNNER_TEST_COVERAGE_ATTRIBUTION_SKIP_FILE="$PWD/skip.txt" xcodebuild test
9999
- **What a skipped test looks like.** It's removed from the suite XCTest builds for its class, so it doesn't run and isn't in the result bundle. A class whose tests are all removed doesn't run its class `setUp` and `tearDown`. A class with tests left runs them as usual, class `setUp` included.
100100
- **Swift Testing.** In a suite with the `.coverageAttribution` trait, a listed test is cancelled before it starts, every argument of a parameterized test included, and reported as skipped, whether tests run in parallel or not. The line names it as the records do: the Swift module, the innermost suite, and the test's display name when it declares one (`Applies a discount`), its function otherwise (`appliesDiscount()`). A display name with a `/` can't be listed. This needs Swift 6.3 (Xcode 26.4) or later: before, a trait can skip a test only by not running it, which reports it as passed, so the listed tests run. Listed tests in suites without the trait run too.
101101

102+
### Unskippable tests
103+
104+
Coverage only sees the code a test runs. A test whose real inputs are something else, such as fixtures, resources, the network or the environment, can break without any covered code changing. Mark it unskippable and it always runs, even when the skip list names it.
105+
106+
**Swift Testing.** Add `.unskippable` to a test, or to a suite to cover every test in it and its nested suites, and `.skippable` to opt a test or a nested suite back in. The most specific one wins: a test's trait overrides its suite's, and a nested suite's overrides its parent's. The suite still needs the `.coverageAttribution` trait, since it's what skips listed tests.
107+
108+
```swift
109+
@Suite(.coverageAttribution, .unskippable)
110+
struct FixtureTests {
111+
@Test func parsesTheFixtures() { … } // always runs
112+
@Test(.skippable) func formatsTheDate() { … } // can be skipped
113+
}
114+
```
115+
116+
**XCTest.** Declare class methods with these names, which the package looks up at run time, so the test target needs nothing from the package. From Swift they need `@objc`.
117+
118+
| Class method | Type | Meaning |
119+
| --- | --- | --- |
120+
| `unskippableTests` | `[String]` / `NSArray<NSString *> *` | The class's tests that always run. |
121+
| `isUnskippable` | `Bool` / `BOOL` | Every test of the class always runs. |
122+
| `skippableTests` | `[String]` / `NSArray<NSString *> *` | The tests the list can still skip in a class that's unskippable. |
123+
124+
```swift
125+
final class FixtureTests: XCTestCase {
126+
@objc static let unskippableTests = ["testParsesTheFixtures"]
127+
}
128+
129+
final class NetworkTests: XCTestCase {
130+
@objc static let isUnskippable = true
131+
@objc static let skippableTests = ["testFormatsTheDate"]
132+
}
133+
```
134+
135+
```objc
136+
@implementation NetworkTests
137+
+ (BOOL)isUnskippable { return YES; }
138+
@end
139+
```
140+
141+
A test is named as in the skip list: `testParsesTheFixtures`, `testParsesTheFixtures()` or its selector. The lists win over the class: a test in `unskippableTests` always runs, a test in `skippableTests` can be skipped, and any other follows `isUnskippable`. A subclass inherits these methods and can override them. A method with a different return type is ignored.
142+
143+
**What the runner sees.** An unskippable test runs and is reported as usual. When the package records, each unskippable test that runs is listed in [`unskippable.tsv`](#output), so a runner that writes the skip list can stop listing it. A listed unskippable test is a *forced run*.
144+
102145
## Output
103146
104147
The output is written under `$TEST_COVERAGE_ATTRIBUTION_DIR/<pid>/`, one directory per test process:
@@ -109,6 +152,7 @@ The output is written under `$TEST_COVERAGE_ATTRIBUTION_DIR/<pid>/`, one directo
109152
| `<n>.data`, `<n>.names` | Image `n`'s raw `__llvm_prf_data` and `__llvm_prf_names` sections: what maps a counter to its function. |
110153
| `records.bin` | One record per scope. |
111154
| `launches.tsv` | One line per app process a UI test launched, written when `XCUIApplication.launch()` or `activate()` returns: the process's pid, the test bundle, the suite and the test, separated by tabs. A launch outside a test, such as in a class `setUp`, has an empty test name. A pid is listed once, with the scope that launched it, even when later tests activate the same process. Only written by a process that launches an app. |
155+
| `unskippable.tsv` | One line per unskippable test that ran, written when it starts: the module, the suite and the test, named as in its record (an XCTest test as XCTest names it, `testParsesTheFixtures`), then `1` if the skip list named it (a forced run) or `0` if not, separated by tabs. A test is listed once per process. A name with a tab or a line break isn't written. Only written by a process that runs an unskippable test. |
112156
113157
A scope is one of the following:
114158
- an XCTest test;

‎Sources/TestCoverageAttribution/CoverageAttributionTrait.swift‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import Testing
55

66
/// Attributes the code each Swift Testing test executes to that test, by marking where each test
77
/// starts and ends: Swift Testing has no observation center a library can join. It also skips the
8-
/// tests the skip list names, by cancelling them before they run.
8+
/// tests the skip list names, by cancelling them before they run, unless they're marked
9+
/// `.unskippable` (``SkippabilityTrait``).
910
///
1011
/// The trait has to be added to every suite, and Swift Testing runs tests in parallel within a
1112
/// process by default, which leaves overlapping tests without evidence.
@@ -34,13 +35,17 @@ public struct CoverageAttributionTrait: TestTrait, SuiteTrait, TestScoping {
3435
// The name the result bundle reports the test under, which is the test's identity
3536
// everywhere else: its display name when it declares one, its function otherwise.
3637
let name = test.displayName ?? components.last ?? test.name
37-
#if compiler(>=6.3)
38-
// `Test.cancel` reports the test as skipped. Before Swift 6.3 a trait can only
39-
// skip a test by not running it, which reports it as passed, so it runs instead.
40-
if test_coverage_attribution_is_skipped(module, suite, name) {
38+
let isListed = test_coverage_attribution_is_skipped(module, suite, name)
39+
if !SkippabilityTrait.isSkippable(test) {
40+
// It runs, listed or not, and the output says so, for the runner to stop listing it.
41+
test_coverage_attribution_report_unskippable(module, suite, name, isListed)
42+
} else if isListed {
43+
#if compiler(>=6.3)
44+
// `Test.cancel` reports the test as skipped. Before Swift 6.3 a trait can only
45+
// skip a test by not running it, which reports it as passed, so it runs instead.
4146
try Test.cancel("Listed in TEST_COVERAGE_ATTRIBUTION_SKIP_FILE", sourceLocation: test.sourceLocation)
42-
}
43-
#endif
47+
#endif
48+
}
4449
test_coverage_attribution_scope_begin(module, suite, name)
4550
defer { test_coverage_attribution_scope_end(module, suite, name) }
4651
try await Self.$isInScope.withValue(true) {

0 commit comments

Comments
 (0)