From 1cba68690b6c480d380a7ff9b22d3dfe0cb3198a Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Fri, 13 Mar 2026 12:29:34 -0400 Subject: [PATCH 1/2] Default initialized test cases will use Swift Testing rather than XCTest --- .github/workflows/ci.yml | 2 + .../SkipBuild/Commands/CheckupCommand.swift | 2 +- .../SkipBuild/Commands/PackageCommand.swift | 7 +- Sources/SkipBuild/SkipProject.swift | 184 +++++++++++++++--- Tests/SkipBuildTests/SkipCommandTests.swift | 161 +++++++++++---- 5 files changed, 283 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf3adf90..91f2563f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,8 @@ jobs: if: false #if: runner.os != 'Linux' + - run: skip android build --build-tests + - name: "Prepare Android emulator environment" if: runner.os == 'Linux' run: | diff --git a/Sources/SkipBuild/Commands/CheckupCommand.swift b/Sources/SkipBuild/Commands/CheckupCommand.swift index 1ce1393a..10a608e3 100644 --- a/Sources/SkipBuild/Commands/CheckupCommand.swift +++ b/Sources/SkipBuild/Commands/CheckupCommand.swift @@ -115,7 +115,7 @@ This command performs a full system checkup to ensure that Skip can create and b } let runTests = primary && nativeMode.isEmpty - let options = ProjectOptionValues(projectName: projectName, swiftPackageVersion: CreateOptions.defaultSwiftPackageVersion, iOSMinVersion: 17.0, chain: true, gitRepo: false, appfair: false, free: true, zero: !isNative, github: true, fastlane: true) + let options = ProjectOptionValues(projectName: projectName, swiftPackageVersion: CreateOptions.defaultSwiftPackageVersion, iOSMinVersion: 17.0, chain: true, gitRepo: false, appfair: false, free: true, zero: !isNative, github: true, fastlane: true, testCaseMode: .testing) // create a project differently based on the index, but the ultimate binary output should be identical return try await initSkipProject( diff --git a/Sources/SkipBuild/Commands/PackageCommand.swift b/Sources/SkipBuild/Commands/PackageCommand.swift index 1bd4bdae..b5082891 100644 --- a/Sources/SkipBuild/Commands/PackageCommand.swift +++ b/Sources/SkipBuild/Commands/PackageCommand.swift @@ -7,6 +7,8 @@ import ArgumentParser import TSCBasic import SkipSyntax +extension TestCaseMode : ExpressibleByArgument { } + /// Common functions for managing Skip Packages common protocol for `AppCommand` and `LibCommand`. @available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) protocol PackageCommand : SkipCommand, ToolOptionsCommand, OutputOptionsCommand { @@ -108,6 +110,9 @@ struct CreateOptions : ParsableArguments { @Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to create github metadata", valueName: "enable")) var github: Bool = false + @Option(help: ArgumentHelp("Test case style: testing or xctest", valueName: "mode")) + var testCaseMode: TestCaseMode = .testing + @Flag(inversion: .prefixedNo, help: ArgumentHelp("Validate generated Package.swift files", valueName: "validate")) var validatePackage: Bool = true @@ -148,7 +153,7 @@ struct CreateOptions : ParsableArguments { } func projectOptionValues(projectName: String) -> ProjectOptionValues { - ProjectOptionValues(projectName: projectName, swiftPackageVersion: self.swiftPackageVersion, iOSMinVersion: self.iosMinVersion, macOSMinVersion: self.macosMinVersion, chain: self.chain, gitRepo: self.gitRepo, appfair: self.appfair, free: self.free || self.appfair, zero: self.zero, github: self.github, fastlane: self.fastlane) + ProjectOptionValues(projectName: projectName, swiftPackageVersion: self.swiftPackageVersion, iOSMinVersion: self.iosMinVersion, macOSMinVersion: self.macosMinVersion, chain: self.chain, gitRepo: self.gitRepo, appfair: self.appfair, free: self.free || self.appfair, zero: self.zero, github: self.github, fastlane: self.fastlane, testCaseMode: self.testCaseMode) } } diff --git a/Sources/SkipBuild/SkipProject.swift b/Sources/SkipBuild/SkipProject.swift index c184e3e3..42b99802 100644 --- a/Sources/SkipBuild/SkipProject.swift +++ b/Sources/SkipBuild/SkipProject.swift @@ -42,6 +42,11 @@ enum ModuleMode { } +enum TestCaseMode: String, CaseIterable { + case testing + case xctest +} + struct ProjectOptionValues { var projectName: String var swiftPackageVersion: String @@ -54,6 +59,7 @@ struct ProjectOptionValues { var zero: Bool var github: Bool var fastlane: Bool + var testCaseMode: TestCaseMode /// Prior to iOS 26, the default macOS version is 3 below the iOS version in terms of API compatibility (i.e., iOS 18.0 == macOS 15.0) var macOSMinVersionCalculated: Double { @@ -1052,21 +1058,142 @@ public class \(moduleName)Module { let rfolder = isNativeModule ? nil : resourceFolder - var testCaseCode = """ -\(testSourceHeader)import XCTest + var testCaseCode: String + if options.testCaseMode == .testing { + testCaseCode = """ +\(testSourceHeader)import Testing import OSLog import Foundation """ - if isNativeModule { + if isNativeModule { + testCaseCode += """ +import SkipBridge + +""" + } + + testCaseCode += """ +@testable import \(moduleName) + +let logger: Logger = Logger(subsystem: "\(moduleName)", category: "Tests") + +@Suite struct \(moduleName)Tests { + +""" + + if isNativeModule { + testCaseCode += """ + init() { + #if SKIP + // needed to load the compiled bridge when the tests are transpiled + loadPeerLibrary(packageName: "\(projectName)", moduleName: "\(moduleName)") + #endif + } + +""" + } + + testCaseCode += """ + + @Test func \(moduleName.prefix(1).lowercased() + moduleName.dropFirst())() throws { + logger.log("running test\(moduleName)") + #expect(1 + 2 == 3, "basic test") + } + +""" + + if let folderName = rfolder { + testCaseCode += """ + + @Test func decodeType() throws { + // load the TestData.json file from the \(folderName) folder and decode it into a struct + let resourceURL: URL = try #require(Bundle.module.url(forResource: "TestData", withExtension: "json")) + let testData = try JSONDecoder().decode(TestData.self, from: Data(contentsOf: resourceURL)) + #expect(testData.testModuleName == "\(moduleName)") + } + +""" + } + + if isNativeModule && (isModelModule || isNativeAppModule) { + testCaseCode += """ + + @Test func viewModel() async throws { + let vm = ViewModel() + vm.items.append(Item(title: "ABC")) + #expect(!vm.items.isEmpty) + #expect(vm.items.last?.title == "ABC") + + vm.clear() + #expect(vm.items.isEmpty) + } + +""" + + } else if isNativeModule { + testCaseCode += """ + + @Test func asyncThrowsFunction() async throws { + +""" + if moduleMode == .native || moduleMode == .nativeBridged { + testCaseCode += """ + let id = UUID() + +""" + } else if moduleMode == .kotlincompat { + testCaseCode += """ + #if SKIP + // when the native module is in kotlincompat, types are unwrapped Java classes + let id = java.util.UUID.randomUUID() + #else + let id = UUID() + #endif + +""" + } + + testCaseCode += """ + let type: \(moduleName)Module.\(moduleName)Type = try await \(moduleName)Module.create\(moduleName)Type(id: id, delay: 0.001) + #expect(type.id == id) + } + +""" + } + testCaseCode += """ + +} + +""" + if rfolder != nil { + testCaseCode += """ + +struct TestData : Codable, Hashable { + var testModuleName: String +} + +""" + } + } else { + // XCTest mode (default) + testCaseCode = """ +\(testSourceHeader)import XCTest +import OSLog +import Foundation + +""" + + if isNativeModule { + testCaseCode += """ import SkipBridge """ - } + } - testCaseCode += """ + testCaseCode += """ @testable import \(moduleName) let logger: Logger = Logger(subsystem: "\(moduleName)", category: "Tests") @@ -1076,19 +1203,19 @@ final class \(moduleName)Tests: XCTestCase { """ - if isNativeModule { - testCaseCode += """ + if isNativeModule { + testCaseCode += """ override func setUp() { - #if os(Android) - // needed to load the compiled bridge from the transpiled tests + #if SKIP + // needed to load the compiled bridge when the tests are transpiled loadPeerLibrary(packageName: "\(projectName)", moduleName: "\(moduleName)") #endif } """ - } + } - testCaseCode += """ + testCaseCode += """ func test\(moduleName)() throws { logger.log("running test\(moduleName)") @@ -1097,8 +1224,8 @@ final class \(moduleName)Tests: XCTestCase { """ - if let folderName = rfolder { - testCaseCode += """ + if let folderName = rfolder { + testCaseCode += """ func testDecodeType() throws { // load the TestData.json file from the \(folderName) folder and decode it into a struct @@ -1108,10 +1235,10 @@ final class \(moduleName)Tests: XCTestCase { } """ - } + } - if isNativeModule && (isModelModule || isNativeAppModule) { - testCaseCode += """ + if isNativeModule && (isModelModule || isNativeAppModule) { + testCaseCode += """ func testViewModel() async throws { let vm = ViewModel() @@ -1125,19 +1252,19 @@ final class \(moduleName)Tests: XCTestCase { """ - } else if isNativeModule { - testCaseCode += """ + } else if isNativeModule { + testCaseCode += """ func testAsyncThrowsFunction() async throws { """ - if moduleMode == .native || moduleMode == .nativeBridged { - testCaseCode += """ + if moduleMode == .native || moduleMode == .nativeBridged { + testCaseCode += """ let id = UUID() """ - } else if moduleMode == .kotlincompat { - testCaseCode += """ + } else if moduleMode == .kotlincompat { + testCaseCode += """ #if SKIP // when the native module is in kotlincompat, types are unwrapped Java classes let id = java.util.UUID.randomUUID() @@ -1146,30 +1273,31 @@ final class \(moduleName)Tests: XCTestCase { #endif """ - } + } - testCaseCode += """ + testCaseCode += """ let type: \(moduleName)Module.\(moduleName)Type = try await \(moduleName)Module.create\(moduleName)Type(id: id, delay: 0.001) XCTAssertEqual(id, type.id) } """ - } + } - testCaseCode += """ + testCaseCode += """ } """ - if rfolder != nil { - testCaseCode += """ + if rfolder != nil { + testCaseCode += """ struct TestData : Codable, Hashable { var testModuleName: String } """ + } } try testCaseCode.write(to: testSwiftFile, atomically: false, encoding: .utf8) diff --git a/Tests/SkipBuildTests/SkipCommandTests.swift b/Tests/SkipBuildTests/SkipCommandTests.swift index 67cfbd20..c0584361 100644 --- a/Tests/SkipBuildTests/SkipCommandTests.swift +++ b/Tests/SkipBuildTests/SkipCommandTests.swift @@ -190,26 +190,25 @@ final class SkipCommandTests: XCTestCase { let testCaseCode = try load("Tests/SomeModuleTests/SomeModuleTests.swift") XCTAssertEqual(testCaseCode, """ - import XCTest + import Testing import OSLog import Foundation @testable import SomeModule let logger: Logger = Logger(subsystem: "SomeModule", category: "Tests") - @available(macOS 13, *) - final class SomeModuleTests: XCTestCase { + @Suite struct SomeModuleTests { - func testSomeModule() throws { + @Test func someModule() throws { logger.log("running testSomeModule") - XCTAssertEqual(1 + 2, 3, "basic test") + #expect(1 + 2 == 3, "basic test") } - func testDecodeType() throws { + @Test func decodeType() throws { // load the TestData.json file from the Resources folder and decode it into a struct - let resourceURL: URL = try XCTUnwrap(Bundle.module.url(forResource: "TestData", withExtension: "json")) + let resourceURL: URL = try #require(Bundle.module.url(forResource: "TestData", withExtension: "json")) let testData = try JSONDecoder().decode(TestData.self, from: Data(contentsOf: resourceURL)) - XCTAssertEqual("SomeModule", testData.testModuleName) + #expect(testData.testModuleName == "SomeModule") } } @@ -733,7 +732,7 @@ final class SkipCommandTests: XCTestCase { let testCaseCode = try load("Tests/SomeModuleTests/SomeModuleTests.swift") XCTAssertEqual(testCaseCode, """ - import XCTest + import Testing import OSLog import Foundation import SkipBridge @@ -741,24 +740,23 @@ final class SkipCommandTests: XCTestCase { let logger: Logger = Logger(subsystem: "SomeModule", category: "Tests") - @available(macOS 13, *) - final class SomeModuleTests: XCTestCase { - override func setUp() { - #if os(Android) - // needed to load the compiled bridge from the transpiled tests + @Suite struct SomeModuleTests { + init() { + #if SKIP + // needed to load the compiled bridge when the tests are transpiled loadPeerLibrary(packageName: "basic-project", moduleName: "SomeModule") #endif } - func testSomeModule() throws { + @Test func someModule() throws { logger.log("running testSomeModule") - XCTAssertEqual(1 + 2, 3, "basic test") + #expect(1 + 2 == 3, "basic test") } - func testAsyncThrowsFunction() async throws { + @Test func asyncThrowsFunction() async throws { let id = UUID() let type: SomeModuleModule.SomeModuleType = try await SomeModuleModule.createSomeModuleType(id: id, delay: 0.001) - XCTAssertEqual(id, type.id) + #expect(type.id == id) } } @@ -1014,7 +1012,7 @@ final class SkipCommandTests: XCTestCase { let testCaseCode = try load("Tests/ModelModuleTests/ModelModuleTests.swift") XCTAssertEqual(testCaseCode, """ - import XCTest + import Testing import OSLog import Foundation import SkipBridge @@ -1022,28 +1020,27 @@ final class SkipCommandTests: XCTestCase { let logger: Logger = Logger(subsystem: "ModelModule", category: "Tests") - @available(macOS 13, *) - final class ModelModuleTests: XCTestCase { - override func setUp() { - #if os(Android) - // needed to load the compiled bridge from the transpiled tests + @Suite struct ModelModuleTests { + init() { + #if SKIP + // needed to load the compiled bridge when the tests are transpiled loadPeerLibrary(packageName: "cool-app", moduleName: "ModelModule") #endif } - func testModelModule() throws { + @Test func modelModule() throws { logger.log("running testModelModule") - XCTAssertEqual(1 + 2, 3, "basic test") + #expect(1 + 2 == 3, "basic test") } - func testViewModel() async throws { + @Test func viewModel() async throws { let vm = ViewModel() vm.items.append(Item(title: "ABC")) - XCTAssertFalse(vm.items.isEmpty) - XCTAssertEqual("ABC", vm.items.last?.title) + #expect(!vm.items.isEmpty) + #expect(vm.items.last?.title == "ABC") vm.clear() - XCTAssertTrue(vm.items.isEmpty) + #expect(vm.items.isEmpty) } } @@ -1200,7 +1197,7 @@ final class SkipCommandTests: XCTestCase { let testCaseCode = try load("Tests/ModelModuleTests/ModelModuleTests.swift") XCTAssertEqual(testCaseCode, """ - import XCTest + import Testing import OSLog import Foundation import SkipBridge @@ -1208,28 +1205,27 @@ final class SkipCommandTests: XCTestCase { let logger: Logger = Logger(subsystem: "ModelModule", category: "Tests") - @available(macOS 13, *) - final class ModelModuleTests: XCTestCase { - override func setUp() { - #if os(Android) - // needed to load the compiled bridge from the transpiled tests + @Suite struct ModelModuleTests { + init() { + #if SKIP + // needed to load the compiled bridge when the tests are transpiled loadPeerLibrary(packageName: "cool-app", moduleName: "ModelModule") #endif } - func testModelModule() throws { + @Test func modelModule() throws { logger.log("running testModelModule") - XCTAssertEqual(1 + 2, 3, "basic test") + #expect(1 + 2 == 3, "basic test") } - func testViewModel() async throws { + @Test func viewModel() async throws { let vm = ViewModel() vm.items.append(Item(title: "ABC")) - XCTAssertFalse(vm.items.isEmpty) - XCTAssertEqual("ABC", vm.items.last?.title) + #expect(!vm.items.isEmpty) + #expect(vm.items.last?.title == "ABC") vm.clear() - XCTAssertTrue(vm.items.isEmpty) + #expect(vm.items.isEmpty) } } @@ -1969,10 +1965,85 @@ final class SkipCommandTests: XCTestCase { """) } + func testLibInitXCTestMode() async throws { + let (projectURL, _) = try await skipInit(projectName: "basic-project", zero: false, mode: [.transpiledModel], tests: true, testCaseMode: .xctest, moduleNames: "SomeModule") + + let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } + + let testCaseCode = try load("Tests/SomeModuleTests/SomeModuleTests.swift") + XCTAssertEqual(testCaseCode, """ + import XCTest + import OSLog + import Foundation + @testable import SomeModule + + let logger: Logger = Logger(subsystem: "SomeModule", category: "Tests") + + @available(macOS 13, *) + final class SomeModuleTests: XCTestCase { + + func testSomeModule() throws { + logger.log("running testSomeModule") + XCTAssertEqual(1 + 2, 3, "basic test") + } + + func testDecodeType() throws { + // load the TestData.json file from the Resources folder and decode it into a struct + let resourceURL: URL = try XCTUnwrap(Bundle.module.url(forResource: "TestData", withExtension: "json")) + let testData = try JSONDecoder().decode(TestData.self, from: Data(contentsOf: resourceURL)) + XCTAssertEqual("SomeModule", testData.testModuleName) + } + + } + + struct TestData : Codable, Hashable { + var testModuleName: String + } + + """) + } + + func testLibInitTestingMode() async throws { + let (projectURL, _) = try await skipInit(projectName: "basic-project", zero: false, mode: [.transpiledModel], tests: true, testCaseMode: .testing, moduleNames: "SomeModule") + + let load = { try String(contentsOf: URL(fileURLWithPath: $0, isDirectory: false, relativeTo: projectURL)) } + + let testCaseCode = try load("Tests/SomeModuleTests/SomeModuleTests.swift") + XCTAssertEqual(testCaseCode, """ + import Testing + import OSLog + import Foundation + @testable import SomeModule + + let logger: Logger = Logger(subsystem: "SomeModule", category: "Tests") + + @Suite struct SomeModuleTests { + + @Test func someModule() throws { + logger.log("running testSomeModule") + #expect(1 + 2 == 3, "basic test") + } + + @Test func decodeType() throws { + // load the TestData.json file from the Resources folder and decode it into a struct + let resourceURL: URL = try #require(Bundle.module.url(forResource: "TestData", withExtension: "json")) + let testData = try JSONDecoder().decode(TestData.self, from: Data(contentsOf: resourceURL)) + #expect(testData.testModuleName == "SomeModule") + } + + } + + struct TestData : Codable, Hashable { + var testModuleName: String + } + + """) + } + /// Default arguments for `skip init` tests let initTestArgs = ["-jA", "--no-build", "--no-test", "--show-tree"] - func skipInit(projectName: String, documented: Bool = false, free: Bool? = nil, zero: Bool? = nil, bridged: Bool? = nil, appfair: Bool? = nil, mode: [ProjectMode], kotlincompat: Bool = false, tests moduleTests: Bool? = nil, fastlane: Bool? = nil, validatePackage: Bool? = true, appid: String? = nil, swiftPackageVersion: String? = nil, resourcePath: String? = "Resources", backgroundColor: String? = nil, moduleNames: String...) async throws -> (projectURL: URL, projectTree: String?) { + func skipInit(projectName: String, documented: Bool = false, free: Bool? = nil, zero: Bool? = nil, bridged: Bool? = nil, appfair: Bool? = nil, mode: [ProjectMode], kotlincompat: Bool = false, tests moduleTests: Bool? = nil, testCaseMode: TestCaseMode? = nil, fastlane: Bool? = nil, validatePackage: Bool? = true, appid: String? = nil, swiftPackageVersion: String? = nil, resourcePath: String? = "Resources", backgroundColor: String? = nil, moduleNames: String...) async throws -> (projectURL: URL, projectTree: String?) { let tmpDir = URL(fileURLWithPath: UUID().uuidString, isDirectory: true, relativeTo: URL(fileURLWithPath: NSTemporaryDirectory() + "/testLibInitCommand/", isDirectory: true)) try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) var cmd = ["init"] + initTestArgs @@ -2028,6 +2099,10 @@ final class SkipCommandTests: XCTestCase { cmd += ["--no-module-tests"] } + if let testCaseMode = testCaseMode { + cmd += ["--test-case-mode", testCaseMode.rawValue] + } + if let swiftPackageVersion { cmd += ["--swift-package-version", swiftPackageVersion] } From cd454c906b17f9bad26f433111e0bc3d7238e539 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Fri, 13 Mar 2026 13:14:09 -0400 Subject: [PATCH 2/2] Fix CI --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91f2563f..bf3adf90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,8 +143,6 @@ jobs: if: false #if: runner.os != 'Linux' - - run: skip android build --build-tests - - name: "Prepare Android emulator environment" if: runner.os == 'Linux' run: |