Skip to content

noGuardInTests: add --boolean-guards-in-tests to preserve boolean guards - #2691

Merged
calda merged 3 commits into
nicklockwood:developfrom
hxperl:no-guard-in-tests-boolean-option
Sep 16, 2026
Merged

calda merged 3 commits into
nicklockwood:developfrom
hxperl:no-guard-in-tests-boolean-option

Conversation

@hxperl

@hxperl hxperl commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

noGuardInTests converts a guard with a boolean condition into a bare assertion, which drops the guard's early exit. Under XCTest the replacement is XCTAssert(...), which records a failure but does not stop the test, so everything after the guard now executes with the condition false.

This is the case Nick reported in #2193 — the bounds check disappears and the array subscript that it protected becomes reachable on an empty array.

Reproduction against main (0256422, 0.63.0), swiftformat Guard.swift --rules noGuardInTests --swiftversion 6.1:

// before
import XCTest

final class PolygonTests: XCTestCase {
    func testCoplanar() {
        guard polygons.count > 1 else {
            return
        }

        let a = Set(polygons[0].vertices)
        XCTAssertEqual(a.count, 3)
    }
}
// after
import XCTest

final class PolygonTests: XCTestCase {
    func testCoplanar() {
        XCTAssert(polygons.count > 1)

        let a = Set(polygons[0].vertices)
        XCTAssertEqual(a.count, 3)
    }
}

With polygons empty, the input fails cleanly and returns; the output reports the assertion failure and then traps on polygons[0].

Worth noting that the rule already contains the reasoning for this, but reaches the opposite conclusion for guards:

case .booleanExpression:
    // XCTAssert doesn't halt the test, so we can't use it to replace
    // if statement conditions. For guard statements, XCTAssert is fine
    // since the guard else block handles early exit.

The guard's else block does not handle the early exit after the transform, because the transform deletes it.

The change

Adds --boolean-guards-in-tests, mirroring the shape of --guard-like-if-statements (#2588):

  • convert (default) — today's behaviour, unchanged.
  • preserve — skip any guard that contains a boolean condition.

Optional-binding guards still convert in both modes: try XCTUnwrap(...) and try #require(...) throw, so the early exit survives. A guard mixing the two (guard someCondition, let value = optionalValue else) is preserved under preserve, since the boolean half is the unsafe half — this matches Nick's framing of "only remove guard let constructs and not other types of guard".

The judgement call

I defaulted to convert, i.e. no behaviour change for anyone currently using the rule. That follows Cal's note that he hit this two or three times and chose to leave it in, and matches how --guard-like-if-statements shipped (default = existing behaviour).

The alternative is defaulting to preserve on the grounds that the current output is unsound rather than merely opinionated. There is a real argument for it — the transform is never semantics-preserving for a boolean guard under XCTest — and noGuardInTests is disabledByDefault, so the blast radius of flipping the default is small. I did not want to make that call unilaterally. Happy to switch it if you would rather; it is a one-line change plus test updates.

A narrower option is also possible: restrict the skip to XCTest only, since under Swift Testing try #require(...) throws and the conversion is semantics-preserving. I made the option framework-independent because the request in #2193 reads as a style preference about guards in general, not only about the unsound case. Also easy to narrow if you prefer.

The option name is the part I am least attached to.

Verification

  • swift test on macOS arm64 (Swift 6.2): the 5 new cases pass and the NoGuardInTestsTests suite is green (82 tests, 0 failures).
  • Five failures in the full run — CommandLineTests.testBadConfigFails2 and four SwiftFormatTests.testInputFile* cases — reproduce identically on a clean main checkout with no changes, so they are pre-existing here and unrelated. CI will need to confirm Linux.
  • Rules.md is the regenerated output from MetadataTests, not hand-edited.

Fixes #2193.

Converting a guard with a boolean condition drops its early exit. Under
XCTest the replacement `XCTAssert(...)` records a failure but does not
halt, so code after the guard now runs with the condition false — the
case reported in nicklockwood#2193, where a `guard polygons.count > 1 else { return }`
bounds check disappeared and the following `polygons[0]` became reachable
on an empty array.

Add `--boolean-guards-in-tests`, defaulting to `convert` so existing
behaviour is unchanged, and skip guards containing a boolean condition
when set to `preserve`. Optional-binding guards still convert in both
modes, since `try XCTUnwrap` / `try #require` throw and so preserve the
early exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017D7mLNAWavucy6fFFPZise
@codecov

codecov Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.50%. Comparing base (0256422) to head (e22587c).
⚠️ Report is 16 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #2691      +/-   ##
===========================================
+ Coverage    95.45%   95.50%   +0.05%     
===========================================
  Files          179      182       +3     
  Lines        27384    27809     +425     
===========================================
+ Hits         26140    26560     +420     
- Misses        1244     1249       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread Sources/Rules/NoGuardInTests.swift Outdated
// Skip if #available / #unavailable (can't be converted to #expect)
return true
case .booleanExpression:
// Converting a boolean guard discards its early exit, so any code

@calda calda Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is only true in XCTest, not Swift Testing, since we generate try #require(myBooleanCondition) in Swift Testing.

How about we also include a --boolean-guards-in-tests preserve-xctest and make that the default value. That preserves the existing default behavior for Swift Testing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I had it wrong — I treated both frameworks the same when only XCTest loses the early exit. try #require throws, so converting under Swift Testing was always safe.

Done as you suggested: preserve-xctest is now the default, with preserve and convert as the other two. Swift Testing behaviour is unchanged from before this PR.

Ten existing tests asserted XCTest conversion, so they now pass .convert explicitly rather than relying on the default — what each is checking is visible at the call site. 82 tests green, and Rules.md is regenerated.

calda is right that the early exit is only lost under XCTest. Swift
Testing generates `try #require(condition)`, which throws, so converting
a boolean guard there preserves the exit and is safe. The first version
of this option treated both frameworks the same and so preserved guards
in Swift Testing for no reason.

`--boolean-guards-in-tests` becomes a three-case option:

  preserve-xctest  (default)  preserve under XCTest, convert under Swift Testing
  preserve                    preserve under both
  convert                     convert under both, the behaviour before this option

The default fixes the unsound case and leaves Swift Testing exactly as it
was, which is what calda asked for.

Ten existing tests asserted XCTest conversion and now pass `.convert`
explicitly, so what each one is checking is visible at the call site
rather than riding on a default. Rules.md is regenerated by MetadataTests.

NoGuardInTestsTests: 82 tests, 0 failures. MetadataTests: 14, 0 failures.
The Lint job caught a single-line if body, which wrapIfStatementBodies
does not allow. Embarrassing place to leave a formatting violation.

Formatted with the build from this branch, so it is the same rules CI
runs. NoGuardInTestsTests still 82 passing.
@calda
calda merged commit f5d557a into nicklockwood:develop Sep 16, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants