Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions Sources/Commands/SwiftTestCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,9 @@ struct TestCommandOptions: ParsableArguments {
help: "Print the path of the exported code coverage JSON file.")
var shouldPrintCodeCovPath: Bool = false

var testCaseSpecifier: TestCaseSpecifier {
var xctestFilterSpecifier: TestCaseSpecifier {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: This is a wonderful rename and give clarity that it applies only to XCTest

if !filter.isEmpty {
return .regex(filter)
return .regex(filter).normalizedForXCTest()
}

return _testCaseSpecifier.map { .specific($0) } ?? .none
Expand Down Expand Up @@ -250,7 +250,7 @@ struct TestCommandOptions: ParsableArguments {
}

/// Tests filtering the specifier, which is used to filter tests to run.
public enum TestCaseSpecifier {
public enum TestCaseSpecifier: Equatable {
/// No filtering.
case none

Expand Down Expand Up @@ -383,8 +383,8 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
buildSystem: buildSystem
)
let tests = try testSuites
.filteredTests(specifier: options.testCaseSpecifier)
.skippedTests(specifier: options.skippedTests(fileSystem: swiftCommandState.fileSystem))
.filteredTests(specifier: options.xctestFilterSpecifier)
.skippedTests(specifier: options.xctestSkippedSpecifier(fileSystem: swiftCommandState.fileSystem))

let testResults: [ParallelTestRunner.TestResult]
if tests.isEmpty {
Expand Down Expand Up @@ -470,17 +470,17 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
}

private func xctestArgs(for testProducts: [BuiltTestProduct], swiftCommandState: SwiftCommandState, buildSystem: any BuildSystem) async throws -> (arguments: [String], testCount: Int?) {
switch options.testCaseSpecifier {
switch options.xctestFilterSpecifier {
case .none:
if case .skip = options.skippedTests(fileSystem: swiftCommandState.fileSystem) {
if case .skip = options.xctestSkippedSpecifier(fileSystem: swiftCommandState.fileSystem) {
fallthrough
} else {
return ([], nil)
}

case .regex, .specific, .skip:
// If the previous specifier `-s` option was used, emit the deprecation notice.
if case .specific = options.testCaseSpecifier {
if case .specific = options.xctestFilterSpecifier {
swiftCommandState.observabilityScope.emit(warning: "'--specifier' option is deprecated; use '--filter' instead")
}

Expand All @@ -495,8 +495,8 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
buildSystem: buildSystem
)
let tests = try testSuites
.filteredTests(specifier: options.testCaseSpecifier)
.skippedTests(specifier: options.skippedTests(fileSystem: swiftCommandState.fileSystem))
.filteredTests(specifier: options.xctestFilterSpecifier)
.skippedTests(specifier: options.xctestSkippedSpecifier(fileSystem: swiftCommandState.fileSystem))

return (TestRunner.xctestArguments(forTestSpecifiers: tests.map(\.specifier)), tests.count)
}
Expand Down Expand Up @@ -578,7 +578,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
(options.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) ||
testEntryPointPath == nil)

let skipSpecifier = options.skippedTests(fileSystem: swiftCommandState.fileSystem)
let skipSpecifier = options.xctestSkippedSpecifier(fileSystem: swiftCommandState.fileSystem)

var productsWithXCTests = Set<AbsolutePath>()
if xctestEnabled {
Expand All @@ -592,7 +592,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
buildSystem: buildSystem
)
let matchingTests = try xctestSuites
.filteredTests(specifier: options.testCaseSpecifier)
.filteredTests(specifier: options.xctestFilterSpecifier)
.skippedTests(specifier: skipSpecifier)
productsWithXCTests = Set(matchingTests.map(\.testProduct.bundlePath))
}
Expand Down Expand Up @@ -1826,16 +1826,61 @@ extension SwiftCommandState {
}
}

extension TestCaseSpecifier {
/// Normalizes filter/skip arguments for XCTest, which only understands test ID patterns.
///
/// `id:foo` and bare `foo` match a test ID, so the prefix is stripped and applied normally. Any other prefix
/// (e.g. `tag:`) is a Swift Testing-only concept no XCTest can match.
func normalizedForXCTest() -> TestCaseSpecifier {
switch self {
case .none, .specific:
return self
case .regex(let array):
// Encountering _any_ prefix other than `id:` (such as `tag:`) means that no XCTest test could match
// against it (because, for example, it's impossible for an XCTest to have a tag). Functionally, this means
// that if we encounter any such filters, we automatically know that we shouldn't run any XCTests. We
// represent this by returning `.regex([])` which no XCTest matches.
var normalizedPatterns = [String]()
for pattern in array {
guard let stripped = Self.strippedIDPatternForXCTest(pattern) else {
return .regex([])
}
normalizedPatterns.append(stripped)
}
return .regex(normalizedPatterns)
case .skip(let array):
let normalizedPatterns = array.compactMap(Self.strippedIDPatternForXCTest(_:))
if normalizedPatterns.isEmpty { return .none }
return .skip(normalizedPatterns)
}
}

/// The XCTest ID pattern for `pattern` (stripping any `id:` prefix), or `nil` if it carries a
/// non-`id:` prefix that XCTest can never match.
private static func strippedIDPatternForXCTest(_ pattern: String) -> String? {
let idPrefix = #/^id:/#
let genericTagPrefix = #/^[a-zA-Z]+:/#
if pattern.contains(idPrefix) {
return String(pattern.trimmingPrefix(idPrefix))
} else if pattern.contains(genericTagPrefix) {
return nil
} else {
return pattern
}
}
}

extension TestCommandOptions {
func skippedTests(fileSystem: FileSystem) -> TestCaseSpecifier {
/// Returns the specifier used for skipping tests, normalized for XCTest.
func xctestSkippedSpecifier(fileSystem: FileSystem) -> TestCaseSpecifier {
// TODO: Remove this once the environment variable is no longer used.
if let override = skippedTestsOverride(fileSystem: fileSystem) {
return override
return override.normalizedForXCTest()
}

return self._testCaseSkip.isEmpty
? .none
: .skip(self._testCaseSkip)
: .skip(self._testCaseSkip).normalizedForXCTest()
}

/// Returns the test case specifier if overridden in the environment.
Expand Down
62 changes: 62 additions & 0 deletions Tests/CommandsTests/TestCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2370,5 +2370,67 @@ struct TestCommandTests {
return (state, outputStream)
}
}
// MARK: - XCTest Filter Normalization Tests

/// Unit tests for `TestCaseSpecifier.normalizedForXCTest()`, which rewrites Swift Testing
/// filter/skip prefixes (e.g. `id:`, `tag:`) for XCTest, which only understands test-ID patterns.
@Suite
struct XCTestFilterNormalizationTests {
@Test
func idPrefixIsStrippedWhenFiltering() {
#expect(TestCaseSpecifier.regex(["id:SomeTests/testFoo"]).normalizedForXCTest() == .regex(["SomeTests/testFoo"]))
}

@Test
func noPrefixIsLeftUnchangedWhenFiltering() {
#expect(TestCaseSpecifier.regex(["SomeTests/testFoo"]).normalizedForXCTest() == .regex(["SomeTests/testFoo"]))
}

@Test
func tagPrefixFilterMatchesNoXCTests() {
#expect(TestCaseSpecifier.regex(["tag:integration"]).normalizedForXCTest() == .regex([]))
}

@Test
func arbitraryPrefixFilterMatchesNoXCTests() {
#expect(TestCaseSpecifier.regex(["foo:bar"]).normalizedForXCTest() == .regex([]))
}

@Test
func mixedIdAndTagFilterMatchesNoXCTests() {
#expect(TestCaseSpecifier.regex(["id:SomeTests/testFoo", "tag:integration"]).normalizedForXCTest() == .regex([]))
}

@Test
func tagBeforeIdFilterMatchesNoXCTests() {
#expect(TestCaseSpecifier.regex(["tag:integration", "id:SomeTests/testFoo"]).normalizedForXCTest() == .regex([]))
}

@Test
func barePatternWithTagFilterMatchesNoXCTests() {
#expect(TestCaseSpecifier.regex(["SomeTests/testFoo", "tag:integration"]).normalizedForXCTest() == .regex([]))
}

@Test
func idPrefixIsStrippedWhenSkipping() {
#expect(TestCaseSpecifier.skip(["id:SomeTests/testFoo"]).normalizedForXCTest() == .skip(["SomeTests/testFoo"]))
}

@Test
func tagPrefixSkipSkipsNoXCTests() {
#expect(TestCaseSpecifier.skip(["tag:integration"]).normalizedForXCTest() == .none)
}

@Test
func mixedIdAndTagSkipKeepsOnlyIdPatterns() {
#expect(TestCaseSpecifier.skip(["id:SomeTests/testFoo", "tag:integration"]).normalizedForXCTest() == .skip(["SomeTests/testFoo"]))
}

@Test
func noneAndSpecificPassThrough() {
#expect(TestCaseSpecifier.none.normalizedForXCTest() == .none)
#expect(TestCaseSpecifier.specific("SomeTests/testFoo").normalizedForXCTest() == .specific("SomeTests/testFoo"))
}
}
}

Loading