From cc74a694254279749a1485d4e4b1cc6116f08cb3 Mon Sep 17 00:00:00 2001 From: Cal Stephens Date: Sat, 25 Jan 2025 17:13:15 -0800 Subject: [PATCH] Add rule to prefer Swift Testing over XCTest --- Rules.md | 73 +++ Sources/ParsingHelpers.swift | 100 +++- Sources/RuleRegistry.generated.swift | 1 + Sources/Rules/RedundantEquatable.swift | 62 --- Sources/Rules/SwiftTesting.swift | 542 ++++++++++++++++++++++ SwiftFormat.xcodeproj/project.pbxproj | 14 + Tests/CodeOrganizationTests.swift | 2 +- Tests/ParsingHelpersTests.swift | 10 +- Tests/Rules/SwiftTestingTests.swift | 619 +++++++++++++++++++++++++ 9 files changed, 1350 insertions(+), 73 deletions(-) create mode 100644 Sources/Rules/SwiftTesting.swift create mode 100644 Tests/Rules/SwiftTestingTests.swift diff --git a/Rules.md b/Rules.md index 14d6a6f3b..34e2bd250 100644 --- a/Rules.md +++ b/Rules.md @@ -112,6 +112,7 @@ * [redundantProperty](#redundantProperty) * [sortSwitchCases](#sortSwitchCases) * [spacingGuards](#spacingGuards) +* [swiftTesting](#swiftTesting) * [unusedPrivateDeclarations](#unusedPrivateDeclarations) * [wrapConditionalBodies](#wrapConditionalBodies) * [wrapEnumCases](#wrapEnumCases) @@ -2916,6 +2917,78 @@ set to 4.2 or above.
+## swiftTesting + +Prefer the Swift Testing library over XCTest. + +
+Examples + +```diff + @testable import MyFeatureLib +- import XCTest ++ import Testing + +- final class MyFeatureTests: XCTestCase { +- func testMyFeatureHasNoBugs() { +- let myFeature = MyFeature() +- myFeature.runAction() +- XCTAssertFalse(myFeature.hasBugs, "My feature has no bugs") +- XCTAssertEqual(myFeature.crashes.count, 0, "My feature doesn't crash") +- XCTAssertNil(myFeature.crashReport) +- } +- } ++ @MainActor ++ final class MyFeatureTests { ++ @Test func myFeatureHasNoBugs() { ++ let myFeature = MyFeature() ++ myFeature.runAction() ++ #expect(!myFeature.hasBugs, "My feature has no bugs") ++ #expect(myFeature.crashes.isEmpty, "My feature doesn't crash") ++ #expect(myFeature.crashReport == nil) ++ } ++ } + +- final class MyFeatureTests: XCTestCase { +- var myFeature: MyFeature! +- +- override func setUp() async throws { +- myFeature = try await MyFeature() +- } +- +- override func tearDown() { +- myFeature = nil +- } +- +- func testMyFeatureWorks() { +- myFeature.runAction() +- XCTAssertTrue(myFeature.worksProperly) +- XCTAssertEqual(myFeature.screens.count, 8) +- } +- } ++ @MainActor ++ final class MyFeatureTests { ++ var myFeature: MyFeature! ++ ++ init() async throws { ++ myFeature = try await MyFeature() ++ } ++ ++ deinit { ++ myFeature = nil ++ } ++ ++ @Test func myFeatureWorks() { ++ myFeature.runAction() ++ #expect(myFeature.worksProperly) ++ #expect(myFeature.screens.count == 8) ++ } ++ } +``` + +
+
+ ## todos Use correct formatting for `TODO:`, `MARK:` or `FIXME:` comments. diff --git a/Sources/ParsingHelpers.swift b/Sources/ParsingHelpers.swift index 9aabe2153..d49e0799e 100644 --- a/Sources/ParsingHelpers.swift +++ b/Sources/ParsingHelpers.swift @@ -2014,7 +2014,7 @@ extension Formatter { switch tokens[nextPartIndex] { case .operator(".", .infix): name += "." - case let .identifier(string): + case let .identifier(string) where name.hasSuffix("."): name += string default: break loop @@ -2058,6 +2058,57 @@ extension Formatter { return importStack } + /// Adds imports for the given list of modules to this file if not already present + func addImports(_ importsToAddIfNeeded: Set) { + let importRanges = parseImports() + let currentImports = Set(importRanges.flatMap { $0.map(\.module) }) + + for importToAddIfNeeded in importsToAddIfNeeded { + guard !currentImports.contains(importToAddIfNeeded) else { continue } + + let newImport: [Token] = [.keyword("import"), .space(" "), .identifier(importToAddIfNeeded)] + + // If there are any existing imports, add the new import in the existing group + if let firstImportIndex = index(of: .keyword("import"), after: -1) { + let startOfFirstImport = startOfModifiers(at: firstImportIndex, includingAttributes: true) + insert(newImport + [linebreakToken(for: firstImportIndex)], at: startOfFirstImport) + } + + // Otherwise if there are no imports: + // - Make sure to insert the comment after any header comment if present + // - Include a blank line after the import + else { + let insertionIndex: Int + if let headerCommentRange = headerCommentTokenRange(), !headerCommentRange.isEmpty { + insertionIndex = headerCommentRange.upperBound + } else { + insertionIndex = 0 + } + + let newImportWithBlankLine = newImport + [ + linebreakToken(for: insertionIndex), + linebreakToken(for: insertionIndex), + ] + + insert(newImportWithBlankLine, at: insertionIndex) + } + } + } + + /// Removes the import for the given module names if present + func removeImports(_ moduleNames: Set) { + let imports = parseImports().flatMap { $0 } + let importsToRemove = imports.filter { moduleNames.contains($0.module) } + + let importsToRemoveByFileOrder = importsToRemove.sorted(by: { lhs, rhs in + lhs.range.lowerBound < rhs.range.lowerBound + }) + + for importToRemove in importsToRemoveByFileOrder.reversed() { + removeTokens(in: importToRemove.range) + } + } + /// Parses the arguments of the closure whose open brace is at the given index. /// Returns `nil` if this is an anonymous closure, or if there was an issue parsing the closure arguments. /// - `{ foo in ... }` returns `argumentNames: ["foo"]` @@ -2532,15 +2583,22 @@ extension Formatter { return arguments } + struct FunctionCallArgument { + /// The label of the argument. `nil` if unlabeled. + let label: String? + /// The value of the argument, including any leading or trailing whitespace / comments. + let value: String + } + /// Parses the parameter labels of the function call with its `(` start of scope /// token at the given index. - func parseFunctionCallArgumentLabels(startOfScope: Int) -> [String?] { + func parseFunctionCallArguments(startOfScope: Int) -> [FunctionCallArgument] { assert(tokens[startOfScope] == .startOfScope("(")) guard let endOfScope = endOfScope(at: startOfScope), index(of: .nonSpaceOrCommentOrLinebreak, after: startOfScope) != endOfScope else { return [] } - var argumentLabels: [String?] = [] + var argumentLabels: [FunctionCallArgument] = [] var currentIndex = startOfScope repeat { @@ -2551,9 +2609,15 @@ extension Formatter { let argumentLabelIndex = index(of: .nonSpaceOrCommentOrLinebreak, before: colonIndex), tokens[argumentLabelIndex].isIdentifier { - argumentLabels.append(tokens[argumentLabelIndex].string) + argumentLabels.append(FunctionCallArgument( + label: tokens[argumentLabelIndex].string, + value: tokens[colonIndex + 1 ..< endOfCurrentArgument].string + )) } else { - argumentLabels.append(nil) + argumentLabels.append(FunctionCallArgument( + label: nil, + value: tokens[endOfPreviousArgument + 1 ..< endOfCurrentArgument].string + )) } if endOfCurrentArgument >= endOfScope { @@ -2603,6 +2667,32 @@ extension Formatter { return conformances } + /// Removes the protocol conformance at the given index. + /// e.g. can remove `Foo` from `Type: Foo, Bar {` (becomes `Type: Bar {`). + func removeConformance(at conformanceIndex: Int) { + guard let previousToken = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex), + let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceIndex) + else { return } + + // The first conformance will be preceded by a colon. + // Every conformance but the last one will be followed by a comma. + // - for example: `Type: Foo, Bar, Baaz {` + let isFirstConformance = tokens[previousToken] == .delimiter(":") + let isLastConformance = tokens[nextToken] != .delimiter(",") + let isOnlyConformance = isFirstConformance && isLastConformance + + if isLastConformance || isOnlyConformance { + removeTokens(in: previousToken ... conformanceIndex) + } else { + // When changing `Foo, Bar` to just `Bar`, also remove the space between them + if token(at: nextToken + 1)?.isSpace == true { + removeTokens(in: conformanceIndex ... (nextToken + 1)) + } else { + removeTokens(in: conformanceIndex ... (nextToken + 1)) + } + } + } + /// The explicit `Visibility` of the `Declaration` with its keyword at the given index func declarationVisibility(keywordIndex: Int) -> Visibility? { // Search for a visibility keyword in the tokens before the primary keyword, diff --git a/Sources/RuleRegistry.generated.swift b/Sources/RuleRegistry.generated.swift index 1d7f5e71d..7a613ff2c 100644 --- a/Sources/RuleRegistry.generated.swift +++ b/Sources/RuleRegistry.generated.swift @@ -107,6 +107,7 @@ let ruleRegistry: [String: FormatRule] = [ "specifiers": .specifiers, "strongOutlets": .strongOutlets, "strongifiedSelf": .strongifiedSelf, + "swiftTesting": .swiftTesting, "todos": .todos, "trailingClosures": .trailingClosures, "trailingCommas": .trailingCommas, diff --git a/Sources/Rules/RedundantEquatable.swift b/Sources/Rules/RedundantEquatable.swift index 3a1202d9e..1be71a3b6 100644 --- a/Sources/Rules/RedundantEquatable.swift +++ b/Sources/Rules/RedundantEquatable.swift @@ -289,66 +289,4 @@ extension Formatter { return validComparedProperties } - - /// Removes the protocol conformance at the given index. - /// e.g. can remove `Foo` from `Type: Foo, Bar {` (becomes `Type: Bar {`). - func removeConformance(at conformanceIndex: Int) { - guard let previousToken = index(of: .nonSpaceOrCommentOrLinebreak, before: conformanceIndex), - let nextToken = index(of: .nonSpaceOrCommentOrLinebreak, after: conformanceIndex) - else { return } - - // The first conformance will be preceded by a colon. - // Every conformance but the last one will be followed by a comma. - // - for example: `Type: Foo, Bar, Baaz {` - let isFirstConformance = tokens[previousToken] == .delimiter(":") - let isLastConformance = tokens[nextToken] != .delimiter(",") - let isOnlyConformance = isFirstConformance && isLastConformance - - if isLastConformance || isOnlyConformance { - removeTokens(in: previousToken ... conformanceIndex) - } else { - // When changing `Foo, Bar` to just `Bar`, also remove the space between them - if token(at: nextToken + 1)?.isSpace == true { - removeTokens(in: conformanceIndex ... (nextToken + 1)) - } else { - removeTokens(in: conformanceIndex ... (nextToken + 1)) - } - } - } - - /// Adds imports for the given list of modules to this file if not already present - func addImports(_ importsToAddIfNeeded: Set) { - let importRanges = parseImports() - let currentImports = Set(importRanges.flatMap { $0.map(\.module) }) - - for importToAddIfNeeded in importsToAddIfNeeded { - guard !currentImports.contains(importToAddIfNeeded) else { continue } - - let newImport: [Token] = [.keyword("import"), .space(" "), .identifier(importToAddIfNeeded)] - - // If there are any existing imports, add the new import in the existing group - if let firstImportIndex = index(of: .keyword("import"), after: -1) { - insert(newImport + [linebreakToken(for: firstImportIndex)], at: firstImportIndex) - } - - // Otherwise if there are no imports: - // - Make sure to insert the comment after any header comment if present - // - Include a blank line after the import - else { - let insertionIndex: Int - if let headerCommentRange = headerCommentTokenRange(), !headerCommentRange.isEmpty { - insertionIndex = headerCommentRange.upperBound - } else { - insertionIndex = 0 - } - - let newImportWithBlankLine = newImport + [ - linebreakToken(for: insertionIndex), - linebreakToken(for: insertionIndex), - ] - - insert(newImportWithBlankLine, at: insertionIndex) - } - } - } } diff --git a/Sources/Rules/SwiftTesting.swift b/Sources/Rules/SwiftTesting.swift new file mode 100644 index 000000000..1b3b14ab8 --- /dev/null +++ b/Sources/Rules/SwiftTesting.swift @@ -0,0 +1,542 @@ +// +// SwiftTesting.swift +// SwiftFormatTests +// +// Created by Cal Stephens on 1/25/25. +// Copyright © 2025 Nick Lockwood. All rights reserved. +// + +import Foundation + +public extension FormatRule { + static let swiftTesting = FormatRule( + help: "Prefer the Swift Testing library over XCTest.", + disabledByDefault: true + ) { formatter in + // Swift Testing was introduced in Xcode 16.0 with Swift 6.0 + guard formatter.options.swiftVersion >= "6.0" else { return } + + // Ensure there are no XCTest helpers that this rule doesn't support + // before we start converting any test cases. + guard !formatter.hasUnsupportedXCTestHelper() else { return } + + let declarations = formatter.parseDeclarations() + + let xcTestSuites = declarations + .compactMap(\.asTypeDeclaration) + .filter { $0.conformances.contains(where: { $0.conformance == "XCTestCase" }) } + + guard !xcTestSuites.isEmpty, + !xcTestSuites.contains(where: { $0.hasUnsupportedXCTestFunctionality() }) + else { return } + + formatter.addImports(["Testing"]) + formatter.removeImports(["XCTest"]) + + for xcTestSuite in xcTestSuites { + xcTestSuite.convertXCTestCaseToSwiftTestingSuite() + } + + formatter.forEach(.identifier) { identifierIndex, token in + if token.string.hasPrefix("XCT") { + formatter.convertXCTestHelperToSwiftTestingExpectation(at: identifierIndex) + } + } + } examples: { + """ + ```diff + @testable import MyFeatureLib + - import XCTest + + import Testing + + - final class MyFeatureTests: XCTestCase { + - func testMyFeatureHasNoBugs() { + - let myFeature = MyFeature() + - myFeature.runAction() + - XCTAssertFalse(myFeature.hasBugs, "My feature has no bugs") + - XCTAssertEqual(myFeature.crashes.count, 0, "My feature doesn't crash") + - XCTAssertNil(myFeature.crashReport) + - } + - } + + @MainActor + + final class MyFeatureTests { + + @Test func myFeatureHasNoBugs() { + + let myFeature = MyFeature() + + myFeature.runAction() + + #expect(!myFeature.hasBugs, "My feature has no bugs") + + #expect(myFeature.crashes.isEmpty, "My feature doesn't crash") + + #expect(myFeature.crashReport == nil) + + } + + } + + - final class MyFeatureTests: XCTestCase { + - var myFeature: MyFeature! + - + - override func setUp() async throws { + - myFeature = try await MyFeature() + - } + - + - override func tearDown() { + - myFeature = nil + - } + - + - func testMyFeatureWorks() { + - myFeature.runAction() + - XCTAssertTrue(myFeature.worksProperly) + - XCTAssertEqual(myFeature.screens.count, 8) + - } + - } + + @MainActor + + final class MyFeatureTests { + + var myFeature: MyFeature! + + + + init() async throws { + + myFeature = try await MyFeature() + + } + + + + deinit { + + myFeature = nil + + } + + + + @Test func myFeatureWorks() { + + myFeature.runAction() + + #expect(myFeature.worksProperly) + + #expect(myFeature.screens.count == 8) + + } + + } + ``` + """ + } +} + +// MARK: XCTestCase test suite convesaion + +extension TypeDeclaration { + /// Whether or not this declaration uses XCTest functionality that is + /// not supported by the swiftTesting rule. + func hasUnsupportedXCTestFunctionality() -> Bool { + let overriddenMethods = body.filter { + $0.modifiers.contains("override") + } + + let supportedOverrides = Set(["setUp", "setUpWithError", "tearDown"]) + + for overriddenMethod in overriddenMethods { + guard let methodName = overriddenMethod.name, + supportedOverrides.contains(methodName) + else { return true } + + // async / throws `tearDown` can't be converted to a `deinit` + if methodName == "tearDown", + overriddenMethod.keyword == "func", + let startOfArguments = formatter.index(of: .startOfScope("("), after: overriddenMethod.keywordIndex), + let endOfArguments = formatter.endOfScope(at: startOfArguments), + let effect = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: endOfArguments), + ["async", "throws"].contains(tokens[effect].string) + { + return true + } + } + + return false + } + + /// Converts this XCTestCase implementation to a Swift Testing test suite + func convertXCTestCaseToSwiftTestingSuite() { + // Remove the XCTestCase conformance + if let xcTestCaseConformance = conformances.first(where: { $0.conformance == "XCTestCase" }) { + formatter.removeConformance(at: xcTestCaseConformance.index) + } + + // From the XCTest to Swift Testing migration guide: + // https://developer.apple.com/documentation/testing/migratingfromxctest + // + // XCTest runs synchronous test methods on the main actor by default, + // while the testing library runs all test functions on an arbitrary task. + // If a test function must run on the main thread, isolate it to the main actor + // with @MainActor, or run the thread-sensitive code inside a call to + // MainActor.run(resultType:body:). + // + // Moving test case to a background thread may cause failures, e.g. if + // the test case accesses any UIKit APIs, so we mark the test suite + // as @MainActor for maximum compatibility. + if !modifiers.contains("@MainActor") { + let startOfModifiers = formatter.startOfModifiers(at: keywordIndex, includingAttributes: true) + formatter.insert(tokenize("@MainActor\n"), at: startOfModifiers) + } + + let instanceMethods = body.filter { $0.keyword == "func" && !$0.modifiers.contains("static") } + let allIdentifiersInTestSuite = Set(formatter.tokens[range].filter(\.isIdentifier).map(\.string)) + + for instanceMethod in instanceMethods { + guard let methodName = instanceMethod.name, + let startOfParameters = formatter.index(of: .startOfScope("("), after: instanceMethod.keywordIndex), + let endOfParameters = formatter.endOfScope(at: startOfParameters), + let startOfFunctionBody = formatter.index(of: .startOfScope("{"), after: endOfParameters), + let endOfFunctionBody = formatter.endOfScope(at: startOfFunctionBody) + else { continue } + + // Convert the setUp method to an initializer + if methodName == "setUp" || methodName == "setUpWithError" { + formatter.convertXCTestOverride( + at: instanceMethod.keywordIndex, + toLifecycleMethod: "init" + ) + } + + // Convert the tearDown method to a deinit + if methodName == "tearDown" { + formatter.convertXCTestOverride( + at: instanceMethod.keywordIndex, + toLifecycleMethod: "deinit" + ) + } + + // Convert any test case method to a @Test method + if methodName.hasPrefix("test") { + let arguments = formatter.parseFunctionDeclarationArguments(startOfScope: startOfParameters) + guard arguments.isEmpty else { continue } + + // In Swift Testing, idiomatic test case names don't start with "test". + var newTestCaseName = methodName.dropFirst("test".count) + newTestCaseName = newTestCaseName.first!.lowercased() + newTestCaseName.dropFirst() + + while newTestCaseName.hasPrefix("_") { + newTestCaseName = newTestCaseName.dropFirst() + } + + // Ensure that the new identifier is valid (e.g. starts with a letter, not a number), + // and is unique / doesn't already exist somewhere in the test suite. + if newTestCaseName.first?.isLetter == true, // ensure the new identifier is valid + !allIdentifiersInTestSuite.contains(String(newTestCaseName)), + let nameIndex = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: instanceMethod.keywordIndex) + { + formatter.replaceToken(at: nameIndex, with: .identifier(String(newTestCaseName))) + } + + // XCTest assertions have throwing autoclosures, so can include a `try` + // without the test case being `throws`. If the test case method isn't `throws` + // but has any `try`s in the method body, we have to add `throws`. + if !tokens[endOfParameters ..< startOfFunctionBody].contains(.keyword("throws")), + tokens[startOfFunctionBody ... endOfFunctionBody].contains(.keyword("try")), + let indexBeforeStartOfFunctionBody = formatter.index(of: .nonSpaceOrComment, before: startOfFunctionBody) + { + formatter.insert([.space(" "), .keyword("throws")], at: indexBeforeStartOfFunctionBody + 1) + } + + // Add the @Test macro + formatter.insert(tokenize("@Test "), at: formatter.startOfModifiers(at: instanceMethod.keywordIndex, includingAttributes: true)) + } + } + } +} + +// MARK: XCTest function helpers + +extension Formatter { + /// Whether or not the file contains an XCTest helper function that + /// isn't supported by the swiftTesting rule. + func hasUnsupportedXCTestHelper() -> Bool { + // https://developer.apple.com/documentation/xctest/xctestcase + let xcTestCaseInstanceMethods = Set(["expectation", "wait", "measure", "measureMetrics", "addTeardownBlock", "runsForEachTargetApplicationUIConfiguration", "continueAfterFailure", "executionTimeAllowance", "startMeasuring", "stopMeasuring", "defaultPerformanceMetrics", "defaultMetrics", "defaultMeasureOptions", "fulfillment", "addUIInterruptionMonitor", "keyValueObservingExpectation", "removeUIInterruptionMonitor"]) + + for index in tokens.indices where tokens[index].isIdentifier { + if xcTestCaseInstanceMethods.contains(tokens[index].string) { + return true + } + + if tokens[index].string.hasPrefix("XC"), + swiftTestingExpectationForXCTestHelper(at: index) == nil, + !["XCTest", "XCTestCase"].contains(tokens[index].string) + { + return true + } + } + + return false + } + + /// Converts the XCTest helper function (e.g. `XCTAssert(...)`) at the given index + /// to a Swift Testng expectation (e.g. `#expect(...)`). + func convertXCTestHelperToSwiftTestingExpectation(at identifierIndex: Int) { + guard let swiftTestingExpectation = swiftTestingExpectationForXCTestHelper(at: identifierIndex), + let startOfFunctionCall = index(of: .startOfScope("("), after: identifierIndex), + let endOfFunctionCall = endOfScope(at: startOfFunctionCall) + else { return } + + replaceTokens(in: identifierIndex ... endOfFunctionCall, with: swiftTestingExpectation) + } + + /// Computes the Swift Testing expectation (e.g. `#expect(...)`) + /// for the XCTest helper function (e.g. `XCTAssert(...)`) at the given index. + /// Returns `nil` if this XCTest helper function is unsupported. + func swiftTestingExpectationForXCTestHelper(at identifierIndex: Int) -> [Token]? { + guard tokens[identifierIndex].isIdentifier, + tokens[identifierIndex].string.hasPrefix("XCT"), + let startOfFunctionCall = index(of: .nonSpaceOrComment, after: identifierIndex) + else { return nil } + + switch tokens[identifierIndex].string { + case "XCTAssert": + return convertXCTAssertToTestingExpectation(at: identifierIndex) { value in + value + } + + case "XCTAssertTrue": + return convertXCTAssertToTestingExpectation(at: identifierIndex) { value in + value + } + + case "XCTAssertFalse": + return convertXCTAssertToTestingExpectation(at: identifierIndex) { value in + // Unlike other operators which are whitespace insensitive, + // the ! token has to come immediately before the first + // non-space/non-comment token in the rhs value. + var tokens = tokenize(value) + if let firstTokenIndex = tokens.firstIndex(where: { !$0.isSpaceOrCommentOrLinebreak }) { + tokens.insert(.operator("!", .prefix), at: firstTokenIndex) + } + + return tokens.string + } + + case "XCTAssertNil": + return convertXCTAssertToTestingExpectation(at: identifierIndex) { value in + "\(value) == nil" + } + + case "XCTAssertNotNil": + return convertXCTAssertToTestingExpectation(at: identifierIndex) { value in + "\(value) != nil" + } + + case "XCTAssertEqual": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "==" + ) + + case "XCTAssertNotEqual": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "!=" + ) + + case "XCTAssertIdentical": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "===" + ) + + case "XCTAssertNotIdentical": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "!==" + ) + + case "XCTAssertGreaterThan": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: ">" + ) + + case "XCTAssertGreaterThanOrEqual": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: ">=" + ) + + case "XCTAssertLessThan": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "<" + ) + + case "XCTAssertLessThanOrEqual": + return convertXCTComparisonToTestingExpectation( + at: identifierIndex, + operator: "<=" + ) + + case "XCTFail": + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + switch functionParams.count { + case 0: + return tokenize("Issue.record()") + case 1: + return tokenize("Issue.record(\(functionParams[0].value.asSwiftTestingComment()))") + default: + return nil + } + + case "XCTUnwrap": + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + switch functionParams.count { + case 1: + return tokenize("#require(\(functionParams[0].value))") + case 2: + return tokenize("#require(\(functionParams[0].value),\(functionParams[1].value.asSwiftTestingComment()))") + default: + return nil + } + + case "XCTAssertNoThrow": + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + switch functionParams.count { + case 1: + return tokenize("#expect(throws: Never.self) { \(functionParams[0].value) }") + case 2: + return tokenize("#expect(throws: Never.self,\(functionParams[1].value.asSwiftTestingComment())) { \(functionParams[0].value) }") + default: + return nil + } + + case "XCTAssertThrowsError": + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + + // Trailing closure variant is unsupported for now + if let endOfFunctionCall = endOfScope(at: startOfFunctionCall), + let startOfTrailingClosure = index(of: .nonSpaceOrCommentOrLinebreak, after: endOfFunctionCall), + tokens[startOfTrailingClosure] == .startOfScope("{") + { return nil } + + switch functionParams.count { + case 1: + return tokenize("#expect(throws: Error.self) { \(functionParams[0].value) }") + case 2: + return tokenize("#expect(throws: Error.self,\(functionParams[1].value.asSwiftTestingComment())) { \(functionParams[0].value) }") + default: + return nil + } + + default: + return nil + } + } + + /// Converts a single-value XCTest assertion like XCTAssertTrue or XCTAssertNil + /// to a Swift Testing expectation. Supports an optional message. + func convertXCTAssertToTestingExpectation( + at identifierIndex: Int, + makeAssertion: (_ value: String) -> String + ) -> [Token]? { + guard let startOfFunctionCall = index(of: .nonSpaceOrComment, after: identifierIndex) else { return nil } + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + + // All of the function params should be unlabeled + guard functionParams.allSatisfy({ $0.label == nil }) else { return nil } + + let value: String + let message: String? + switch functionParams.count { + case 1: + value = functionParams[0].value + message = nil + case 2: + value = functionParams[0].value + message = functionParams[1].value.asSwiftTestingComment() + default: + return nil + } + + if let message = message { + return tokenize("#expect(\(makeAssertion(value)),\(message))") + } else { + return tokenize("#expect(\(makeAssertion(value)))") + } + } + + /// Converts a single-value XCTest assertion like XCTAssertTrue or XCTAssertNil + /// to a Swift Testing expectation. Supports an optional message. + func convertXCTComparisonToTestingExpectation( + at identifierIndex: Int, + operator operatorToken: String + ) -> [Token]? { + guard let startOfFunctionCall = index(of: .nonSpaceOrComment, after: identifierIndex) else { return nil } + let functionParams = parseFunctionCallArguments(startOfScope: startOfFunctionCall) + + // All of the function params should be unlabeled + guard functionParams.allSatisfy({ $0.label == nil }) else { return nil } + + let lhs: String + let rhs: String + let message: String? + switch functionParams.count { + case 2: + lhs = functionParams[0].value + rhs = functionParams[1].value + message = nil + case 3: + lhs = functionParams[0].value + rhs = functionParams[1].value + message = functionParams[2].value.asSwiftTestingComment() + default: + return nil + } + + if let message = message { + return tokenize("#expect(\(lhs) \(operatorToken)\(rhs),\(message))") + } else { + return tokenize("#expect(\(lhs) \(operatorToken)\(rhs))") + } + } + + /// Converts the XCTest override method `setUp` or `tearDown` to the given lifecycle method + func convertXCTestOverride(at keywordIndex: Int, toLifecycleMethod lifecycleMethodName: String) { + guard let nameIndex = index(of: .nonSpaceOrCommentOrLinebreak, after: keywordIndex), + let startOfArgumentsIndex = index(of: .startOfScope("("), after: nameIndex), + let endOfArgumentsIndex = endOfScope(at: startOfArgumentsIndex), + let startOfFunctionBody = index(of: .startOfScope("{"), after: endOfArgumentsIndex), + let endOfFunctionBody = endOfScope(at: startOfFunctionBody) + else { return } + + // Remove `super.setUp()` / `super.tearDown()` if present + if let superCall = index(of: .identifier("super"), in: startOfFunctionBody + 1 ..< endOfFunctionBody), + let dotIndex = index(of: .nonSpaceOrLinebreak, after: superCall), + tokens[dotIndex] == .operator(".", .infix), + let methodName = index(of: .nonSpaceOrCommentOrLinebreak, after: dotIndex), + tokens[methodName] == tokens[nameIndex], + let startOfCall = index(of: .nonSpaceOrCommentOrLinebreak, after: methodName), + tokens[startOfCall] == .startOfScope("("), + let endOfCall = endOfScope(at: startOfCall) + { + removeTokens(in: startOfLine(at: superCall) ... endOfCall + 1) + } + + // Replace `func setUp` with `init`, or `func tearDown` with `deinit`. + // For `deinit`, we also have to remove the parens from the `tearDown()` method. + if lifecycleMethodName == "deinit" { + replaceTokens(in: keywordIndex ... endOfArgumentsIndex, with: [.keyword(lifecycleMethodName)]) + } else { + replaceTokens(in: keywordIndex ... nameIndex, with: [.keyword(lifecycleMethodName)]) + } + + // Remove the `override` modifier + if let overrideModifier = indexOfModifier("override", forDeclarationAt: keywordIndex) { + removeTokens(in: overrideModifier ... overrideModifier + 1) + } + } +} + +extension String { + /// Converts this value to a comment that can be used as a Swift Testing `Comment` value, + /// which is `ExpressibleByStringLiteral` but not a `String` itself. + func asSwiftTestingComment() -> String { + let formatter = Formatter(tokenize(self)) + + // If the entire value is a string literal, we can use it directly as + // a Swift Testing comment literal. + if let startOfString = formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: -1), + formatter.tokens[startOfString].isStringDelimiter, + let endOfString = formatter.endOfScope(at: startOfString), + formatter.index(of: .nonSpaceOrCommentOrLinebreak, after: endOfString) == nil + { + return self + } + + else { + let leadingSpaces = formatter.currentIndentForLine(at: 0) + return leadingSpaces + "Comment(rawValue: \(trimmingCharacters(in: .whitespaces)))" + } + } +} diff --git a/SwiftFormat.xcodeproj/project.pbxproj b/SwiftFormat.xcodeproj/project.pbxproj index e997ce97d..c1720c2a6 100644 --- a/SwiftFormat.xcodeproj/project.pbxproj +++ b/SwiftFormat.xcodeproj/project.pbxproj @@ -515,6 +515,11 @@ 2E2BADB42C57F6DD00590239 /* SortDeclarations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2BABFE2C57F6DD00590239 /* SortDeclarations.swift */; }; 2E2BADB52C57F6DD00590239 /* SortDeclarations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2BABFE2C57F6DD00590239 /* SortDeclarations.swift */; }; 2E2BADB62C57F6DD00590239 /* SortDeclarations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2BABFE2C57F6DD00590239 /* SortDeclarations.swift */; }; + 2E723EF32D45592B00D1B389 /* SwiftTestingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E723EF22D45592B00D1B389 /* SwiftTestingTests.swift */; }; + 2E723EF62D455B2A00D1B389 /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */; }; + 2E723EF72D455B2B00D1B389 /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */; }; + 2E723EF92D455B2C00D1B389 /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */; }; + 2E723EFA2D455B2C00D1B389 /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */; }; 2E7D30A42A7940C500C32174 /* Singularize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E7D30A32A7940C500C32174 /* Singularize.swift */; }; 2E8DE6F82C57FEB30032BF25 /* RedundantClosureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DE68D2C57FEB30032BF25 /* RedundantClosureTests.swift */; }; 2E8DE6F92C57FEB30032BF25 /* BlankLinesBetweenChainedFunctionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8DE68E2C57FEB30032BF25 /* BlankLinesBetweenChainedFunctionsTests.swift */; }; @@ -917,6 +922,8 @@ 2E2BABFC2C57F6DD00590239 /* SortSwitchCases.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SortSwitchCases.swift; sourceTree = ""; }; 2E2BABFD2C57F6DD00590239 /* RedundantType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedundantType.swift; sourceTree = ""; }; 2E2BABFE2C57F6DD00590239 /* SortDeclarations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SortDeclarations.swift; sourceTree = ""; }; + 2E723EF22D45592B00D1B389 /* SwiftTestingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTestingTests.swift; sourceTree = ""; }; + 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTesting.swift; sourceTree = ""; }; 2E7D30A32A7940C500C32174 /* Singularize.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Singularize.swift; sourceTree = ""; }; 2E8DE68D2C57FEB30032BF25 /* RedundantClosureTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedundantClosureTests.swift; sourceTree = ""; }; 2E8DE68E2C57FEB30032BF25 /* BlankLinesBetweenChainedFunctionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlankLinesBetweenChainedFunctionsTests.swift; sourceTree = ""; }; @@ -1365,6 +1372,7 @@ 2E2BABBB2C57F6DD00590239 /* WrapSingleLineComments.swift */, 2E2BABDF2C57F6DD00590239 /* WrapSwitchCases.swift */, 2E2BABF92C57F6DD00590239 /* YodaConditions.swift */, + 2E723EF42D455B2600D1B389 /* SwiftTesting.swift */, ); path = Rules; sourceTree = ""; @@ -1467,6 +1475,7 @@ EBA6E7062C5B7DC400CBD360 /* SpacingGuardsTests.swift */, 2E8DE69C2C57FEB30032BF25 /* StrongifiedSelfTests.swift */, 2E8DE6B92C57FEB30032BF25 /* StrongOutletsTests.swift */, + 2E723EF22D45592B00D1B389 /* SwiftTestingTests.swift */, 2E8DE6952C57FEB30032BF25 /* TodosTests.swift */, 2E8DE6D92C57FEB30032BF25 /* TrailingClosuresTests.swift */, 2E8DE69A2C57FEB30032BF25 /* TrailingCommasTests.swift */, @@ -1957,6 +1966,7 @@ 2E2BAD3B2C57F6DD00590239 /* Braces.swift in Sources */, 2E2BABFF2C57F6DD00590239 /* InitCoderUnavailable.swift in Sources */, 2E2BACB72C57F6DD00590239 /* OrganizeDeclarations.swift in Sources */, + 2E723EF62D455B2A00D1B389 /* SwiftTesting.swift in Sources */, 2E2BAD132C57F6DD00590239 /* Wrap.swift in Sources */, 2E2BACDF2C57F6DD00590239 /* RedundantReturn.swift in Sources */, 2E2BAC0F2C57F6DD00590239 /* WrapMultilineConditionalAssignment.swift in Sources */, @@ -2001,6 +2011,7 @@ 2E8DE74C2C57FEB30032BF25 /* WrapConditionalBodiesTests.swift in Sources */, EBA6E70B2C5B7E8400CBD360 /* SpacingGuardsTests.swift in Sources */, 2E8DE7332C57FEB30032BF25 /* SpaceAroundCommentsTests.swift in Sources */, + 2E723EF32D45592B00D1B389 /* SwiftTestingTests.swift in Sources */, 2E8DE7342C57FEB30032BF25 /* PropertyTypesTests.swift in Sources */, 2E8DE7162C57FEB30032BF25 /* TrailingSpaceTests.swift in Sources */, 2E8DE7472C57FEB30032BF25 /* ConditionalAssignmentTests.swift in Sources */, @@ -2258,6 +2269,7 @@ 2E2BAD502C57F6DD00590239 /* RedundantRawValues.swift in Sources */, 2E2BACAC2C57F6DD00590239 /* RedundantLetError.swift in Sources */, 01045A9A2119979400D2BE3D /* Arguments.swift in Sources */, + 2E723EF72D455B2B00D1B389 /* SwiftTesting.swift in Sources */, 580496D62C584E8F004B7DBF /* EmptyExtensions.swift in Sources */, 2E9DE5072C95F9A1000FEDF8 /* FileMacro.swift in Sources */, 2E2BAD482C57F6DD00590239 /* WrapLoopBodies.swift in Sources */, @@ -2318,6 +2330,7 @@ 2E2BACED2C57F6DD00590239 /* RedundantObjc.swift in Sources */, 2E2BAC692C57F6DD00590239 /* LinebreakAtEndOfFile.swift in Sources */, E487211D201D885A0014845E /* RulesViewController.swift in Sources */, + 2E723EF92D455B2C00D1B389 /* SwiftTesting.swift in Sources */, 2E9DE5082C95F9A1000FEDF8 /* FileMacro.swift in Sources */, 2E2BAC212C57F6DD00590239 /* RedundantOptionalBinding.swift in Sources */, 01045A9B2119979400D2BE3D /* Arguments.swift in Sources */, @@ -2468,6 +2481,7 @@ 2E2BACEE2C57F6DD00590239 /* RedundantObjc.swift in Sources */, 2E2BAC6A2C57F6DD00590239 /* LinebreakAtEndOfFile.swift in Sources */, 0142C77023C3FB6D005D5832 /* LintFileCommand.swift in Sources */, + 2E723EFA2D455B2C00D1B389 /* SwiftTesting.swift in Sources */, 2E9DE5092C95F9A1000FEDF8 /* FileMacro.swift in Sources */, 2E2BAC222C57F6DD00590239 /* RedundantOptionalBinding.swift in Sources */, E4E4D3CC2033F17C000D7CB1 /* EnumAssociable.swift in Sources */, diff --git a/Tests/CodeOrganizationTests.swift b/Tests/CodeOrganizationTests.swift index d2abb587c..42d85e022 100644 --- a/Tests/CodeOrganizationTests.swift +++ b/Tests/CodeOrganizationTests.swift @@ -104,7 +104,7 @@ class CodeOrganizationTests: XCTestCase { // between methods with the same base name var functionCallArguments: [String?]? if let functionCallStartOfScope = formatter.index(of: .startOfScope("("), after: index) { - functionCallArguments = formatter.parseFunctionCallArgumentLabels(startOfScope: functionCallStartOfScope) + functionCallArguments = formatter.parseFunctionCallArguments(startOfScope: functionCallStartOfScope).map(\.label) } guard let matchingHelper = allRuleFileHelpers.first(where: { helper in diff --git a/Tests/ParsingHelpersTests.swift b/Tests/ParsingHelpersTests.swift index f351105a4..71d6f9692 100644 --- a/Tests/ParsingHelpersTests.swift +++ b/Tests/ParsingHelpersTests.swift @@ -2558,27 +2558,27 @@ class ParsingHelpersTests: XCTestCase { let formatter = Formatter(tokenize(input)) XCTAssertEqual( - formatter.parseFunctionCallArgumentLabels(startOfScope: 1), // foo(...) + formatter.parseFunctionCallArguments(startOfScope: 1).map(\.label), // foo(...) [nil, "bar", nil, "quux", "last"] ) XCTAssertEqual( - formatter.parseFunctionCallArgumentLabels(startOfScope: 3), // Foo(...) + formatter.parseFunctionCallArguments(startOfScope: 3).map(\.label), // Foo(...) ["foo"] ) XCTAssertEqual( - formatter.parseFunctionCallArgumentLabels(startOfScope: 15), // Bar(...) + formatter.parseFunctionCallArguments(startOfScope: 15).map(\.label), // Bar(...) [nil] ) XCTAssertEqual( - formatter.parseFunctionCallArgumentLabels(startOfScope: 27), // Quux() + formatter.parseFunctionCallArguments(startOfScope: 27).map(\.label), // Quux() [] ) XCTAssertEqual( - formatter.parseFunctionCallArgumentLabels(startOfScope: 49), // isOperator(...) + formatter.parseFunctionCallArguments(startOfScope: 49).map(\.label), // isOperator(...) ["at"] ) } diff --git a/Tests/Rules/SwiftTestingTests.swift b/Tests/Rules/SwiftTestingTests.swift new file mode 100644 index 000000000..bb683e4e0 --- /dev/null +++ b/Tests/Rules/SwiftTestingTests.swift @@ -0,0 +1,619 @@ +// +// SwiftTestingTests.swift +// SwiftFormatTests +// +// Created by Cal Stephens on 1/25/25. +// Copyright © 2025 Nick Lockwood. All rights reserved. +// + +import XCTest +@testable import SwiftFormat + +final class SwiftTestingTests: XCTestCase { + func testConvertsSimpleTestSuite() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + let myFeature = MyFeature() + myFeature.runAction() + XCTAssertTrue(myFeature.worksProperly) + XCTAssertEqual(myFeature.screens.count, 8) + } + + func testMyFeatureHasNoBugs() { + let myFeature = MyFeature() + myFeature.runAction() + XCTAssertFalse(myFeature.hasBugs, "My feature has no bugs") + XCTAssertEqual(myFeature.crashes.count, 0, "My feature doesn't crash") + XCTAssertNil(myFeature.crashReport) + } + } + """ + + let output = """ + @testable import MyFeatureLib + import Testing + + @MainActor + final class MyFeatureTests { + @Test func myFeatureWorks() { + let myFeature = MyFeature() + myFeature.runAction() + #expect(myFeature.worksProperly) + #expect(myFeature.screens.count == 8) + } + + @Test func myFeatureHasNoBugs() { + let myFeature = MyFeature() + myFeature.runAction() + #expect(!myFeature.hasBugs, "My feature has no bugs") + #expect(myFeature.crashes.isEmpty, "My feature doesn't crash") + #expect(myFeature.crashReport == nil) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, [output], rules: [.swiftTesting, .sortImports, .isEmpty], options: options) + } + + func testConvertsTestSuiteWithSetUpTearDown() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + var myFeature: MyFeature! + + override func setUp() async throws { + try await super.setUp() + myFeature = try await MyFeature() + } + + override func tearDown() { + super.tearDown() + myFeature = nil + } + + func testMyFeatureHasNoBugs() { + myFeature.runAction() + XCTAssertFalse(myFeature.hasBugs, "My feature has no bugs") + XCTAssertEqual(myFeature.crashes.count, 0, "My feature doesn't crash") + XCTAssertNil(myFeature.crashReport) + } + } + """ + + let output = """ + @testable import MyFeatureLib + import Testing + + @MainActor + final class MyFeatureTests { + var myFeature: MyFeature! + + init() async throws { + myFeature = try await MyFeature() + } + + deinit { + myFeature = nil + } + + @Test func myFeatureHasNoBugs() { + myFeature.runAction() + #expect(!myFeature.hasBugs, "My feature has no bugs") + #expect(myFeature.crashes.isEmpty, "My feature doesn't crash") + #expect(myFeature.crashReport == nil) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, [output], rules: [.swiftTesting, .sortImports, .isEmpty], options: options) + } + + func testConvertsSimpleXCTestHelpers() { + let input = """ + import XCTest + + class HelperConversionTests: XCTestCase { + func testConvertsSimpleXCTestHelpers() throws { + XCTAssert(foo) + XCTAssert(foo, "foo is true") + XCTAssert(foo, "foo" + " is true") + XCTAssertTrue(foo) + XCTAssertTrue(foo, "foo is true") + XCTAssertFalse(foo) + XCTAssertFalse(foo, "foo is false") + XCTAssertNil(foo) + XCTAssertNil(foo, "foo is nil") + XCTAssertNotNil(foo) + XCTAssertNotNil(foo, "foo is not nil") + XCTAssertEqual(foo, bar) + XCTAssertEqual(foo, bar, "foo and bar are equal") + XCTAssertEqual(foo, bar, "foo and bar" + " are equal") + XCTAssertNotEqual(foo, bar) + XCTAssertNotEqual(foo, bar, "foo and bar are different") + XCTAssertIdentical(foo, bar) + XCTAssertIdentical(foo, bar, "foo and bar are the same reference") + XCTAssertNotIdentical(foo, bar) + XCTAssertNotIdentical(foo, bar, "foo and bar are different references") + XCTAssertGreaterThan(foo, bar) + XCTAssertGreaterThan(foo, bar, "foo is greater than bar") + XCTAssertGreaterThanOrEqual(foo, bar) + XCTAssertGreaterThanOrEqual(foo, bar, "foo is greater than or equal bar") + XCTAssertLessThan(foo, bar) + XCTAssertLessThan(foo, bar, "foo is less than bar") + XCTAssertLessThanOrEqual(foo, bar) + XCTAssertLessThanOrEqual(foo, bar, "foo is less than or equal bar") + XCTFail() + XCTFail("Unexpected issue") + XCTFail("Unexpected" + " " + "issue") + XCTFail(someStringValue) + try XCTUnwrap(foo) + try XCTUnwrap(foo, "foo should not be nil") + XCTAssertThrowsError(try foo.bar) + XCTAssertThrowsError(try foo.bar, "foo.bar should throw an error") + XCTAssertNoThrow(try foo.bar) + XCTAssertNoThrow(try foo.bar, "foo.bar should not throw an error") + } + } + """ + + let output = """ + import Testing + + @MainActor + class HelperConversionTests { + @Test func convertsSimpleXCTestHelpers() throws { + #expect(foo) + #expect(foo, "foo is true") + #expect(foo, Comment(rawValue: "foo" + " is true")) + #expect(foo) + #expect(foo, "foo is true") + #expect(!foo) + #expect(!foo, "foo is false") + #expect(foo == nil) + #expect(foo == nil, "foo is nil") + #expect(foo != nil) + #expect(foo != nil, "foo is not nil") + #expect(foo == bar) + #expect(foo == bar, "foo and bar are equal") + #expect(foo == bar, Comment(rawValue: "foo and bar" + " are equal")) + #expect(foo != bar) + #expect(foo != bar, "foo and bar are different") + #expect(foo === bar) + #expect(foo === bar, "foo and bar are the same reference") + #expect(foo !== bar) + #expect(foo !== bar, "foo and bar are different references") + #expect(foo > bar) + #expect(foo > bar, "foo is greater than bar") + #expect(foo >= bar) + #expect(foo >= bar, "foo is greater than or equal bar") + #expect(foo < bar) + #expect(foo < bar, "foo is less than bar") + #expect(foo <= bar) + #expect(foo <= bar, "foo is less than or equal bar") + Issue.record() + Issue.record("Unexpected issue") + Issue.record(Comment(rawValue: "Unexpected" + " " + "issue")) + Issue.record(Comment(rawValue: someStringValue)) + try #require(foo) + try #require(foo, "foo should not be nil") + #expect(throws: Error.self) { try foo.bar } + #expect(throws: Error.self, "foo.bar should throw an error") { try foo.bar } + #expect(throws: Never.self) { try foo.bar } + #expect(throws: Never.self, "foo.bar should not throw an error") { try foo.bar } + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, [output], rules: [.swiftTesting, .wrapArguments, .indent], options: options) + } + + func testConvertsMultilineXCTestHelpers() { + let input = """ + import XCTest + + class HelperConversionTests: XCTestCase { + func test_converts_multiline_XCTest_helpers() { + XCTAssert(foo.bar( + baaz: "baaz", + quux: "quux")) + + XCTAssertEqual( + // Comment before first argument + foo.bar.baaz("quux"), + // Comment before second argument + Foo(bar: "bar", baaz: "Baaz")) + + XCTAssert( + // Comment before first argument + foo.bar(baaz: "baaz", quux: "quux"), + // Comment before message + "foo is valid") + + XCTAssertEqual( + // Comment before first argument + foo.bar.baaz("quux"), + // Comment before second argument + Foo(bar: "bar", baaz: "Baaz"), + // Comment before message + "foo matches expected value") + + XCTFail( + // Comment before multiline string + #\"\"\" + Multiline string + in method call + \"\"\"#) + + XCTAssertFalse( + // Comment before first argument + foo.bar.baaz.quux, + // Comment before second argument + "foo.bar.baaz.quux is false") + } + } + """ + + let output = """ + import Testing + + @MainActor + class HelperConversionTests { + @Test func converts_multiline_XCTest_helpers() { + #expect(foo.bar( + baaz: "baaz", + quux: "quux")) + + #expect( + // Comment before first argument + foo.bar.baaz("quux") == + // Comment before second argument + Foo(bar: "bar", baaz: "Baaz")) + + #expect( + // Comment before first argument + foo.bar(baaz: "baaz", quux: "quux"), + // Comment before message + "foo is valid") + + #expect( + // Comment before first argument + foo.bar.baaz("quux") == + // Comment before second argument + Foo(bar: "bar", baaz: "Baaz"), + // Comment before message + "foo matches expected value") + + Issue.record( + // Comment before multiline string + #\"\"\" + Multiline string + in method call + \"\"\"#) + + #expect( + // Comment before first argument + !foo.bar.baaz.quux, + // Comment before second argument + "foo.bar.baaz.quux is false") + } + } + """ + + let options = FormatOptions(closingParenPosition: .sameLine, swiftVersion: "6.0") + testFormatting(for: input, [output], rules: [.swiftTesting, .wrapArguments, .indent, .trailingSpace], options: options) + } + + func testPreservesUnsupportedExpectationHelpers() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyAsyncFeatureWorks() { + let expectation = expectation(description: "my feature runs async") + MyFeature().run { + expectation.fulfill() + } + wait(for: [expectation]) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testPreservesUnsupportedUITestHelpers() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testUITest() { + let app = XCUIApplication() + app.buttons["Learn More"].tap() + XCTAssert(app.staticTexts["Success"].exists) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testPreservesUnsupportedPerformanceTestHelpers() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testPerformance() { + measure { + MyFeature.expensiveOperation() + } + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testPreservesAsyncOrThrowsTearDown() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + var myFeature: MyFeature! + + override func setUp() async throws { + try await super.setUp() + myFeature = try await MyFeature() + } + + /// deinit can't be async / throws + override func tearDown() async throws { + super.tearDown() + try await myFeature.cleanUp() + } + + func testMyFeatureHasNoBugs() { + myFeature.runAction() + XCTAssertFalse(myFeature.hasBugs) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testPreservesUnsupportedMethodOverride() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + let myFeature = MyFeature() + myFeature.runAction() + XCTAssertTrue(myFeature.worksProperly) + } + + override func someUnknownOveride() { + super.someUnknownOveride() + print("test") + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testConvertsHelpersInHelperMethods() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + let myFeature = MyFeature() + assertMyFeatureWorks(myFeature) + } + } + + func assertMyFeatureWorks(_ feature: MyFeature) { + XCTAssert(feature.works) + } + """ + + let output = """ + @testable import MyFeatureLib + import Testing + + @MainActor + final class MyFeatureTests { + @Test func myFeatureWorks() { + let myFeature = MyFeature() + assertMyFeatureWorks(myFeature) + } + } + + func assertMyFeatureWorks(_ feature: MyFeature) { + #expect(feature.works) + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, [output], rules: [.swiftTesting, .sortImports], options: options) + } + + func testPreservesHelpersWithLineFileParams() { + let input = """ + @testable import MyFeatureLib + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + let myFeature = MyFeature() + assertMyFeatureWorks(myFeature) + } + } + + func assertMyFeatureWorks(_ feature: MyFeature, file: StaticString = #file, line: UInt = #line) { + XCTAssert(feature.works, file: file, line: line) + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, rule: .swiftTesting, options: options) + } + + func testDoesntUpdateNameToIdentifierRequiringBackTicks() { + let input = """ + import XCTest + + final class MyFeatureTests: XCTestCase { + func test123() { + XCTAssertEqual(1 + 2, 3) + } + } + """ + + let output = """ + import Testing + + @MainActor + final class MyFeatureTests { + @Test func test123() { + #expect(1 + 2 == 3) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, output, rule: .swiftTesting, options: options) + } + + func testDoesntUpTestNameToExistingFunctionName() { + let input = """ + import XCTest + + final class MyFeatureTests: XCTestCase { + func testOnePlusTwo() { + XCTAssertEqual(onePlusTwo(), 3) + } + + func onePlusTwo() -> Int { + 1 + 2 + } + } + """ + + let output = """ + import Testing + + @MainActor + final class MyFeatureTests { + @Test func testOnePlusTwo() { + #expect(onePlusTwo() == 3) + } + + func onePlusTwo() -> Int { + 1 + 2 + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, output, rule: .swiftTesting, options: options) + } + + func testPreservesTestMethodWithArguments() { + let input = """ + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + testMyFeatureWorks(MyFeature()) + } + + func testMyFeatureWorks(_ feature: Feature) { + feature.runAction() + XCTAssertTrue(feature.worksProperly) + } + } + """ + + let output = """ + import Testing + + @MainActor + final class MyFeatureTests { + @Test func myFeatureWorks() { + testMyFeatureWorks(MyFeature()) + } + + func testMyFeatureWorks(_ feature: Feature) { + feature.runAction() + #expect(feature.worksProperly) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, output, rule: .swiftTesting, options: options) + } + + func testAddsThrowingEffectIfNeeded() { + // XCTest helpers all have throwing autoclosure params, + // so can have `try` without the test case being `throws`. + // #exect doesn't work like this, so the test case has to be throwing. + let input = """ + import XCTest + + final class MyFeatureTests: XCTestCase { + func testMyFeatureWorks() { + let myFeature = MyFeature() + XCTAssertTrue(try feature.worksProperly) + } + + func testMyFeatureWorksAsync() async { + let myFeature = await MyFeature() + XCTAssertTrue(try feature.worksProperly) + } + } + """ + + let output = """ + import Testing + + @MainActor + final class MyFeatureTests { + @Test func myFeatureWorks() throws { + let myFeature = MyFeature() + #expect(try feature.worksProperly) + } + + @Test func myFeatureWorksAsync() async throws { + let myFeature = await MyFeature() + #expect(try feature.worksProperly) + } + } + """ + + let options = FormatOptions(swiftVersion: "6.0") + testFormatting(for: input, output, rule: .swiftTesting, options: options) + } +}