[K/N][test] XCTest support

* Adds xctest wrapper and launcher
* Supports running xctest bundles under the simulator and host xctest
* Initial support of xctest in the native.tests project with stdlib
 and kotlin.test

This is a part of ^KT-58928
diff --git a/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt b/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt
index a2b3958..5cc9d24 100644
--- a/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt
+++ b/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt
@@ -188,7 +188,7 @@
     project.tasks.getByName(taskName).dependsOnDist()
 }
 
-fun TaskProvider<Task>.dependsOnDist() {
+fun TaskProvider<out Task>.dependsOnDist() {
     configure {
         dependsOnDist()
     }
@@ -224,6 +224,10 @@
 
 fun Task.dependsOnDist() {
     val target = project.testTarget
+    dependsOnDist(target)
+}
+
+fun Task.dependsOnDist(target: KonanTarget) {
     if (project.isDefaultNativeHome) {
         dependsOn(":kotlin-native:dist")
         if (target != HostManager.host) {
@@ -252,6 +256,12 @@
     }
 }
 
+fun Task.dependsOnPlatformLibs(target: KonanTarget) {
+    if (project.isDefaultNativeHome) {
+        dependsOn(":kotlin-native:${target.name}PlatformLibs")
+    }
+}
+
 fun Task.konanOldPluginTaskDependenciesWalker(index: Int = 0, walker: Task.(Int) -> Unit) {
     walker(index + 1)
     dependsOn.forEach {
diff --git a/kotlin-native/runtime/src/main/cpp/Runtime.h b/kotlin-native/runtime/src/main/cpp/Runtime.h
index b7b073f..4cb16be 100644
--- a/kotlin-native/runtime/src/main/cpp/Runtime.h
+++ b/kotlin-native/runtime/src/main/cpp/Runtime.h
@@ -7,6 +7,7 @@
 #define RUNTIME_RUNTIME_H
 
 #include "Porting.h"
+#include "Memory.h"
 
 #ifdef __cplusplus
 extern "C" {
diff --git a/kotlin-native/utilities/xctest-runner/build.gradle.kts b/kotlin-native/utilities/xctest-runner/build.gradle.kts
new file mode 100644
index 0000000..5079ddf
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/build.gradle.kts
@@ -0,0 +1,213 @@
+import org.jetbrains.kotlin.*
+import org.jetbrains.kotlin.bitcode.CompileToBitcodeExtension
+import org.jetbrains.kotlin.cpp.CppUsage
+import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
+import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinUsages
+import org.jetbrains.kotlin.gradle.tasks.CInteropProcess
+import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile
+import org.jetbrains.kotlin.konan.target.*
+import java.io.ByteArrayOutputStream
+import java.util.*
+
+plugins {
+    id("kotlin.native.build-tools-conventions")
+    kotlin("multiplatform")
+    id("compile-to-bitcode")
+}
+
+group = "org.jetbrains.kotlin.native.test.xctest"
+
+val distDir: File by project
+val konanHome: String by extra(distDir.absolutePath)
+// Set native home for KGP
+extra["kotlin.native.home"] = konanHome
+
+with(PlatformInfo) {
+    if (isMac()) {
+        checkXcodeVersion(project)
+    }
+}
+
+/**
+ * Path to the target SDK platform.
+ *
+ * By default, K/N includes only SDK frameworks.
+ * It's required to get the Library frameworks path where the `XCTest.framework` is located.
+ */
+fun targetPlatform(target: String): String {
+    val out = ByteArrayOutputStream()
+    val result = project.exec {
+        executable = "/usr/bin/xcrun"
+        args = listOf("--sdk", target, "--show-sdk-platform-path")
+        standardOutput = out
+    }
+
+    check(result.exitValue == 0) {
+        "xcrun ended unsuccessfully. See the output: $out"
+    }
+
+    return out.toString().trim()
+}
+
+val targets = listOf(
+    KonanTarget.MACOS_X64,
+    KonanTarget.MACOS_ARM64,
+    KonanTarget.IOS_X64,
+    KonanTarget.IOS_SIMULATOR_ARM64,
+    KonanTarget.IOS_ARM64
+).filter {
+    it in platformManager.enabled
+}
+
+/*
+ * Double laziness: lazily create functions that execute `/usr/bin/xcrun` and return
+ * a path to the Developer frameworks.
+ */
+val developerFrameworks: Map<KonanTarget, () -> String> by lazy {
+    platformManager.enabled
+        .filter { it.family.isAppleFamily }
+        .associateWith { target ->
+            val configurable = platformManager.platform(target).configurables as AppleConfigurables
+            val platform = configurable.platformName().lowercase()
+            fun(): String = "${targetPlatform(platform)}/Developer/Library/Frameworks/"
+        }
+}
+
+/**
+ * Gets a path to the developer frameworks location.
+ */
+fun KonanTarget.getDeveloperFramework(): String = developerFrameworks[this]?.let { it() } ?: error("Not supported target $this")
+
+if (PlatformInfo.isMac()) {
+    kotlin {
+        val nativeTargets = listOf(
+            macosX64(KonanTarget.MACOS_X64.name),
+            macosArm64(KonanTarget.MACOS_ARM64.name),
+            iosX64(KonanTarget.IOS_X64.name),
+            iosArm64(KonanTarget.IOS_ARM64.name),
+            iosSimulatorArm64(KonanTarget.IOS_SIMULATOR_ARM64.name)
+        )
+
+        nativeTargets.forEach {
+            it.compilations.all {
+                cinterops {
+                    register("XCTest") {
+                        compilerOpts("-iframework", konanTarget.getDeveloperFramework())
+                    }
+                }
+            }
+        }
+        sourceSets.all {
+            languageSettings.apply {
+                // Oh, yeah! So much experimental, so wow!
+                optIn("kotlinx.cinterop.BetaInteropApi")
+                optIn("kotlinx.cinterop.ExperimentalForeignApi")
+                optIn("kotlin.experimental.ExperimentalNativeApi")
+            }
+        }
+    }
+}
+
+// Due to KT-42056 and KT-48410, it is not possible to set dependencies on dist when opened in the IDEA.
+// IDEA sync makes cinterop tasks eagerly resolve dependencies, effectively running the dist-build in configuration time
+if (!project.isIdeaActive) {
+    targets.forEach {
+        val targetName = it.name.capitalize()
+        tasks.named<KotlinNativeCompile>("compileKotlin$targetName") {
+            dependsOnDist(it)
+            dependsOnPlatformLibs(it)
+        }
+
+        tasks.named<CInteropProcess>("cinteropXCTest$targetName") {
+            dependsOnDist(it)
+            dependsOnPlatformLibs(it)
+        }
+    }
+} else {
+    tasks.named("prepareKotlinIdeaImport") {
+        enabled = false
+    }
+}
+
+bitcode {
+    targets.map { it.withSanitizer() }
+        .forEach { targetWithSanitizer ->
+            target(targetWithSanitizer) {
+                module("xctest") {
+                    compilerArgs.set(
+                        listOf(
+                            "-iframework", target.getDeveloperFramework(),
+                            "--std=c++17",
+                        )
+                    )
+                    // Uses headers from the K/N runtime
+                    headersDirs.from(project(":kotlin-native:runtime").files("src/main/cpp"))
+
+                    sourceSets { main {} }
+                    onlyIf { target.family.isAppleFamily }
+                }
+            }
+        }
+}
+
+val xcTestLauncherBitcode by configurations.creating {
+    isCanBeConsumed = false
+    isCanBeResolved = true
+    attributes {
+        attribute(CppUsage.USAGE_ATTRIBUTE, objects.named(CppUsage.LLVM_BITCODE))
+    }
+}
+
+val xcTestArtifactsConfig by configurations.creating {
+    attributes {
+        attribute(Usage.USAGE_ATTRIBUTE, objects.named(KotlinUsages.KOTLIN_API))
+        // Native target-specific
+        attribute(KotlinPlatformType.attribute, KotlinPlatformType.native)
+    }
+}
+
+dependencies {
+    xcTestLauncherBitcode(project)
+}
+
+// Each platform has three artifacts: cinterop, regular klib and bc. Add bc file to the klib
+targets.forEach { target ->
+    val targetName = target.name
+
+    val bitcodeArtifacts = xcTestLauncherBitcode.incoming.artifactView {
+        attributes {
+            attribute(TargetWithSanitizer.TARGET_ATTRIBUTE, target.withSanitizer())
+        }
+    }
+
+    tasks.register("${targetName}XCTestLauncher") {
+        description = "Build native launcher for $targetName"
+        group = CompileToBitcodeExtension.BUILD_TASK_GROUP
+        dependsOn(bitcodeArtifacts.files)
+    }
+
+    val runnerKlibProducer = tasks.register<Zip>("${targetName}XCTestRunner") {
+        val klibTask = tasks.named<KotlinNativeCompile>("compileKotlin${targetName.capitalize()}")
+        dependsOn(klibTask)
+
+        archiveFileName.set("${targetName}XCTest.klib")
+        destinationDirectory.set(layout.buildDirectory)
+
+        from(zipTree(klibTask.get().outputFile))
+        from(bitcodeArtifacts.files) {
+            into("default/targets/$targetName/native")
+        }
+    }
+
+    artifacts {
+        add(xcTestArtifactsConfig.name, runnerKlibProducer) {
+            classifier = targetName
+        }
+        add(xcTestArtifactsConfig.name, tasks.named<CInteropProcess>("cinteropXCTest${targetName.capitalize()}")) {
+            classifier = targetName
+        }
+        add(xcTestArtifactsConfig.name, File(target.getDeveloperFramework())) {
+            classifier = "${targetName}Frameworks"
+        }
+    }
+}
diff --git a/kotlin-native/utilities/xctest-runner/gradle.properties b/kotlin-native/utilities/xctest-runner/gradle.properties
new file mode 100644
index 0000000..bd7059f
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/gradle.properties
@@ -0,0 +1,6 @@
+# Disable commonizer. It writes its data to dist, that's not desirable during the build and messes with types
+kotlin.mpp.enableNativeDistributionCommonizationCache = false
+kotlin.mpp.enableGranularSourceSetsMetadata = false
+kotlin.mpp.enableCInteropCommonization = false
+kotlin.internal.mpp.hierarchicalStructureByDefault = false
+kotlin.mpp.enableCompatibilityMetadataVariant = false
\ No newline at end of file
diff --git a/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/NativeTestRunner.kt b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/NativeTestRunner.kt
new file mode 100644
index 0000000..c4ce73d
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/NativeTestRunner.kt
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
+ */
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
+import kotlinx.cinterop.*
+import kotlin.native.internal.test.*
+import platform.Foundation.*
+import platform.Foundation.NSError
+import platform.Foundation.NSInvocation
+import platform.Foundation.NSString
+import platform.Foundation.NSMethodSignature
+import platform.UniformTypeIdentifiers.UTTypeSourceCode
+import platform.XCTest.*
+import platform.objc.*
+
+/**
+ * An XCTest equivalent of the K/N TestCase.
+ */
+@ExportObjCClass(name = "Kotlin/Native::Test")
+class XCTestCaseRunner(
+    invocation: NSInvocation,
+    val testName: String,
+    val testCase: TestCase,
+) : XCTestCase(invocation) {
+    // Sets XCTest to continue running after failure to match Kotlin Test
+    override fun continueAfterFailure(): Boolean = true
+
+    private val ignored = testCase.ignored || testCase.suite.ignored
+
+    @ObjCAction
+    fun run() {
+        if (ignored) {
+            // FIXME: It is not possible to use XCTSkip() due to the KT-43719 and not implemented exception importing.
+            //  Using `_XCTSkipHandler(...)` fails with
+            //   Uncaught Kotlin exception: kotlinx.cinterop.ForeignException: _XCTSkipFailureException:: Test skipped
+            //  _XCTSkipHandler(testName, 0U, "Test $testName is ignored")
+            //  So, just skip the test. It will be seen as passed in XCode, but K/N TestListener should correctly process that.
+            return
+        }
+        try {
+            testCase.doRun()
+        } catch (throwable: Throwable) {
+            val type = when (throwable) {
+                is AssertionError -> XCTIssueTypeAssertionFailure
+                else -> XCTIssueTypeUncaughtException
+            }
+
+            val stackTrace = throwable.getStackTrace()
+            val failedStackLine = stackTrace.first {
+                // try to filter out kotlin.Exceptions and kotlin.test.Assertion inits
+                !it.contains("kfun:kotlin.")
+            }
+            // Find path and line number to create source location
+            val matchResult = Regex("^\\d+ +.* \\((.*):(\\d+):.*\\)$").find(failedStackLine)
+            val sourceLocation = if (matchResult != null) {
+                val (file, line) = matchResult.destructured
+                XCTSourceCodeLocation(file, line.toLong())
+            } else {
+                // No debug info to get the path. Still have to record location
+                XCTSourceCodeLocation(testCase.suite.name, 0L)
+            }
+
+            @Suppress("CAST_NEVER_SUCCEEDS")
+            val stackAsPayload = (stackTrace.joinToString("\n") as? NSString)?.dataUsingEncoding(NSUTF8StringEncoding)
+            val stackTraceAttachment = XCTAttachment.attachmentWithUniformTypeIdentifier(
+                identifier = UTTypeSourceCode.identifier,
+                name = "Kotlin stacktrace (full)",
+                payload = stackAsPayload,
+                userInfo = null
+            )
+
+            val issue = XCTIssue(
+                type = type,
+                compactDescription = "$throwable in $testName",
+                detailedDescription = buildString {
+                    appendLine("Test '$testName' from '${testCase.suite.name}' failed with $throwable")
+                    throwable.cause?.let { appendLine("(caused by ${throwable.cause})") }
+                },
+                sourceCodeContext = XCTSourceCodeContext(
+                    callStackAddresses = throwable.getStackTraceAddresses(),
+                    location = sourceLocation
+                ),
+                associatedError = NSErrorWithKotlinException(throwable),
+                attachments = listOf(stackTraceAttachment)
+            )
+            testRun?.recordIssue(issue) ?: error("TestRun for the test $testName not found")
+        }
+    }
+
+    override fun setUp() {
+        if (!ignored) testCase.doBefore()
+    }
+
+    override fun tearDown() {
+        if (!ignored) testCase.doAfter()
+    }
+
+    override fun description(): String = buildString {
+        append(testName)
+        if (ignored) append("(ignored)")
+    }
+
+    override fun name() = testName
+
+    companion object : XCTestCaseMeta() {
+        /**
+         * This method is invoked by the XCTest when it discovered XCTestCase instance
+         * that contains test method.
+         */
+        override fun testCaseWithInvocation(invocation: NSInvocation?): XCTestCase {
+            error(
+                """
+                This should not happen by default.
+                Got invocation: ${invocation?.description}
+                with selector @sel(${NSStringFromSelector(invocation?.selector)})
+                """.trimIndent()
+            )
+        }
+
+        // region Dynamic run methods creation
+
+        /**
+         * Creates and adds method to the metaclass with implementation block
+         * that holds an XCTestCase instance to be run
+         */
+        private fun createRunMethod(selector: SEL) {
+            val result = class_addMethod(
+                cls = this.`class`(),
+                name = selector,
+                imp = imp_implementationWithBlock(this::runner),
+                types = "v@:" // See ObjC' type encodings: v (returns void), @ (id self), : (SEL _cmd)
+            )
+            check(result) {
+                "Internal error: was unable to add method with selector $selector"
+            }
+        }
+
+        /**
+         * Disposes the implementation block for given selector
+         */
+        private fun dispose(selector: SEL) {
+            val imp = class_getMethodImplementation(
+                cls = this.`class`(),
+                name = selector
+            )
+            val result = imp_removeBlock(imp)
+            check(result) {
+                "Internal error: was unable to remove block for $selector"
+            }
+        }
+
+        // TODO: Clean up those methods? When/where should this be invoked?
+        private fun disposeRunMethods() {
+            testMethodsNames().forEach {
+                val selector = NSSelectorFromString(it)
+                dispose(selector)
+            }
+        }
+
+        @Suppress("UNUSED_PARAMETER")
+        private fun runner(runner: XCTestCaseRunner, cmd: SEL) = runner.run()
+        // endregion
+
+        /**
+         * Creates Test invocations for each test method to make them resolvable by the XCTest machinery.
+         *
+         * @see NSInvocation
+         */
+        override fun testInvocations(): List<NSInvocation> = testMethodsNames().map {
+            val selector = NSSelectorFromString(it)
+            createRunMethod(selector)
+            this.instanceMethodSignatureForSelector(selector)?.let { signature ->
+                // Those casts can never succeed ¯\_(ツ)_/¯
+                @Suppress("CAST_NEVER_SUCCEEDS")
+                val invocation = NSInvocation.invocationWithMethodSignature(signature as NSMethodSignature)
+                invocation.setSelector(selector)
+                invocation
+            } ?: error("Not able to create NSInvocation for method $it")
+        }
+    }
+}
+
+private typealias SEL = COpaquePointer?
+
+/**
+ * This is a NSError-wrapper of Kotlin exception used to pass it through the XCTIssue.
+ * See [NativeTestObserver] for the usage.
+ */
+internal class NSErrorWithKotlinException(val kotlinException: Throwable) : NSError(NSCocoaErrorDomain, NSValidationErrorMinimum, null)
+
+/**
+ * XCTest equivalent of K/N TestSuite.
+ */
+class XCTestSuiteRunner(val testSuite: TestSuite) : XCTestSuite(testSuite.name) {
+    private val ignoredSuite: Boolean
+        get() = testSuite.ignored || testSuite.testCases.all { it.value.ignored }
+
+    override fun setUp() {
+        if (!ignoredSuite) testSuite.doBeforeClass()
+    }
+
+    override fun tearDown() {
+        if (!ignoredSuite) testSuite.doAfterClass()
+    }
+}
diff --git a/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/TestObserver.kt b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/TestObserver.kt
new file mode 100644
index 0000000..d9ecc3d6
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/TestObserver.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
+ */
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
+import kotlin.native.internal.test.*
+import kotlin.time.*
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+import platform.Foundation.NSError
+import platform.darwin.NSObject
+import platform.XCTest.*
+
+/**
+ * Test execution observation.
+ *
+ * Logs tests and notifies listeners set with [testSettings].
+ * @see TestSettings
+ */
+internal class NativeTestObserver(private val testSettings: TestSettings) : NSObject(), XCTestObservationProtocol {
+    private val listeners: Set<TestListener> = testSettings.listeners
+
+    private val logger: TestLogger = testSettings.logger
+
+    private inline fun sendToListeners(event: TestListener.() -> Unit) {
+        logger.event()
+        listeners.forEach(event)
+    }
+
+    private fun XCTest.getTestDuration(): Duration =
+        testRun?.totalDuration
+            ?.toDuration(DurationUnit.SECONDS)
+            ?: Duration.ZERO
+
+    override fun testCase(testCase: XCTestCase, didRecordIssue: XCTIssue) {
+        val duration = testCase.getTestDuration()
+        val error = didRecordIssue.associatedError as NSError
+        val throwable = if (error is NSErrorWithKotlinException) {
+            error.kotlinException
+        } else {
+            Throwable(didRecordIssue.compactDescription)
+        }
+        if (testCase is XCTestCaseRunner) {
+            sendToListeners { fail(testCase.testCase, throwable, duration.inWholeMilliseconds) }
+        }
+    }
+
+    override fun testCase(testCase: XCTestCase, didRecordExpectedFailure: XCTExpectedFailure) {
+        logger.log("TestCase: $testCase got expected failure: ${didRecordExpectedFailure.failureReason}")
+        this.testCase(testCase, didRecordExpectedFailure.issue)
+    }
+
+    override fun testCaseDidFinish(testCase: XCTestCase) {
+        val duration = testCase.getTestDuration()
+        if (testCase.testRun?.hasSucceeded == true) {
+            if (testCase is XCTestCaseRunner) {
+                val test = testCase.testCase
+                if (!test.ignored) sendToListeners { pass(test, duration.inWholeMilliseconds) }
+            }
+        }
+    }
+
+    override fun testCaseWillStart(testCase: XCTestCase) {
+        if (testCase is XCTestCaseRunner) {
+            val test = testCase.testCase
+            if (test.ignored) {
+                sendToListeners { ignore(test) }
+            } else {
+                sendToListeners { start(test) }
+            }
+        }
+    }
+
+    override fun testSuite(testSuite: XCTestSuite, didRecordIssue: XCTIssue) {
+        logger.log("TestSuite ${testSuite.name} recorded issue: ${didRecordIssue.compactDescription}")
+    }
+
+    override fun testSuite(testSuite: XCTestSuite, didRecordExpectedFailure: XCTExpectedFailure) {
+        logger.log("TestSuite ${testSuite.name} got expected failure: ${didRecordExpectedFailure.failureReason}")
+        this.testSuite(testSuite, didRecordExpectedFailure.issue)
+    }
+
+    override fun testSuiteDidFinish(testSuite: XCTestSuite) {
+        val duration = testSuite.getTestDuration().inWholeMilliseconds
+        if (testSuite is XCTestSuiteRunner) {
+            sendToListeners { finishSuite(testSuite.testSuite, duration) }
+        } else if (testSuite.name == TOP_LEVEL_SUITE) {
+            sendToListeners {
+                finishIteration(testSettings, 0, duration)  // test iterations are not supported
+                finishTesting(testSettings, duration)
+            }
+        }
+    }
+
+    override fun testSuiteWillStart(testSuite: XCTestSuite) {
+        if (testSuite is XCTestSuiteRunner) {
+            sendToListeners { startSuite(testSuite.testSuite) }
+        } else if (testSuite.name == TOP_LEVEL_SUITE) {
+            sendToListeners {
+                startTesting(testSettings)
+                startIteration(testSettings, 0, testSettings.testSuites)  // test iterations are not supported
+            }
+        }
+    }
+
+    override fun debugDescription() = "Native test listener with test settings $testSettings"
+}
\ No newline at end of file
diff --git a/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/configuration.kt b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/configuration.kt
new file mode 100644
index 0000000..3325ecb
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/commonMain/kotlin/configuration.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
+ */
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
+import kotlinx.cinterop.*
+import kotlin.native.internal.test.*
+import platform.Foundation.NSInvocation
+import platform.Foundation.NSBundle
+import platform.Foundation.NSStringFromSelector
+import platform.XCTest.*
+import platform.objc.*
+
+internal const val TOP_LEVEL_SUITE = "Kotlin/Native test suite"
+
+// Name of the key that contains arguments used to set [TestSettings]
+private const val TEST_ARGUMENTS_KEY = "KotlinNativeTestArgs"
+
+// Test settings should be initialized by the setup method
+// It stores settings with the filtered test suites, loggers and listeners.
+private var testSettings: TestSettings? = null
+
+internal fun testMethodsNames(): List<String> = testSettings?.testSuites
+    ?.toList()
+    ?.flatMap { testSuite ->
+        testSuite.testCases.values.map { "$testSuite.${it.name}" }
+    } ?: error("TestSettings isn't initialized")
+
+@Suppress("unused")
+@kotlin.native.internal.ExportForCppRuntime("Konan_create_testSuite")
+internal fun setupXCTestSuite(): XCTestSuite {
+    val nativeTestSuite = XCTestSuite.testSuiteWithName(TOP_LEVEL_SUITE)
+
+    // Get test arguments from the Info.plist to create test settings
+    val plistTestArgs = NSBundle.allBundles.mapNotNull {
+        (it as? NSBundle)?.infoDictionary?.get(TEST_ARGUMENTS_KEY)
+    }.singleOrNull() as? String
+    val args = plistTestArgs?.split(" ")?.toTypedArray() ?: emptyArray<String>()
+
+    // Initialize settings with the given args
+    testSettings = TestProcessor(GeneratedSuites.suites, args).process()
+
+    checkNotNull(testSettings) {
+        "Test settings wasn't set. Check provided arguments and suites"
+    }
+
+    // Set test observer that will log test execution
+    testSettings?.let {
+        XCTestObservationCenter.sharedTestObservationCenter.addTestObserver(NativeTestObserver(it))
+    }
+
+    if (testSettings?.runTests == true) {
+        // Generate and add tests to the main suite
+        testSettings?.testSuites?.generate()?.forEach {
+            nativeTestSuite.addTest(it)
+        }
+
+        // Tests created (self-check)
+        @Suppress("UNCHECKED_CAST")
+        check(testSettings?.testSuites?.size == (nativeTestSuite.tests as List<XCTest>).size) {
+            "The amount of generated XCTest suites should be equal to Kotlin test suites"
+        }
+    }
+
+    return nativeTestSuite
+}
+
+private fun Collection<TestSuite>.generate(): List<XCTestSuite> {
+    val testInvocations = XCTestCaseRunner.testInvocations()
+    return this.map { suite ->
+        val xcSuite = XCTestSuiteRunner(suite)
+        suite.testCases.values.map { testCase ->
+            testInvocations.filter {
+                it.selectorString() == "${suite.name}.${testCase.name}"
+            }.map { invocation ->
+                XCTestCaseRunner(
+                    invocation = invocation,
+                    testName = "${suite.name}.${testCase.name}",
+                    testCase = testCase
+                )
+            }.single()
+        }.forEach {
+            xcSuite.addTest(it)
+        }
+        xcSuite
+    }
+}
+
+private fun NSInvocation.selectorString() = NSStringFromSelector(selector)
diff --git a/kotlin-native/utilities/xctest-runner/src/commonTest/kotlin/Test.kt b/kotlin-native/utilities/xctest-runner/src/commonTest/kotlin/Test.kt
new file mode 100644
index 0000000..9f8577c
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/commonTest/kotlin/Test.kt
@@ -0,0 +1,125 @@
+import kotlin.native.concurrent.Worker
+import kotlin.test.*
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+
+@BeforeClass
+fun testTopBeforeClass() {
+    println("Top level BEFORE CLASS")
+}
+
+@AfterClass
+fun testTopAfterClass() {
+    println("Top level AFTER CLASS")
+}
+
+@BeforeTest
+fun testTopLevelBefore() {
+    println("Top level BEFORE")
+}
+
+@AfterTest
+fun testTopLevelAfter() {
+    println("Top level AFTER")
+}
+
+@Test
+fun testTopOne() {
+    println("Top level ONE")
+}
+
+@Test
+fun testTopTwo() {
+    println("Top level TWO")
+}
+
+@Test
+@Ignore
+fun testTopIgnored() {
+    println("Top level IGNORED")
+}
+
+class MyTest {
+    companion object {
+        @BeforeClass
+        fun beforeClass() {
+            println("Setup @BeforeClass")
+        }
+
+        @AfterClass
+        fun afterClass() {
+            println("After @AfterClass")
+        }
+    }
+
+    @BeforeTest
+    fun beforeTest() {
+        println("Setup @BeforeTest")
+    }
+
+    @Test
+    fun testABC() {
+        println("Test @Test ABC")
+        // Wait a sec to see that test is actually working in the suite, not during creation
+        Worker.current.park(1.seconds.toLong(DurationUnit.MICROSECONDS))
+    }
+
+    @Test
+    fun testOther() {
+        println("Test @Test Other")
+    }
+
+    @Test
+    fun testFailed() {
+        println("Failed started")
+        assertTrue(false, "Kotlin assertion failed")
+        println("Failed ended")
+    }
+
+    @Test
+    @Ignore
+    fun testIgnored() {
+        println("Ignored test")
+    }
+
+    @AfterTest
+    fun afterTest() {
+        println("After @AfterTest")
+    }
+}
+
+@Ignore
+class IgnoredSuite {
+    @BeforeTest
+    fun beforeTest() {
+        println("Setup @BeforeTest")
+    }
+
+    @Test
+    fun testTest() {
+        println("Test @Test Test")
+    }
+
+    @AfterTest
+    fun afterTest() {
+        println("After @AfterTest")
+    }
+}
+
+class SuiteWithIgnoredCases {
+    @BeforeTest
+    fun beforeTest() {
+        println("Setup @BeforeTest")
+    }
+
+    @Test
+    @Ignore
+    fun testIgnored() {
+        println("Ignored test")
+    }
+
+    @AfterTest
+    fun afterTest() {
+        println("After @AfterTest")
+    }
+}
\ No newline at end of file
diff --git a/kotlin-native/utilities/xctest-runner/src/nativeInterop/cinterop/XCTest.def b/kotlin-native/utilities/xctest-runner/src/nativeInterop/cinterop/XCTest.def
new file mode 100644
index 0000000..8c8f2ad
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/nativeInterop/cinterop/XCTest.def
@@ -0,0 +1,9 @@
+depends = Foundation darwin posix
+language = Objective-C
+package = platform.XCTest
+modules = XCTest
+
+compilerOpts = -framework XCTest
+linkerOpts = -framework XCTest
+
+foreignExceptionMode = objc-wrap
diff --git a/kotlin-native/utilities/xctest-runner/src/xctest/cpp/TestLauncher.mm b/kotlin-native/utilities/xctest-runner/src/xctest/cpp/TestLauncher.mm
new file mode 100644
index 0000000..2cd9f6d
--- /dev/null
+++ b/kotlin-native/utilities/xctest-runner/src/xctest/cpp/TestLauncher.mm
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
+ */
+
+#import <XCTest/XCTest.h>
+
+#import "Common.h"
+#import "Runtime.h"
+#import "ObjCExport.h"
+
+extern "C" OBJ_GETTER0(Konan_create_testSuite);
+
+@interface XCTestLauncher : XCTestCase
+@end
+
+@implementation XCTestLauncher
+/**
+ * Test suites factory.
+ *
+ * This is a starting point for XCTest to get the test suites with test cases.
+ * K/N dynamically adds test suites for Kotlin tests.
+ * See `setupXCTestSuite` Kotlin method.
+ */
++ (id)defaultTestSuite {
+    Kotlin_initRuntimeIfNeeded();
+    Kotlin_mm_switchThreadStateRunnable();
+    KRef result = nil;
+    Konan_create_testSuite(&result);
+    id retainedResult = Kotlin_ObjCExport_refToRetainedObjC(result);
+    Kotlin_mm_switchThreadStateNative();
+    return retainedResult;
+}
+
+- (void)dealloc {
+    Kotlin_shutdownRuntime();
+    [super dealloc];
+}
+@end
diff --git a/native/executors/src/main/kotlin/org/jetbrains/kotlin/native/executors/XCTestExecutor.kt b/native/executors/src/main/kotlin/org/jetbrains/kotlin/native/executors/XCTestExecutor.kt
new file mode 100644
index 0000000..b61c56d
--- /dev/null
+++ b/native/executors/src/main/kotlin/org/jetbrains/kotlin/native/executors/XCTestExecutor.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
+ */
+
+package org.jetbrains.kotlin.native.executors
+
+import org.jetbrains.kotlin.konan.target.*
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.nio.file.Files
+import kotlin.io.path.exists
+
+abstract class AbstractXCTestExecutor(
+    private val configurables: AppleConfigurables,
+    private val executor: Executor
+) : Executor {
+    private val hostExecutor = HostExecutor()
+
+    private val target by configurables::target
+
+    private fun targetPlatform(): String {
+        val xcodeTarget = when (target) {
+            KonanTarget.MACOS_X64, KonanTarget.MACOS_ARM64 -> "macosx"
+            KonanTarget.IOS_X64, KonanTarget.IOS_SIMULATOR_ARM64 -> "iphonesimulator"
+            KonanTarget.IOS_ARM64 -> "iphoneos"
+            else -> error("Target $target is not supported buy the executor")
+        }
+
+        val stdout = ByteArrayOutputStream()
+        val request = ExecuteRequest(
+            "/usr/bin/xcrun",
+            args = mutableListOf("--sdk", xcodeTarget, "--show-sdk-platform-path"),
+            stdout = stdout
+        )
+        hostExecutor.execute(request).assertSuccess()
+
+        return stdout.toString("UTF-8").trim()
+    }
+
+    private val frameworkPath: String
+        get() = "${targetPlatform()}/Developer/Library/Frameworks/"
+
+    private val xcTestExecutablePath: String
+        get() = "${targetPlatform()}/Developer/Library/Xcode/Agents/xctest"
+
+    override fun execute(request: ExecuteRequest): ExecuteResponse {
+        val bundlePath = if (request.args.isNotEmpty()) {
+            // Copy the bundle to a temp dir
+            val dir = Files.createTempDirectory("tmp-xctest-runner")
+            dir.toFile().deleteOnExit()
+            val newBundlePath = File(request.executableAbsolutePath).run {
+                val newPath = dir.resolve(name)
+                copyRecursively(newPath.toFile())
+                newPath
+            }
+            check(newBundlePath.exists())
+
+            // Passing arguments to the XCTest-runner using Info.plist file.
+            val infoPlist = newBundlePath.toFile()
+                .walk()
+                .firstOrNull { it.name == "Info.plist" }
+                ?.absolutePath
+            checkNotNull(infoPlist) { "Info.plist of xctest-bundle wasn't found. Check the bundle contents and location "}
+
+            val writeArgsRequest = ExecuteRequest(
+                executableAbsolutePath = "/usr/libexec/PlistBuddy",
+                args = mutableListOf("-c", "Add :KotlinNativeTestArgs string ${request.args.joinToString(" ")}", infoPlist)
+            )
+            val writeResponse = hostExecutor.execute(writeArgsRequest)
+            writeResponse.assertSuccess()
+
+            newBundlePath.toString()
+        } else {
+            request.executableAbsolutePath
+        }
+
+        val response = executor.execute(request.copying {
+            environment["DYLD_FRAMEWORK_PATH"] = frameworkPath
+            executableAbsolutePath = xcTestExecutablePath
+            args.clear()
+            args.add(bundlePath)
+        })
+
+        if (request.executableAbsolutePath != bundlePath) {
+            // Remove the copied bundle after the run
+            File(bundlePath).deleteRecursively()
+        }
+        return response
+    }
+}
+
+class XCTestHostExecutor(configurables: AppleConfigurables) : AbstractXCTestExecutor(configurables, HostExecutor())
+
+class XCTestSimulatorExecutor(configurables: AppleConfigurables) :
+    AbstractXCTestExecutor(configurables, XcodeSimulatorExecutor(configurables))
\ No newline at end of file
diff --git a/native/native.tests/build.gradle.kts b/native/native.tests/build.gradle.kts
index 36b677c..11243d1 100644
--- a/native/native.tests/build.gradle.kts
+++ b/native/native.tests/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.konan.target.HostManager
+
 plugins {
     kotlin("jvm")
     id("jps-compatible")
@@ -33,14 +35,49 @@
     }
 }
 
+if (kotlinBuildProperties.isKotlinNativeEnabled &&
+    HostManager.hostIsMac &&
+    project.hasProperty(TestProperty.XCTEST_FRAMEWORK.fullName)
+) {
+    val xcTestConfig = configurations.detachedConfiguration(
+        dependencies.project(path = ":kotlin-native:utilities:xctest-runner", configuration = "xcTestArtifactsConfig")
+    )
+    // Set test tasks dependency on this config
+    tasks.withType<Test>().configureEach {
+        if (name.endsWith("XCTest")) {
+            dependsOn(xcTestConfig)
+        }
+    }
+
+    val testTarget = project.findProperty(TestProperty.TEST_TARGET.fullName)?.toString() ?: HostManager.hostName
+    // Set XCTest runner and cinterop klibs location
+    project.extra.set(
+        TestProperty.CUSTOM_KLIBS.fullName,
+        xcTestConfig.resolvedConfiguration
+            .resolvedArtifacts
+            .filter { it.classifier == testTarget }
+            .map { it.file }
+            .joinToString(separator = File.pathSeparator)
+    )
+    // Set XCTest.framework location (Developer Frameworks directory)
+    project.setProperty(
+        TestProperty.XCTEST_FRAMEWORK.fullName,
+        xcTestConfig.resolvedConfiguration
+            .resolvedArtifacts
+            .filter { it.classifier == "${testTarget}Frameworks" }
+            .map { it.file }
+            .singleOrNull()
+    )
+}
+
 testsJar {}
 
 // Tasks that run different sorts of tests. Most frequent use case: running specific tests at TeamCity.
 val infrastructureTest = nativeTest("infrastructureTest", "infrastructure")
 val codegenBoxTest = nativeTest("codegenBoxTest", "codegen & !frontend-fir")
 val codegenBoxK2Test = nativeTest("codegenBoxK2Test", "codegen & frontend-fir")
-val stdlibTest = nativeTest("stdlibTest", "stdlib & !frontend-fir")
-val stdlibK2Test = nativeTest("stdlibK2Test", "stdlib & frontend-fir")
+val stdlibTest = nativeTest("stdlibTest", "stdlib & !frontend-fir & !xctest")
+val stdlibK2Test = nativeTest("stdlibK2Test", "stdlib & frontend-fir & !xctest")
 val kotlinTestLibraryTest = nativeTest("kotlinTestLibraryTest", "kotlin-test & !frontend-fir")
 val kotlinTestLibraryK2Test = nativeTest("kotlinTestLibraryK2Test", "kotlin-test & frontend-fir")
 val partialLinkageTest = nativeTest("partialLinkageTest", "partial-linkage")
@@ -49,6 +86,12 @@
 val cachesTest = nativeTest("cachesTest", "caches")
 val klibTest = nativeTest("klibTest", "klib")
 
+// xctest tasks
+val stdlibTestWithXCTest = nativeTest("stdlibTestWithXCTest", "stdlib & !frontend-fir & xctest")
+val stdlibK2TestWithXCTest = nativeTest("stdlibK2TestWithXCTest", "stdlib & frontend-fir & xctest")
+val kotlinTestLibraryTestWithXCTest = nativeTest("kotlinTestLibraryTestWithXCTest", "kotlin-test & !frontend-fir & xctest")
+val kotlinTestLibraryK2TestWithXCTest = nativeTest("kotlinTestLibraryK2TestWithXCTest", "kotlin-test & frontend-fir & xctest")
+
 val testTags = findProperty("kotlin.native.tests.tags")?.toString()
 // Note: arbitrary JUnit tag expressions can be used in this property.
 // See https://junit.org/junit5/docs/current/user-guide/#running-tests-tag-expressions
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/KotlinTestLibraryTest.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/KotlinTestLibraryTest.kt
index 492567b..c17a8f6 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/KotlinTestLibraryTest.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/KotlinTestLibraryTest.kt
@@ -63,3 +63,38 @@
     @TestFactory
     fun worker() = dynamicTestCase(TestCaseId.Named("worker"))
 }
+
+@Tag("kotlin-test")
+@Tag("xctest")
+@PredefinedTestCases(
+    TC(
+        name = "defaultXCTest",
+        runnerType = TestRunnerType.DEFAULT,
+        freeCompilerArgs = [STDLIB_IS_A_FRIEND],
+        sourceLocations = ["libraries/kotlin.test/common/src/test/kotlin/**.kt"]
+    )
+)
+@UsePartialLinkage(UsePartialLinkage.Mode.DISABLED)
+class KotlinTestLibraryTestWithXCTest : AbstractNativeBlackBoxTest() {
+    @TestFactory
+    fun default() = dynamicTestCase(TestCaseId.Named("defaultXCTest"))
+}
+
+@Tag("kotlin-test")
+@Tag("frontend-fir")
+@Tag("xctest")
+@PredefinedTestCases(
+    TC(
+        name = "defaultXCTest",
+        runnerType = TestRunnerType.DEFAULT,
+        freeCompilerArgs = [STDLIB_IS_A_FRIEND],
+        sourceLocations = ["libraries/kotlin.test/common/src/test/kotlin/**.kt"]
+    )
+)
+@FirPipeline
+@UsePartialLinkage(UsePartialLinkage.Mode.DISABLED)
+class FirKotlinTestLibraryTestWithXCTest : AbstractNativeBlackBoxTest() {
+    @TestFactory
+    fun default() = dynamicTestCase(TestCaseId.Named("defaultXCTest"))
+}
+
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/StdlibTest.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/StdlibTest.kt
index b91058b..f371f4d 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/StdlibTest.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/StdlibTest.kt
@@ -7,6 +7,8 @@
 
 package org.jetbrains.kotlin.konan.blackboxtest
 
+import org.jetbrains.kotlin.konan.blackboxtest.support.*
+import org.jetbrains.kotlin.konan.blackboxtest.support.ClassLevelProperty
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestCaseId
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestRunnerType
 import org.jetbrains.kotlin.konan.blackboxtest.support.group.FirPipeline
@@ -111,6 +113,63 @@
     fun worker() = dynamicTestCase(TestCaseId.Named("worker"))
 }
 
+@Tag("stdlib")
+@Tag("xctest")
+@PredefinedTestCases(
+    TC(
+        name = "xctest",
+        runnerType = TestRunnerType.DEFAULT,
+        freeCompilerArgs = [ENABLE_MPP, STDLIB_IS_A_FRIEND, ENABLE_X_STDLIB_API, ENABLE_X_ENCODING_API, ENABLE_RANGE_UNTIL],
+        sourceLocations = [
+            "libraries/stdlib/test/**.kt",
+            "libraries/stdlib/common/test/**.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/text/**.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/utils.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/jsCollectionFactoriesActuals.kt"
+        ],
+        ignoredTests = [DISABLED_STDLIB_TEST]
+    )
+)
+@UsePartialLinkage(UsePartialLinkage.Mode.DISABLED)
+class StdlibTestWithXCTest : AbstractNativeBlackBoxTest() {
+    @TestFactory
+    fun xctest() = dynamicTestCase(TestCaseId.Named("xctest"))
+}
+
+@Tag("stdlib")
+@Tag("xctest")
+@Tag("frontend-fir")
+@PredefinedTestCases(
+    TC(
+        name = "xctest",
+        runnerType = TestRunnerType.DEFAULT,
+        freeCompilerArgs = [
+            ENABLE_MPP, STDLIB_IS_A_FRIEND, ENABLE_X_STDLIB_API, ENABLE_X_ENCODING_API, ENABLE_RANGE_UNTIL,
+            "-Xcommon-sources=libraries/stdlib/common/test/jsCollectionFactories.kt",
+            "-Xcommon-sources=libraries/stdlib/common/test/testUtils.kt",
+            "-Xcommon-sources=libraries/stdlib/test/testUtils.kt",
+            "-Xcommon-sources=libraries/stdlib/test/text/StringEncodingTest.kt",
+        ],
+        sourceLocations = [
+            "libraries/stdlib/test/**.kt",
+            "libraries/stdlib/common/test/**.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/text/**.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/utils.kt",
+            "kotlin-native/backend.native/tests/stdlib_external/jsCollectionFactoriesActuals.kt"
+        ],
+        ignoredFiles = [
+            DISABLED_K2_ARRAYS,
+        ],
+        ignoredTests = [DISABLED_STDLIB_TEST]
+    )
+)
+@FirPipeline
+@UsePartialLinkage(UsePartialLinkage.Mode.DISABLED)
+class FirStdlibTestWithXCTest : AbstractNativeBlackBoxTest() {
+    @TestFactory
+    fun xctest() = dynamicTestCase(TestCaseId.Named("xctest"))
+}
+
 private const val ENABLE_MPP = "-Xmulti-platform"
 internal const val STDLIB_IS_A_FRIEND = "-friend-modules=$KOTLIN_NATIVE_DISTRIBUTION/klib/common/stdlib"
 private const val ENABLE_X_STDLIB_API = "-opt-in=kotlin.ExperimentalStdlibApi"
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/ConfigurationProperties.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/ConfigurationProperties.kt
index 8bfde34..094daa2 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/ConfigurationProperties.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/ConfigurationProperties.kt
@@ -69,7 +69,7 @@
     EXECUTION_TIMEOUT("executionTimeout"),
     SANITIZER("sanitizer"),
     COMPILER_OUTPUT_INTERCEPTOR("compilerOutputInterceptor"),
-
+    XCTEST_FRAMEWORK("xctest"),
     ;
 
     internal val propertyName = fullPropertyName(shortName)
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/NativeTestSupport.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/NativeTestSupport.kt
index 1a7d442..9c70b57 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/NativeTestSupport.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/NativeTestSupport.kt
@@ -199,6 +199,7 @@
         output += computePipelineType(testClass.get())
         output += computeUsedPartialLinkageConfig(enclosingTestClass)
         output += computeCompilerOutputInterceptor(enforcedProperties)
+        output += computeXCTestRunner(enforcedProperties)
 
         return nativeTargets
     }
@@ -251,7 +252,7 @@
         enforcedProperties: EnforcedProperties,
         distribution: Distribution,
         kotlinNativeTargets: KotlinNativeTargets,
-        optimizationMode: OptimizationMode
+        optimizationMode: OptimizationMode,
     ): CacheMode {
         val cacheMode = ClassLevelProperty.CACHE_MODE.readValue(
             enforcedProperties,
@@ -323,6 +324,14 @@
         return Timeouts(executionTimeout)
     }
 
+    private fun computeXCTestRunner(enforcedProperties: EnforcedProperties) = XCTestRunner(
+        ClassLevelProperty.XCTEST_FRAMEWORK.readValue(
+            enforcedProperties,
+            { File(it).absolutePath },
+            default = ""
+        )
+    )
+
     /*************** Test class settings (for black box tests only) ***************/
 
     private fun ExtensionContext.getOrCreateTestClassSettings(): TestClassSettings =
@@ -422,7 +431,7 @@
     private fun computeGeneratedSourceDirs(
         baseDirs: BaseDirs,
         targets: KotlinNativeTargets,
-        enclosingTestClass: Class<*>
+        enclosingTestClass: Class<*>,
     ): GeneratedSources {
         val testSourcesDir = baseDirs.testBuildDir
             .resolve("bb.src") // "bb" for black box
@@ -440,7 +449,7 @@
     private fun computeBinariesForBlackBoxTests(
         baseDirs: BaseDirs,
         targets: KotlinNativeTargets,
-        enclosingTestClass: Class<*>
+        enclosingTestClass: Class<*>,
     ): Binaries {
         val testBinariesDir = baseDirs.testBuildDir
             .resolve("bb.out") // "bb" for black box
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilation.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilation.kt
index be25914..ab19a66 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilation.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilation.kt
@@ -479,6 +479,108 @@
     }
 }
 
+internal class TestBundleCompilation(
+    val settings: Settings,
+    freeCompilerArgs: TestCompilerArgs,
+    sourceModules: Collection<TestModule>,
+    private val extras: Extras,
+    dependencies: Iterable<TestCompilationDependency<*>>,
+    expectedArtifact: XCTestBundle,
+    private val tryPassSystemCacheDirectory: Boolean = true,
+) : SourceBasedCompilation<XCTestBundle>(
+    targets = settings.get(),
+    home = settings.get(),
+    classLoader = settings.get(),
+    optimizationMode = settings.get(),
+    compilerOutputInterceptor = settings.get(),
+    threadStateChecker = settings.get(),
+    sanitizer = settings.get(),
+    gcType = settings.get(),
+    gcScheduler = settings.get(),
+    allocator = settings.get(),
+    pipelineType = settings.getStageDependentPipelineType(),
+    freeCompilerArgs = freeCompilerArgs,
+    compilerPlugins = settings.get(),
+    sourceModules = sourceModules,
+    dependencies = CategorizedDependencies(dependencies),
+    expectedArtifact = expectedArtifact
+) {
+    // TODO: Enabling caches lead to link failure
+    //  Undefined symbols for architecture x86_64:
+    //    "_dns_class_number", referenced from:
+    //        _platform_darwin_dns_class_number_wrapper379 in liborg.jetbrains.kotlin.native.platform.darwin-cache.a(result.o)
+    private val cacheMode: CacheMode = CacheMode.WithoutCache // settings.get()
+    override val binaryOptions = BinaryOptions.RuntimeAssertionsMode.chooseFor(cacheMode)
+
+    private val partialLinkageConfig: UsedPartialLinkageConfig = settings.get()
+
+    override fun applySpecificArgs(argsBuilder: ArgsBuilder): Unit = with(argsBuilder) {
+        add(
+            "-produce", "test_bundle",
+            "-linker-option", "-F" + settings.get<XCTestRunner>().frameworksPath,
+            "-output", expectedArtifact.bundleDir.path
+        )
+        when (extras) {
+            is NoTestRunnerExtras -> error("It doesn't suit")
+            is WithTestRunnerExtras -> {
+                val testDumpFile: File? = if (sourceModules.isEmpty()
+                    && dependencies.includedLibraries.isNotEmpty()
+                    && cacheMode.useStaticCacheForUserLibraries
+                ) {
+                    // If there are no source modules passed to the compiler, but there is an included library with the static cache, then
+                    // this should be two-stage test mode: Test functions are already stored in the included library, and they should
+                    // already have been dumped during generation of library's static cache.
+                    null // No, don't need to dump tests.
+                } else {
+                    expectedArtifact.testDumpFile // Yes, need to dump tests.
+                }
+                applyTestRunnerSpecificArgs(extras, testDumpFile)
+            }
+        }
+        applyPartialLinkageArgs(partialLinkageConfig)
+        super.applySpecificArgs(argsBuilder)
+    }
+
+    override fun applyDependencies(argsBuilder: ArgsBuilder): Unit = with(argsBuilder) {
+        super.applyDependencies(argsBuilder)
+        cacheMode.staticCacheForDistributionLibrariesRootDir
+            ?.takeIf { tryPassSystemCacheDirectory }
+            ?.let { cacheRootDir -> add("-Xcache-directory=$cacheRootDir") }
+        add(dependencies.uniqueCacheDirs) { libraryCacheDir -> "-Xcache-directory=${libraryCacheDir.path}" }
+    }
+
+    override fun postCompileCheck() {
+        expectedArtifact.assertTestDumpFileNotEmptyIfExists()
+    }
+
+    companion object {
+        internal fun ArgsBuilder.applyTestRunnerSpecificArgs(extras: WithTestRunnerExtras, testDumpFile: File?) {
+            val testRunnerArg = when (extras.runnerType) {
+                TestRunnerType.DEFAULT -> "-generate-test-runner"
+                TestRunnerType.WORKER, TestRunnerType.NO_EXIT -> error("Those runners don't work here")
+            }
+            add(testRunnerArg)
+            testDumpFile?.let { add("-Xdump-tests-to=$it") }
+        }
+
+        internal fun XCTestBundle.assertTestDumpFileNotEmptyIfExists() {
+            if (testDumpFile.exists()) {
+                testDumpFile.useLines { lines ->
+                    assertTrue(lines.filter(String::isNotBlank).any()) { "Test dump file is empty: $testDumpFile" }
+                }
+            }
+        }
+
+        internal fun ArgsBuilder.applyPartialLinkageArgs(partialLinkageConfig: UsedPartialLinkageConfig) {
+            with(partialLinkageConfig.config) {
+                add("-Xpartial-linkage=${mode.name.lowercase()}")
+                if (mode.isEnabled)
+                    add("-Xpartial-linkage-loglevel=${logLevel.name.lowercase()}")
+            }
+        }
+    }
+}
+
 internal class CategorizedDependencies(uncategorizedDependencies: Iterable<TestCompilationDependency<*>>) {
     val failures: Set<TestCompilationResult.Failure> by lazy {
         uncategorizedDependencies.flatMapToSet { dependency ->
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationArtifact.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationArtifact.kt
index d20658d..2c55abf 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationArtifact.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationArtifact.kt
@@ -6,6 +6,7 @@
 package org.jetbrains.kotlin.konan.blackboxtest.support.compilation
 
 import java.io.File
+import java.nio.file.*
 
 internal sealed interface TestCompilationArtifact {
     val logFile: File
@@ -28,7 +29,12 @@
     data class ObjCFramework(private val buildDir: File, val frameworkName: String) : TestCompilationArtifact {
         val frameworkDir: File get() = buildDir.resolve("$frameworkName.framework")
         override val logFile: File get() = frameworkDir.resolveSibling("${frameworkDir.name}.log")
-        val headersDir: File get () = frameworkDir.resolve("Headers")
+        val headersDir: File get() = frameworkDir.resolve("Headers")
         val mainHeader: File get() = headersDir.resolve("$frameworkName.h")
     }
+
+    data class XCTestBundle(val bundleDir: File) : TestCompilationArtifact {
+        override val logFile: File get() = bundleDir.resolveSibling("${bundleDir.name}.log")
+        val testDumpFile: File get() = bundleDir.resolveSibling("${bundleDir.name}.dump")
+    }
 }
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationFactory.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationFactory.kt
index 1e1a8c3..7a52621 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationFactory.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/compilation/TestCompilationFactory.kt
@@ -18,6 +18,7 @@
 import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationDependencyType.*
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.*
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.*
+import org.jetbrains.kotlin.konan.target.CompilerOutputKind
 import org.jetbrains.kotlin.test.services.JUnit5Assertions.assertTrue
 import org.jetbrains.kotlin.utils.addIfNotNull
 import java.io.File
@@ -26,10 +27,12 @@
     private val cachedKlibCompilations = ThreadSafeCache<KlibCacheKey, KlibCompilations>()
     private val cachedExecutableCompilations = ThreadSafeCache<ExecutableCacheKey, TestCompilation<Executable>>()
     private val cachedObjCFrameworkCompilations = ThreadSafeCache<ObjCFrameworkCacheKey, ObjCFrameworkCompilation>()
+    private val cachedTestBundleCompilations = ThreadSafeCache<TestBundleCacheKey, TestBundleCompilation>()
 
     private data class KlibCacheKey(val sourceModules: Set<TestModule>, val freeCompilerArgs: TestCompilerArgs)
     private data class ExecutableCacheKey(val sourceModules: Set<TestModule>)
     private data class ObjCFrameworkCacheKey(val sourceModules: Set<TestModule>)
+    private data class TestBundleCacheKey(val sourceModules: Set<TestModule>)
 
     // A pair of compilations for a KLIB itself and for its static cache that are created together.
     private data class KlibCompilations(val klib: TestCompilation<KLIB>, val staticCache: TestCompilation<KLIBStaticCache>?)
@@ -138,6 +141,39 @@
         }
     }
 
+    fun testCasesToTestBundle(testCases: Collection<TestCase>, settings: Settings): TestCompilation<XCTestBundle> {
+        val rootModules = testCases.flatMapToSet { testCase -> testCase.rootModules }
+        val cacheKey = TestBundleCacheKey(rootModules)
+
+        // Fast pass.
+        cachedTestBundleCompilations[cacheKey]?.let { return it }
+
+        // Long pass.
+        val freeCompilerArgs = rootModules.first().testCase.freeCompilerArgs // Should be identical inside the same test case group.
+        val extras = testCases.first().extras // Should be identical inside the same test case group.
+        val executableArtifact = XCTestBundle(settings.artifactFileForXCTestBundle(rootModules))
+
+        val (
+            dependenciesToCompileExecutable: Iterable<CompiledDependency<*>>,
+            sourceModulesToCompileExecutable: Set<TestModule.Exclusive>
+        ) = getDependenciesAndSourceModules(settings, rootModules, freeCompilerArgs) {
+            // TODO: should also include caches but the test compilation architecture is not configurable enough
+            //  to de-duplicate code used for executable and other compiler outputs
+            ProduceStaticCache.No
+        }
+
+        return cachedTestBundleCompilations.computeIfAbsent(cacheKey) {
+            TestBundleCompilation(
+                settings = settings,
+                freeCompilerArgs = freeCompilerArgs,
+                sourceModules = sourceModulesToCompileExecutable,
+                extras = extras,
+                dependencies = dependenciesToCompileExecutable,
+                expectedArtifact = executableArtifact
+            )
+        }
+    }
+
     private fun getDependenciesAndSourceModules(
         settings: Settings,
         rootModules: Set<TestModule.Exclusive>,
@@ -267,6 +303,18 @@
         private fun Settings.artifactFileForExecutable(module: TestModule.Exclusive) =
             singleModuleArtifactFile(module, get<KotlinNativeTargets>().testTarget.family.exeSuffix)
 
+        private fun Settings.artifactFileForXCTestBundle(modules: Set<TestModule.Exclusive>) = when (modules.size) {
+            1 -> artifactFileForXCTestBundle(modules.first())
+            else -> multiModuleArtifactFile(modules, xctestExtension())
+        }
+
+        private fun Settings.artifactFileForXCTestBundle(module: TestModule.Exclusive) =
+            singleModuleArtifactFile(module, xctestExtension())
+
+        private fun Settings.xctestExtension(): String = CompilerOutputKind.TEST_BUNDLE
+            .suffix(get<KotlinNativeTargets>().testTarget)
+            .substringAfterLast(".")
+
         private fun Settings.artifactFileForKlib(modules: Set<TestModule>, freeCompilerArgs: TestCompilerArgs): File =
             when (modules.size) {
                 1 -> when (val module = modules.first()) {
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/group/PredefinedTestCaseGroupProvider.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/group/PredefinedTestCaseGroupProvider.kt
index 8a196d5..902f7f1 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/group/PredefinedTestCaseGroupProvider.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/group/PredefinedTestCaseGroupProvider.kt
@@ -8,12 +8,14 @@
 import org.jetbrains.kotlin.konan.blackboxtest.support.*
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestCase.WithTestRunnerExtras
 import org.jetbrains.kotlin.konan.blackboxtest.support.runner.TestRunChecks
+import org.jetbrains.kotlin.konan.blackboxtest.support.settings.CustomKlibs
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.KotlinNativeHome
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.Settings
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.Timeouts
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.ThreadSafeCache
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.expandGlobTo
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.getAbsoluteFile
+import org.jetbrains.kotlin.konan.blackboxtest.support.util.mapToSet
 import org.jetbrains.kotlin.test.services.JUnit5Assertions.assertTrue
 import org.jetbrains.kotlin.test.services.JUnit5Assertions.fail
 import java.io.File
@@ -72,7 +74,10 @@
                     ignoredTests = predefinedTestCase.ignoredTests.toSet()
                 )
             )
-            testCase.initialize(null, null)
+            testCase.initialize(
+                givenModules = settings.get<CustomKlibs>().klibs.mapToSet(TestModule::Given),
+                findSharedModule = null
+            )
 
             TestCaseGroup.Default(disabledTestCaseIds = emptySet(), testCases = listOf(testCase))
         }
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/RunnerWithExecutor.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/RunnerWithExecutor.kt
index 7ee2f9f..8a88780 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/RunnerWithExecutor.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/RunnerWithExecutor.kt
@@ -42,6 +42,7 @@
         val request = ExecuteRequest(
             executableAbsolutePath = executable.executableFile.absolutePath,
             args = programArgs,
+            workingDirectory = executable.executableFile.parentFile,
             stdin = stdin,
             stdout = stdout,
             stderr = stderr,
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunProvider.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunProvider.kt
index b232954..d37d7f8 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunProvider.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunProvider.kt
@@ -11,11 +11,14 @@
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestCase.NoTestRunnerExtras
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestCase.WithTestRunnerExtras
 import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilation
+import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationArtifact
 import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationArtifact.Executable
 import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationFactory
+import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationResult
 import org.jetbrains.kotlin.konan.blackboxtest.support.compilation.TestCompilationResult.Companion.assertSuccess
 import org.jetbrains.kotlin.konan.blackboxtest.support.group.TestCaseGroupProvider
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.Settings
+import org.jetbrains.kotlin.konan.blackboxtest.support.settings.XCTestRunner
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.ThreadSafeCache
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.TreeNode
 import org.jetbrains.kotlin.konan.blackboxtest.support.util.buildTree
@@ -33,14 +36,16 @@
 ) : BaseTestRunProvider(), ExtensionContext.Store.CloseableResource {
     private val compilationFactory = TestCompilationFactory()
     private val cachedCompilations = ThreadSafeCache<TestCompilationCacheKey, TestCompilation<Executable>>()
+    private val cachedXCTestCompilations = ThreadSafeCache<TestCompilationCacheKey, TestCompilation<TestCompilationArtifact.XCTestBundle>>()
 
     /**
      * Produces a single [TestRun] per [TestCase]. So-called "one test case/one test run" mode.
      *
      * If [TestCase] contains multiple functions annotated with [kotlin.test.Test], then all these functions will be executed
-     * in one shot. If either function will fail, the whole JUnit test will be considered as failed.
+     * in one shot. If either function fails, the whole JUnit test will be considered as failed.
      *
      * Example:
+     * ```
      *   //+++ testData file (foo.kt): +++//
      *   @kotlin.test.Test
      *   fun one() { /* ... */ }
@@ -58,11 +63,12 @@
      *           // If either of test functions fails, the whole "testFoo()" JUnit test is marked as failed.
      *       }
      *   }
+     * ```
      */
     fun getSingleTestRun(
         testCaseId: TestCaseId,
         settings: Settings
-    ): TestRun = withTestExecutable(testCaseId, settings) { testCase, executable ->
+    ): TestRun = withTestSettingsDispatched(testCaseId, settings) { testCase, executable ->
         createSingleTestRun(testCase, executable)
     }
 
@@ -72,10 +78,11 @@
      * If [TestCase] contains multiple functions annotated with [kotlin.test.Test], then a separate [TestRun] will be produced
      * for each such function.
      *
-     * This allows to have a better granularity in tests. So that every individual test method inside [TestCase] will be considered
+     * This allows having a better granularity in tests. So that every test method inside [TestCase] will be considered
      * as an individual JUnit test, and will be presented as a separate row in JUnit test report.
      *
      * Example:
+     * ```
      *   //+++ testData file (foo.kt): +++//
      *   @kotlin.test.Test
      *   fun one() { /* ... */ }
@@ -95,11 +102,12 @@
      *           // in the test report, and "testFoo.two" will be presented as passed.
      *       }
      *   }
+     * ```
      */
     fun getTestRuns(
         testCaseId: TestCaseId,
         settings: Settings
-    ): Collection<TreeNode<TestRun>> = withTestExecutable(testCaseId, settings) { testCase, executable ->
+    ): Collection<TreeNode<TestRun>> = withTestSettingsDispatched(testCaseId, settings) { testCase, executable ->
         fun createTestRun(testRunName: String, testName: TestName?) = createTestRun(testCase, executable, testRunName, testName)
 
         when (testCase.kind) {
@@ -117,6 +125,18 @@
         }
     }
 
+    private fun <T> withTestSettingsDispatched(
+        testCaseId: TestCaseId,
+        settings: Settings,
+        action: (TestCase, TestExecutable) -> T
+    ): T {
+        return if (settings.get<XCTestRunner>().isEnabled) {
+            withTestBundle(testCaseId, settings, action)
+        } else {
+            withTestExecutable(testCaseId, settings, action)
+        }
+    }
+
     private fun <T> withTestExecutable(
         testCaseId: TestCaseId,
         settings: Settings,
@@ -162,6 +182,54 @@
         return action(testCase, executable)
     }
 
+    private fun <T> withTestBundle(
+        testCaseId: TestCaseId,
+        settings: Settings,
+        action: (TestCase, TestExecutable) -> T
+    ): T {
+        val testCaseGroup = testCaseGroupProvider.getTestCaseGroup(testCaseId.testCaseGroupId, settings)
+            ?: fail { "No test case for $testCaseId" }
+
+        assumeTrue(testCaseGroup.isEnabled(testCaseId), "Test case is disabled")
+
+        val testCase = testCaseGroup.getByName(testCaseId) ?: fail { "No test case for $testCaseId" }
+
+        val testCompilation = when (testCase.kind) {
+            TestKind.STANDALONE -> {
+                // Create a separate compilation for each standalone test case.
+                cachedXCTestCompilations.computeIfAbsent(
+                    TestCompilationCacheKey.Standalone(testCaseId)
+                ) {
+                    compilationFactory.testCasesToTestBundle(listOf(testCase), settings)
+                }
+            }
+            TestKind.REGULAR -> {
+                // Group regular test cases by compiler arguments.
+                val testRunnerType = testCase.extras<WithTestRunnerExtras>().runnerType
+                cachedXCTestCompilations.computeIfAbsent(
+                    TestCompilationCacheKey.Grouped(
+                        testCaseGroupId = testCaseId.testCaseGroupId,
+                        freeCompilerArgs = testCase.freeCompilerArgs,
+                        sharedModules = testCase.sharedModules,
+                        runnerType = testRunnerType
+                    )
+                ) {
+                    val testCases = testCaseGroup.getRegularOnly(testCase.freeCompilerArgs, testCase.sharedModules, testRunnerType)
+                    assertTrue(testCases.isNotEmpty())
+                    compilationFactory.testCasesToTestBundle(testCases, settings)
+                }
+            }
+            else -> error("Test kind ${testCase.kind} is not supported yet in XCTest runner")
+        }
+
+        val compilationResult = testCompilation.result.assertSuccess() // <-- Compilation happens here.
+        // FIXME: temp adapter.
+        val adapter = TestCompilationResult.Success(Executable(compilationResult.resultingArtifact.bundleDir), compilationResult.loggedData)
+        val executable = TestExecutable.fromCompilationResult(testCase, adapter)
+
+        return action(testCase, executable)
+    }
+
     private fun Collection<TestName>.filterIrrelevant(testCase: TestCase) =
         if (testCase.kind == TestKind.REGULAR)
             filter { testName -> testName.packageName.startsWith(testCase.nominalPackageName) }
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunners.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunners.kt
index 55301d5..060b56f 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunners.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/runner/TestRunners.kt
@@ -6,16 +6,14 @@
 package org.jetbrains.kotlin.konan.blackboxtest.support.runner
 
 import org.jetbrains.kotlin.konan.blackboxtest.support.TestName
+import org.jetbrains.kotlin.konan.blackboxtest.support.settings.*
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.ForcedNoopTestRunner
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.KotlinNativeHome
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.KotlinNativeTargets
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.Settings
 import org.jetbrains.kotlin.konan.blackboxtest.support.settings.Timeouts
-import org.jetbrains.kotlin.native.executors.Executor
-import org.jetbrains.kotlin.native.executors.EmulatorExecutor
-import org.jetbrains.kotlin.native.executors.XcodeSimulatorExecutor
 import org.jetbrains.kotlin.konan.target.*
-import org.jetbrains.kotlin.native.executors.RosettaExecutor
+import org.jetbrains.kotlin.native.executors.*
 import org.jetbrains.kotlin.test.services.JUnit5Assertions.fail
 import java.util.concurrent.ConcurrentHashMap
 
@@ -24,13 +22,26 @@
         if (get<ForcedNoopTestRunner>().value) {
             NoopTestRunner
         } else with(get<KotlinNativeTargets>()) {
-            if (testTarget == hostTarget) {
+            val nativeHome = get<KotlinNativeHome>()
+            val distribution = Distribution(nativeHome.dir.path)
+            val configurables = PlatformManager(distribution, true).platform(testTarget).configurables
+
+            if (get<XCTestRunner>().isEnabled) {
+                // Forcibly run tests with XCTest
+                check(configurables is AppleConfigurables) {
+                    "Running tests with XCTest is not supported on non-Apple $configurables"
+                }
+                val executor = cached(
+                    if (testTarget == hostTarget) {
+                        XCTestHostExecutor(configurables)
+                    } else {
+                        XCTestSimulatorExecutor(configurables)
+                    }
+                )
+                RunnerWithExecutor(executor, testRun)
+            } else if (testTarget == hostTarget) {
                 LocalTestRunner(testRun)
             } else {
-                val nativeHome = get<KotlinNativeHome>()
-                val distribution = Distribution(nativeHome.dir.path)
-                val configurables = PlatformManager(distribution, true).platform(testTarget).configurables
-
                 val executor = cached(
                     when {
                         configurables is ConfigurablesWithEmulator -> EmulatorExecutor(configurables)
diff --git a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/settings/TestProcessSettings.kt b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/settings/TestProcessSettings.kt
index aa5a8d4..d639041 100644
--- a/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/settings/TestProcessSettings.kt
+++ b/native/native.tests/tests/org/jetbrains/kotlin/konan/blackboxtest/support/settings/TestProcessSettings.kt
@@ -290,3 +290,12 @@
     DEFAULT,
     NONE
 }
+
+/**
+ * XCTestRunner setting.
+ *
+ * @param frameworksPath is a linker path to the developer frameworks location.
+ */
+internal class XCTestRunner(val frameworksPath: String) {
+    val isEnabled: Boolean = frameworksPath.isNotEmpty()
+}
diff --git a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts
index 3fd30e4..8e362e3 100644
--- a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts
+++ b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts
@@ -138,10 +138,13 @@
                 }
 
             // Workaround to avoid remote build cache misses due to absolute paths in relativePathBaseArg
-            doFirst {
-                if (relativePathBaseArg != null) {
-                    @Suppress("DEPRECATION")
-                    kotlinOptions.freeCompilerArgs += relativePathBaseArg
+            if (project.path != ":kotlin-native:utilities:xctest-runner") {
+                // In xctest-runner project
+                doFirst {
+                    if (relativePathBaseArg != null) {
+                        @Suppress("DEPRECATION")
+                        kotlinOptions.freeCompilerArgs += relativePathBaseArg
+                    }
                 }
             }
         }
diff --git a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/nativeTest.kt b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/nativeTest.kt
index 379b6e2..17f0404 100644
--- a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/nativeTest.kt
+++ b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/nativeTest.kt
@@ -6,7 +6,7 @@
 import org.gradle.kotlin.dsl.project
 import java.io.File
 
-private enum class TestProperty(shortName: String) {
+enum class TestProperty(shortName: String) {
     // Use a separate Gradle property to pass Kotlin/Native home to tests: "kotlin.internal.native.test.nativeHome".
     // Don't use "kotlin.native.home" and similar properties for this purpose, as these properties may have undesired
     // effect on other Gradle tasks (ex: :kotlin-native:dist) that might be executed along with test task.
@@ -26,7 +26,8 @@
     CACHE_MODE("cacheMode"),
     EXECUTION_TIMEOUT("executionTimeout"),
     SANITIZER("sanitizer"),
-    TEAMCITY("teamcity");
+    TEAMCITY("teamcity"),
+    XCTEST_FRAMEWORK("xctest");
 
     val fullName = "kotlin.internal.native.test.$shortName"
 }
@@ -190,6 +191,7 @@
             compute(CACHE_MODE)
             compute(EXECUTION_TIMEOUT)
             compute(SANITIZER)
+            compute(XCTEST_FRAMEWORK)
 
             // Pass whether tests are running at TeamCity.
             computePrivate(TEAMCITY) { kotlinBuildProperties.isTeamcityBuild.toString() }
diff --git a/settings.gradle b/settings.gradle
index fcb0827..2ab3216 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -850,6 +850,7 @@
     include ':kotlin-native:Interop:Skia'
     include ':kotlin-native:utilities:basic-utils'
     include ':kotlin-native:utilities:cli-runner'
+    include ':kotlin-native:utilities:xctest-runner'
     include ':kotlin-native:klib'
     include ':kotlin-native:common'
     include ':kotlin-native:runtime'