diff --git a/Sources/SkipBuild/Commands/SkipstoneCommand.swift b/Sources/SkipBuild/Commands/SkipstoneCommand.swift index dea870aa..e2b07ee5 100644 --- a/Sources/SkipBuild/Commands/SkipstoneCommand.swift +++ b/Sources/SkipBuild/Commands/SkipstoneCommand.swift @@ -418,6 +418,7 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { try await transpiler.transpile(handler: handleTranspilation) try saveCodebaseInfo() // save out the ModuleName.skipcode.json try saveSkipBridgeCode() + try saveTestHarness() let sourceModules = try linkDependentModuleSources() try linkResources() @@ -646,6 +647,30 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { } } + func saveTestHarness() throws { + // auto-generate an XCSkipTests.swift test harness when the plugin requested it + if let testHarnessOutput = skipstoneOptions.testHarnessOutput { + let testHarnessOutputPath = try AbsolutePath(validating: testHarnessOutput) + let harnessContents = """ + // Auto-generated by Skip — do not edit + #if os(macOS) || os(Linux) // Skip transpiled tests only run on supported hosts + import Foundation + import XCTest + import SkipTest + + /// This test case will run the transpiled tests for the Skip module. + final class XCSkipTests: XCTestCase, XCGradleHarness { + public func testSkipModule() async throws { + try await runGradleTests() + } + } + #endif + + """ + try writeChanges(tag: "test harness", to: testHarnessOutputPath, contents: harnessContents.utf8Data, readOnly: true) + } + } + func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws { try generateGradleWrapperProperties() try generateProguardFile(packageName) @@ -1398,6 +1423,9 @@ struct SkipstoneCommandOptions: ParsableArguments { @Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix")) var skipBridgeOutput: String? = nil + + @Option(name: [.long], help: ArgumentHelp("Path for auto-generated test harness", valueName: "path")) + var testHarnessOutput: String? = nil } diff --git a/Sources/SkipBuild/SkipProject.swift b/Sources/SkipBuild/SkipProject.swift index 02bc6810..b2921f20 100644 --- a/Sources/SkipBuild/SkipProject.swift +++ b/Sources/SkipBuild/SkipProject.swift @@ -1174,44 +1174,6 @@ struct TestData : Codable, Hashable { try testCaseCode.write(to: testSwiftFile, atomically: false, encoding: .utf8) - // only create the transpiled test harness when this is a non-native module or if the module is bridged; - // otherwise, pure swift testing will suffice for both Darwin and Android - if !isNativeModule || moduleMode == .nativeBridged { - let testSkipModuleFile = testDir.appending(path: "XCSkipTests.swift") - try """ - \(sourceHeader)import Foundation - #if os(macOS) || os(Linux) // Skip transpiled tests only run on supported hosts - import SkipTest - - /// This test case will run the transpiled tests for the Skip module. - @available(macOS 13, macCatalyst 16, *) - final class XCSkipTests: XCTestCase, XCGradleHarness { - public func testSkipModule() async throws { - // Run the transpiled JUnit tests for the current test module. - // These tests will be executed locally using Robolectric. - // Connected device or emulator tests can be run by setting the - // `ANDROID_SERIAL` environment variable to an `adb devices` - // ID in the scheme's Run settings. - // - // Note that it isn't currently possible to filter the tests to run. - try await runGradleTests() - } - } - #endif - - /// True when running in a transpiled Java runtime environment - let isJava = ProcessInfo.processInfo.environment["java.io.tmpdir"] != nil - /// True when running within an Android environment (either an emulator or device) - let isAndroid = isJava && ProcessInfo.processInfo.environment["ANDROID_ROOT"] != nil - /// True is the transpiled code is currently running in the local Robolectric test environment - let isRobolectric = isJava && !isAndroid - /// True if the system's `Int` type is 32-bit. - let is32BitInteger = Int64(Int.max) == Int64(Int32.max) - \(sourceFooter) - - """.write(to: testSkipModuleFile, atomically: false, encoding: .utf8) - } - let skipYamlAppTests = """ # # Skip configuration for \(moduleName) module #build: diff --git a/Sources/SkipSyntax/CodebaseInfo.swift b/Sources/SkipSyntax/CodebaseInfo.swift index 0cf7aae1..01d8a1f8 100644 --- a/Sources/SkipSyntax/CodebaseInfo.swift +++ b/Sources/SkipSyntax/CodebaseInfo.swift @@ -49,6 +49,7 @@ public final class CodebaseInfo { "SkipFuseUI": ["SkipUI", "SkipFoundation", "SkipModel"], "UIKit": ["SkipUI", "SkipFoundation", "SkipModel"], "UserNotifications": ["SkipUI", "SkipFoundation", "SkipModel"], + "Testing": ["SkipUnit"], "XCTest": ["SkipUnit"], ] diff --git a/Sources/SkipSyntax/ExpressionTypes.swift b/Sources/SkipSyntax/ExpressionTypes.swift index 81091646..4fabc6d8 100644 --- a/Sources/SkipSyntax/ExpressionTypes.swift +++ b/Sources/SkipSyntax/ExpressionTypes.swift @@ -1256,11 +1256,125 @@ final class MacroExpansion: Expression { return RawExpression(sourceCode: "// #\(macroExpansionExpr.macroName.text) omitted", syntax: syntax, in: syntaxTree) case "colorLiteral": return colorLiteral(syntax: macroExpansionExpr) + case "expect": + return expectOrRequire(syntax: macroExpansionExpr, isThrowing: false, in: syntaxTree) + case "require": + return expectOrRequire(syntax: macroExpansionExpr, isThrowing: true, in: syntaxTree) default: throw Message.macroExpansionUnsupported(syntax, source: syntaxTree.source) } } + /// Convert `#expect(...)` or `#require(...)` Swift Testing macros into function calls + /// that map to SkipUnit assertion functions. + /// + /// Supports: + /// - `#expect(condition)` → `expectTrue(condition)` + /// - `#expect(a == b)` → `expectEqual(a, b)` + /// - `#expect(a != b)` → `expectNotEqual(a, b)` + /// - `#expect(throws: ErrorType.self) { code }` → `expectThrows(ErrorType.self) { code }` + /// - `#require(condition)` → `requireTrue(condition)` + /// - `#require(optionalExpr)` → `requireNotNil(optionalExpr)` + /// + /// The `isThrowing` parameter distinguishes `#require` (which throws on failure) from `#expect`. + private static func expectOrRequire(syntax macroExpansionExpr: MacroExpansionExprSyntax, isThrowing: Bool, in syntaxTree: SyntaxTree) -> Expression { + let prefix = isThrowing ? "require" : "expect" + let args = Array(macroExpansionExpr.arguments) + + // #expect(throws: SomeError.self) { ... } or #require(throws: SomeError.self) { ... } + if let throwsArg = args.first(where: { $0.label?.text == "throws" }) { + var functionArgs: [LabeledValue] = [ + LabeledValue(label: "throws", value: ExpressionDecoder.decode(syntax: throwsArg.expression, in: syntaxTree)) + ] + // Include trailing closure if present + if let trailingClosure = macroExpansionExpr.trailingClosure { + let closureExpr = ExpressionDecoder.decode(syntax: trailingClosure, in: syntaxTree) + functionArgs.append(LabeledValue(value: closureExpr)) + } + return FunctionCall(function: Identifier(name: "\(prefix)Throws"), arguments: functionArgs, trailingClosureCount: macroExpansionExpr.trailingClosure != nil ? 1 : 0, syntax: macroExpansionExpr) + } + + // Single argument case: #expect(expr) or #require(expr) + guard let firstArg = args.first else { + // No arguments — emit as a raw call + return FunctionCall(function: Identifier(name: "\(prefix)True"), arguments: [], syntax: macroExpansionExpr) + } + + let expression = firstArg.expression + + // Check for comparison operators: ==, !=, >, <, >=, <= + // These appear as SequenceExprSyntax in SwiftSyntax. The comparison operator + // has the lowest precedence, so we find the last comparison operator in the + // sequence and split the expression on it. + if let sequenceExpr = expression.as(SequenceExprSyntax.self) { + let elements = Array(sequenceExpr.elements) + // Find the last comparison operator (lowest precedence in Swift) + let comparisonOps: Set = ["==", "!=", ">", "<", ">=", "<="] + if let opIndex = elements.lastIndex(where: { elem in + if let op = elem.as(BinaryOperatorExprSyntax.self) { + return comparisonOps.contains(op.operator.text) + } + return false + }), let op = elements[opIndex].as(BinaryOperatorExprSyntax.self) { + let opText = op.operator.text + let lhsElements = Array(elements[..] = args.dropFirst().compactMap { arg in + if arg.label == nil || arg.label?.text == "sourceLocation" { return nil } + return LabeledValue(value: ExpressionDecoder.decode(syntax: arg.expression, in: syntaxTree)) + } + + let funcName: String + switch opText { + case "==": funcName = "\(prefix)Equal" + case "!=": funcName = "\(prefix)NotEqual" + case ">": funcName = "\(prefix)GreaterThan" + case ">=": funcName = "\(prefix)GreaterThanOrEqual" + case "<": funcName = "\(prefix)LessThan" + case "<=": funcName = "\(prefix)LessThanOrEqual" + default: funcName = "\(prefix)True" // fallback + } + return FunctionCall(function: Identifier(name: funcName), arguments: [LabeledValue(value: lhs), LabeledValue(value: rhs)] + msgArgs, syntax: macroExpansionExpr) + } + } + + // Default: treat as a boolean condition check + var allArgs: [LabeledValue] = [LabeledValue(value: ExpressionDecoder.decode(syntax: expression, in: syntaxTree))] + // Include any extra message arguments + for arg in args.dropFirst() { + if arg.label?.text == "sourceLocation" { continue } + allArgs.append(LabeledValue(label: arg.label?.text, value: ExpressionDecoder.decode(syntax: arg.expression, in: syntaxTree))) + } + + // If we have a trailing closure (e.g. #expect { ... }), use it + if let trailingClosure = macroExpansionExpr.trailingClosure, args.isEmpty { + let closureExpr = ExpressionDecoder.decode(syntax: trailingClosure, in: syntaxTree) + return FunctionCall(function: Identifier(name: "\(prefix)True"), arguments: [LabeledValue(value: closureExpr)], trailingClosureCount: 1, syntax: macroExpansionExpr) + } + + return FunctionCall(function: Identifier(name: isThrowing ? "requireNotNil" : "\(prefix)True"), arguments: allArgs, syntax: macroExpansionExpr) + } + private static func colorLiteral(syntax macroExpansionExpr: MacroExpansionExprSyntax) -> Expression { let red = numericLiteral(for: macroExpansionExpr.arguments.first { $0.label?.text == "red" }) let green = numericLiteral(for: macroExpansionExpr.arguments.first { $0.label?.text == "green" }) diff --git a/Sources/SkipSyntax/HelperTypes.swift b/Sources/SkipSyntax/HelperTypes.swift index 97dc0b6e..8d386426 100644 --- a/Sources/SkipSyntax/HelperTypes.swift +++ b/Sources/SkipSyntax/HelperTypes.swift @@ -319,6 +319,8 @@ struct Attribute: Hashable, Codable { case published case state case stateObject + case suite + case test case toolbarContentBuilder case unavailable case unknown @@ -395,6 +397,10 @@ struct Attribute: Hashable, Codable { return .state case "StateObject": return .stateObject + case "Suite": + return .suite + case "Test": + return .test case "ToolbarContentBuilder": return .toolbarContentBuilder case "ViewBuilder": diff --git a/Sources/SkipSyntax/Kotlin/KotlinUnitTestTransformer.swift b/Sources/SkipSyntax/Kotlin/KotlinUnitTestTransformer.swift index f18a42c5..f34a0776 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinUnitTestTransformer.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinUnitTestTransformer.swift @@ -2,38 +2,119 @@ // Licensed under the GNU Affero General Public License v3.0 // SPDX-License-Identifier: AGPL-3.0-only -/// Convert `XCTestCase` test functions to JUnit test functions. +/// Convert `XCTestCase` test functions and Swift Testing `@Test` functions to JUnit test functions. /// -/// - Seealso: `SkipUnit/XCTestCase.kt` +/// Handles two styles of test declaration: +/// 1. **XCTest**: Classes inheriting from `XCTestCase` with `test`-prefixed methods +/// 2. **Swift Testing**: Functions annotated with `@Test` and types annotated with `@Suite` +/// +/// In both cases, JUnit `@Test` annotations and the AndroidJUnit4 runner annotation are applied. +/// Async test functions are wrapped with coroutine test dispatchers. +/// +/// - Seealso: `SkipUnit/XCTest.kt` final class KotlinUnitTestTransformer: KotlinTransformer { + /// Swift source attributes gathered during the gather phase. + /// Maps source file paths to sets of function names that have `@Test` attributes. + private var swiftTestingFunctions: [Source.FilePath: Set] = [:] + /// Types annotated with `@Suite` in Swift source. + private var swiftTestingSuites: [Source.FilePath: Set] = [:] + + func gather(from syntaxTree: SyntaxTree) { + var testFunctions: Set = [] + var suiteTypes: Set = [] + + for statement in syntaxTree.root.statements { + gatherTestAttributes(from: statement, testFunctions: &testFunctions, suiteTypes: &suiteTypes) + } + + if !testFunctions.isEmpty { + swiftTestingFunctions[syntaxTree.source.file] = testFunctions + } + if !suiteTypes.isEmpty { + swiftTestingSuites[syntaxTree.source.file] = suiteTypes + } + } + + /// Recursively gather `@Test` and `@Suite` attributes from the Swift AST. + private func gatherTestAttributes(from statement: Statement, testFunctions: inout Set, suiteTypes: inout Set) { + if let funcDecl = statement as? FunctionDeclaration { + if funcDecl.attributes.contains(.test) { + testFunctions.insert(funcDecl.name) + } + } + if let typeDecl = statement as? TypeDeclaration { + if typeDecl.attributes.contains(.suite) { + suiteTypes.insert(typeDecl.name) + } + for member in typeDecl.members { + gatherTestAttributes(from: member, testFunctions: &testFunctions, suiteTypes: &suiteTypes) + } + } + if let codeBlock = statement as? CodeBlock { + for child in codeBlock.statements { + gatherTestAttributes(from: child, testFunctions: &testFunctions, suiteTypes: &suiteTypes) + } + } + } + func apply(to syntaxTree: KotlinSyntaxTree, translator: KotlinTranslator) -> [KotlinTransformerOutput] { guard let codebaseInfo = translator.codebaseInfo else { return [] } + let sourceFile = syntaxTree.source.file + let testFuncNames = swiftTestingFunctions[sourceFile] ?? [] + var importPackages: Set = [] syntaxTree.root.visit(ifSkipBlockContent: syntaxTree.isBridgeFile) { - visit($0, codebaseInfo: codebaseInfo, importPackages: &importPackages) + visit($0, codebaseInfo: codebaseInfo, testFuncNames: testFuncNames, importPackages: &importPackages) } syntaxTree.dependencies.imports.formUnion(importPackages) return [] } - private func visit(_ node: KotlinSyntaxNode, codebaseInfo: CodebaseInfo.Context, importPackages: inout Set) -> VisitResult { - if let functionDeclaration = node as? KotlinFunctionDeclaration, let owningClass = functionDeclaration.parent as? KotlinClassDeclaration, Self.isTestFunction(functionDeclaration, owningClass: owningClass, codebaseInfo: codebaseInfo) { - if functionDeclaration.apiFlags.options.contains(.async) { - transformAsyncTest(functionDeclaration: functionDeclaration, owningClass: owningClass, importPackages: &importPackages) - } else { - functionDeclaration.annotations += ["@Test"] - } - let testRunner = "@org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class)" - if !owningClass.annotations.contains(testRunner) { - owningClass.annotations += [testRunner] + private func visit(_ node: KotlinSyntaxNode, codebaseInfo: CodebaseInfo.Context, testFuncNames: Set, importPackages: inout Set) -> VisitResult { + if let functionDeclaration = node as? KotlinFunctionDeclaration, let owningClass = functionDeclaration.parent as? KotlinClassDeclaration { + // Check for XCTest-style test functions (name-based detection) + let isXCTest = Self.isXCTestFunction(functionDeclaration, owningClass: owningClass, codebaseInfo: codebaseInfo) + // Check for Swift Testing @Test functions (attribute-based detection) + let isSwiftTesting = testFuncNames.contains(functionDeclaration.name) + + if isXCTest || isSwiftTesting { + if functionDeclaration.apiFlags.options.contains(.async) { + transformAsyncTest(functionDeclaration: functionDeclaration, owningClass: owningClass, importPackages: &importPackages) + } else { + functionDeclaration.annotations += ["@Test"] + } + let testRunner = "@org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class)" + if !owningClass.annotations.contains(testRunner) { + owningClass.annotations += [testRunner] + } + // For Swift Testing @Suite types that don't extend XCTestCase, + // make them implement the XCTestCase interface for assertion access + if isSwiftTesting && !isXCTest { + ensureXCTestCaseConformance(owningClass) + } + return .skip } - return .skip } return .recurse(nil) } + /// Ensures the given class implements the `XCTestCase` interface if it doesn't already. + /// This is needed for Swift Testing `@Suite` types that don't inherit from XCTestCase + /// but still need access to the assertion methods defined on the interface. + private func ensureXCTestCaseConformance(_ classDeclaration: KotlinClassDeclaration) { + let hasXCTestCase = classDeclaration.inherits.contains { supertype in + if case .named(let name, _) = supertype, name == "XCTestCase" { + return true + } + return false + } + if !hasXCTestCase { + classDeclaration.inherits.append(.named("XCTestCase", [])) + } + } + private func transformAsyncTest(functionDeclaration: KotlinFunctionDeclaration, owningClass: KotlinClassDeclaration, importPackages: inout Set) { importPackages.insert("kotlinx.coroutines.*") importPackages.insert("kotlinx.coroutines.test.*") @@ -67,7 +148,9 @@ final class KotlinUnitTestTransformer: KotlinTransformer { } } - private static func isTestFunction(_ functionDeclaration: KotlinFunctionDeclaration, owningClass: KotlinClassDeclaration, codebaseInfo: CodebaseInfo.Context) -> Bool { + /// Checks whether a function is an XCTest-style test function (name starts with "test", + /// no parameters, non-static, owning class inherits from XCTestCase). + private static func isXCTestFunction(_ functionDeclaration: KotlinFunctionDeclaration, owningClass: KotlinClassDeclaration, codebaseInfo: CodebaseInfo.Context) -> Bool { guard functionDeclaration.name.hasPrefix("test") && !functionDeclaration.isStatic && functionDeclaration.role != .global else { return false } diff --git a/Tests/SkipBuildTests/SkipCommandTests.swift b/Tests/SkipBuildTests/SkipCommandTests.swift index b85db469..67cfbd20 100644 --- a/Tests/SkipBuildTests/SkipCommandTests.swift +++ b/Tests/SkipBuildTests/SkipCommandTests.swift @@ -40,16 +40,12 @@ final class SkipCommandTests: XCTestCase { │ └─ TestData.json ├─ Skip │ └─ skip.yml - ├─ SomeModuleTests.swift - └─ XCSkipTests.swift + └─ SomeModuleTests.swift """) let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } - let XCSkipTests = try load("Tests/SomeModuleTests/XCSkipTests.swift") - XCTAssertTrue(XCSkipTests.contains("testSkipModule()")) - let PackageSwift = try load("Package.swift") XCTAssertEqual(PackageSwift, """ // swift-tools-version: 6.1 @@ -177,16 +173,12 @@ final class SkipCommandTests: XCTestCase { │ └─ TestData.json ├─ Skip │ └─ skip.yml - ├─ SomeModuleTests.swift - └─ XCSkipTests.swift + └─ SomeModuleTests.swift """) let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } - let XCSkipTests = try load("Tests/SomeModuleTests/XCSkipTests.swift") - XCTAssertTrue(XCSkipTests.contains("testSkipModule()")) - let moduleCode = try load("Sources/SomeModule/SomeModule.swift") XCTAssertEqual(moduleCode, """ import Foundation @@ -278,18 +270,13 @@ final class SkipCommandTests: XCTestCase { ├─ FreeModuleTests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } - let XCSkipTests = try load("Tests/FreeModuleTests/XCSkipTests.swift") - XCTAssertTrue(XCSkipTests.contains("testSkipModule()")) - XCTAssertTrue(XCSkipTests.hasPrefix(SourceLicense.lgpl3LinkingException.sourceHeader), "bad source license in: \(XCSkipTests)") - let FreeModuleTests = try load("Tests/FreeModuleTests/FreeModuleTests.swift") XCTAssertTrue(FreeModuleTests.hasPrefix(SourceLicense.lgpl3LinkingException.sourceHeader), "bad source license in: \(FreeModuleTests)") @@ -345,19 +332,13 @@ final class SkipCommandTests: XCTestCase { ├─ BridgedModuleTests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } - let XCSkipTests = try load("Tests/BridgedModuleTests/XCSkipTests.swift") - XCTAssertTrue(XCSkipTests.contains("testSkipModule()")) - - //let BridgedModuleTests = try load("Tests/BridgedModuleTests/BridgedModuleTests.swift") - let BridgedModule = try load("Sources/BridgedModule/BridgedModule.swift") XCTAssertEqual(BridgedModule, """ #if !SKIP_BRIDGE @@ -473,9 +454,8 @@ final class SkipCommandTests: XCTestCase { ├─ APPNAMETests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) @@ -570,9 +550,8 @@ final class SkipCommandTests: XCTestCase { ├─ APPNAMETests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) @@ -686,9 +665,8 @@ final class SkipCommandTests: XCTestCase { ├─ APPNAMETests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) @@ -726,16 +704,12 @@ final class SkipCommandTests: XCTestCase { │ └─ TestData.json ├─ Skip │ └─ skip.yml - ├─ SomeModuleTests.swift - └─ XCSkipTests.swift + └─ SomeModuleTests.swift """) let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } - let XCSkipTests = try load("Tests/SomeModuleTests/XCSkipTests.swift") - XCTAssertTrue(XCSkipTests.contains("testSkipModule()")) - let moduleCode = try load("Sources/SomeModule/SomeModule.swift") XCTAssertEqual(moduleCode, """ import Foundation @@ -988,16 +962,14 @@ final class SkipCommandTests: XCTestCase { │ ├─ AppModuleTests.swift │ ├─ Resources │ │ └─ TestData.json - │ ├─ Skip - │ │ └─ skip.yml - │ └─ XCSkipTests.swift + │ └─ Skip + │ └─ skip.yml └─ ModelModuleTests ├─ ModelModuleTests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) @@ -1497,16 +1469,14 @@ final class SkipCommandTests: XCTestCase { │ ├─ FreeAppModelTests.swift │ ├─ Resources │ │ └─ TestData.json - │ ├─ Skip - │ │ └─ skip.yml - │ └─ XCSkipTests.swift + │ └─ Skip + │ └─ skip.yml └─ FreeAppTests ├─ FreeAppTests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) @@ -1628,23 +1598,20 @@ final class SkipCommandTests: XCTestCase { │ ├─ BottomModuleTests.swift │ ├─ Resources │ │ └─ TestData.json - │ ├─ Skip - │ │ └─ skip.yml - │ └─ XCSkipTests.swift + │ └─ Skip + │ └─ skip.yml ├─ MiddleModuleTests │ ├─ MiddleModuleTests.swift │ ├─ Resources │ │ └─ TestData.json - │ ├─ Skip - │ │ └─ skip.yml - │ └─ XCSkipTests.swift + │ └─ Skip + │ └─ skip.yml └─ TopModuleTests ├─ Resources │ └─ TestData.json ├─ Skip │ └─ skip.yml - ├─ TopModuleTests.swift - └─ XCSkipTests.swift + └─ TopModuleTests.swift """) @@ -1934,16 +1901,14 @@ final class SkipCommandTests: XCTestCase { │ ├─ M1Tests.swift │ ├─ Resources │ │ └─ TestData.json - │ ├─ Skip - │ │ └─ skip.yml - │ └─ XCSkipTests.swift + │ └─ Skip + │ └─ skip.yml └─ M2Tests ├─ M2Tests.swift ├─ Resources │ └─ TestData.json - ├─ Skip - │ └─ skip.yml - └─ XCSkipTests.swift + └─ Skip + └─ skip.yml """) diff --git a/Tests/SkipSyntaxTests/TransformerTests.swift b/Tests/SkipSyntaxTests/TransformerTests.swift index a10dc646..75928ff7 100644 --- a/Tests/SkipSyntaxTests/TransformerTests.swift +++ b/Tests/SkipSyntaxTests/TransformerTests.swift @@ -74,6 +74,157 @@ final class TransformerTests: XCTestCase { """) } + // MARK: - Swift Testing Transformer Tests + + func testSwiftTestingBasic() async throws { + try await check(swift: """ + import Testing + + struct MyTests { + @Test func addition() { + #expect(1 + 1 == 2) + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class MyTests: XCTestCase { + @Test + internal fun addition(): Unit = expectEqual(1 + 1, 2) + } + """) + } + + func testSwiftTestingExpectTrue() async throws { + try await check(swift: """ + import Testing + + struct MyTests { + @Test func boolCheck() { + let x = true + #expect(x) + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class MyTests: XCTestCase { + @Test + internal fun boolCheck() { + val x = true + expectTrue(x) + } + } + """) + } + + func testSwiftTestingExpectNotEqual() async throws { + try await check(swift: """ + import Testing + + struct MyTests { + @Test func inequality() { + #expect(1 != 2) + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class MyTests: XCTestCase { + @Test + internal fun inequality(): Unit = expectNotEqual(1, 2) + } + """) + } + + func testSwiftTestingRequire() async throws { + try await check(swift: """ + import Testing + + struct MyTests { + @Test func unwrap() throws { + let x: Int? = 42 + let y = try #require(x) + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class MyTests: XCTestCase { + @Test + internal fun unwrap() { + val x: Int? = 42 + val y = requireNotNil(x) + } + } + """) + } + + func testSwiftTestingMultipleFunctions() async throws { + try await check(swift: """ + import Testing + + struct MathTests { + @Test func addition() { + #expect(2 + 2 == 4) + } + + @Test func subtraction() { + #expect(5 - 3 == 2) + } + + func helperNotATest() -> Int { + return 42 + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class MathTests: XCTestCase { + @Test + internal fun addition(): Unit = expectEqual(2 + 2, 4) + + @Test + internal fun subtraction(): Unit = expectEqual(5 - 3, 2) + + internal fun helperNotATest(): Int = 42 + } + """) + } + + func testSwiftTestingComparisons() async throws { + try await check(swift: """ + import Testing + + struct CompTests { + @Test func comparisons() { + #expect(5 > 3) + #expect(3 < 5) + #expect(5 >= 5) + #expect(3 <= 5) + } + } + """, kotlin: """ + import skip.unit.* + + @org.junit.runner.RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class) + internal class CompTests: XCTestCase { + @Test + internal fun comparisons() { + expectGreaterThan(5, 3) + expectLessThan(3, 5) + expectGreaterThanOrEqual(5, 5) + expectLessThanOrEqual(3, 5) + } + } + """) + } + func testModuleBundleTransformer() async throws { try await check(swift: """ import Foundation