From 32050b0c36c9b6e40899b07e7d6a8e7de1fcbb8f Mon Sep 17 00:00:00 2001 From: Sebastian Sellmair Date: Fri, 27 Sep 2024 09:19:18 +0200 Subject: [PATCH] [kotlin][test] Implement KotlinPluginUnitTest.kt GitOrigin-RevId: 1604c3aee6388889fb3c2b1ecdd6f65de07c60dc --- .../code-insight/kotlin.base.code-insight.iml | 1 + .../kotlin/idea/test/KotlinPluginUnitTest.kt | 288 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 plugins/kotlin/tests-common/test/org/jetbrains/kotlin/idea/test/KotlinPluginUnitTest.kt diff --git a/plugins/kotlin/base/code-insight/kotlin.base.code-insight.iml b/plugins/kotlin/base/code-insight/kotlin.base.code-insight.iml index f2d71155d498..2de13d6f4733 100644 --- a/plugins/kotlin/base/code-insight/kotlin.base.code-insight.iml +++ b/plugins/kotlin/base/code-insight/kotlin.base.code-insight.iml @@ -59,5 +59,6 @@ + \ No newline at end of file diff --git a/plugins/kotlin/tests-common/test/org/jetbrains/kotlin/idea/test/KotlinPluginUnitTest.kt b/plugins/kotlin/tests-common/test/org/jetbrains/kotlin/idea/test/KotlinPluginUnitTest.kt new file mode 100644 index 000000000000..305f2656e3e5 --- /dev/null +++ b/plugins/kotlin/tests-common/test/org/jetbrains/kotlin/idea/test/KotlinPluginUnitTest.kt @@ -0,0 +1,288 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.kotlin.idea.test + +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.JavaCodeInsightTestFixture +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase +import com.intellij.testFramework.fixtures.LightJavaCodeInsightFixtureTestCase5 +import org.jetbrains.kotlin.idea.base.plugin.KotlinPluginMode +import org.jetbrains.kotlin.idea.base.plugin.useK2Plugin +import org.junit.jupiter.api.TestTemplate +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback +import org.junit.jupiter.api.extension.ConditionEvaluationResult +import org.junit.jupiter.api.extension.ExecutionCondition +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extension +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.ParameterContext +import org.junit.jupiter.api.extension.ParameterResolver +import org.junit.jupiter.api.extension.TestTemplateInvocationContext +import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider +import org.junit.jupiter.params.provider.EnumSource +import org.junit.platform.commons.util.AnnotationUtils +import java.util.stream.Stream +import kotlin.jvm.optionals.getOrNull + +/** + * # Simple junit5 based test method intended for writing unit tests + * + * ## When to use + * This test fixture can be used for + * - testing small utility functions + * - testing simple PSI-based code + * - testing small analysis-api based code + * + * for anything larger, please use test-data-based tests and generate K1 & K2 variants. + * + * ## K1 vs. K2 + * This test fixture is intended to write quick unit tests and therefore does not + * require splitting the tests into `AbstractXyzTest`, `K1XyzTest` and `K2XyzTest`. + * However, the tests still require to be executed in the context of the Kotlin Plugin. + * K1: `kotlin.all-tests` + * K2: `kotlin.fir-all-tests` + * + * The test will detect in which context it runs. + * If the test shall only exist in either K1 or K2, providing the desired modes is possible + * ```kotlin + * class MyTest { + * @KotlinPluginUnitTest(KotlinPluginMode.K1) // <- only contributes the test in K1 environments + * fun onlyK1() { + * + * } + * + * @KotlinPluginUnitTest(KotlinPluginMode.K2) // <- only contributes the test in K2 environments + * fun onlyK2() { + * + * } + * } + * ``` + * + * ## Running from IDEA + * Since this test is expected to run in the runtime classpath of either K1 or K2, it is expected + * to select a proper classpath (cp) for run configurations. + * + * - K1: `kotlin.all-tests` + * - K2: `kotlin.fir-all-tests` + * + * Tip: When executing such tests often, it might be helpful to select the desired cp in the Junit run configuration template. + * + * ## Samples + * ### Test with source code + * ```kotlin + * class MyTest { + * @KotlinPluginUnitTest + * fun testWithSourceCode(project: Project) { + * val ktClass = KtPsiFactory(project).createClass("class A") + * // ... + * } + * } + * ``` + * + * ### Test with known [KotlinPluginMode] + * ```kotlin + * class MyTest { + * @KotlinPluginUnitTest + * fun test(mode: KotlinPluginMode) { + * // ... + * } + * } + * ``` + * + * ### Test with [JavaCodeInsightTestFixture] + * ```kotlin + * class MyTest { + * @KotlinPluginUnitTest + * fun test(fixture: JavaCodeInsightTestFixture) { + * + * } + * } + * ``` + */ + +@TestTemplate +@ExtendWith(KotlinPluginUnitTestExtension::class) +@EnumSource(KotlinPluginMode::class) +annotation class KotlinPluginUnitTest(vararg val modes: KotlinPluginMode = [KotlinPluginMode.K1, KotlinPluginMode.K2]) + +internal class KotlinPluginUnitTestExtension : TestTemplateInvocationContextProvider { + override fun supportsTestTemplate(context: ExtensionContext): Boolean { + if (context.testMethod.isEmpty) return false + val testMethod = context.testMethod.get() + return AnnotationUtils.isAnnotated(testMethod, KotlinPluginUnitTest::class.java) + } + + override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream { + val testMethod = context.testMethod.getOrNull() ?: return Stream.empty() + val annotation = testMethod.getAnnotation(KotlinPluginUnitTest::class.java) ?: return Stream.empty() + + val k1PluginModeProviderClassName = "org.jetbrains.kotlin.idea.base.fe10.plugin.K1KotlinPluginModeProvider" + val k2PluginModeProviderClassName = "org.jetbrains.kotlin.idea.fir.plugin.K2KotlinPluginModeProvider" + + val k1ProviderClassOrNull = javaClass.classLoader.loadClassOrNull(k1PluginModeProviderClassName) + val k2ProviderClassOrNull = javaClass.classLoader.loadClassOrNull(k2PluginModeProviderClassName) + + if (k1ProviderClassOrNull != null && k2ProviderClassOrNull != null) { + thisLogger().warn("Detected K1 & K2 plugin at runtime: Use 'kotlin.all-tests' or 'kotlin.fir-all-tests' as classpath") + } + + if (k2ProviderClassOrNull != null) { + return if (annotation.modes.contains(KotlinPluginMode.K2)) { + Stream.of(KotlinPluginUnitTestInvocationContext(KotlinPluginMode.K2)) + } else Stream.of(IgnoredKotlinPluginUnitTestInvocationContext(KotlinPluginMode.K2)) + } + + if (k1ProviderClassOrNull != null) { + return if (annotation.modes.contains(KotlinPluginMode.K1)) { + Stream.of(KotlinPluginUnitTestInvocationContext(KotlinPluginMode.K1)) + } else Stream.of(IgnoredKotlinPluginUnitTestInvocationContext(KotlinPluginMode.K1)) + } + + error("Missing Kotlin Plugin: Use 'kotlin.all-tests' or 'kotlin.fir-all-tests' as classpath") + } +} + +private fun ClassLoader.loadClassOrNull(name: String): Class<*>? { + return try { + loadClass(name) + } catch (_: ClassNotFoundException) { + null + } +} + +private class KotlinPluginUnitTestInvocationContext( + val mode: KotlinPluginMode +) : TestTemplateInvocationContext { + override fun getDisplayName(invocationIndex: Int): String? { + return "KotlinPluginMode ${mode.name}" + } + + override fun getAdditionalExtensions(): List? { + return return listOf( + KotlinPluginModeParameterResolver(mode), + KotlinPluginSetup(mode), + LightJavaCodeInsightTestFixtureExtension5() + ) + } +} + + +private class IgnoredKotlinPluginUnitTestInvocationContext( + val mode: KotlinPluginMode +) : TestTemplateInvocationContext { + + inner class Ignored : ExecutionCondition { + override fun evaluateExecutionCondition(context: ExtensionContext?): ConditionEvaluationResult? { + return ConditionEvaluationResult.disabled("Not enabled for KotlinPluginMode.$mode") + } + } + + override fun getDisplayName(invocationIndex: Int): String? { + return "KotlinPluginMode: ${mode.name} | Ignored" + } + + override fun getAdditionalExtensions(): List? { + return return listOf( + Ignored(), + KotlinPluginModeParameterResolver(mode), + KotlinPluginSetup(mode), + LightJavaCodeInsightTestFixtureExtension5() + ) + } +} + + +private class KotlinPluginModeParameterResolver( + private val mode: KotlinPluginMode +) : ParameterResolver { + override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean { + return parameterContext.parameter.type == KotlinPluginMode::class.java + } + + override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any? { + if (parameterContext.parameter.type != KotlinPluginMode::class.java) return null + return mode + } +} + +private class KotlinPluginSetup(override val pluginMode: KotlinPluginMode) : + BeforeEachCallback, AfterEachCallback, + BeforeTestExecutionCallback, ExpectedPluginModeProvider { + private val namespace = ExtensionContext.Namespace.create(KotlinPluginSetup::class.java) + + override fun beforeEach(context: ExtensionContext) { + context.getStore(namespace).getOrComputeIfAbsent("pluginMode") { useK2Plugin } + useK2Plugin = pluginMode == KotlinPluginMode.K2 + } + + override fun afterEach(context: ExtensionContext) { + useK2Plugin = context.getStore(namespace).get("pluginMode") as Boolean? + } + + override fun beforeTestExecution(context: ExtensionContext) { + assertKotlinPluginMode() + } +} + +private class LightJavaCodeInsightTestFixtureExtension5 : BeforeEachCallback, AfterEachCallback, ParameterResolver { + + private val namespace = ExtensionContext.Namespace.create(LightJavaCodeInsightFixtureTestCase5::class.java) + + @Suppress("JUnitMalformedDeclaration") // 'private class' is OK as this is just a wrapper and not intended to be executed + private class LightJavaCodeInsightFixtureTestCaseWrapper : LightJavaCodeInsightFixtureTestCase() { + val fixture get() = myFixture + + fun beforeEach() { + setUp() + } + + fun afterEach() { + tearDown() + } + } + + private fun ExtensionContext.getTestCaseWrapper(): LightJavaCodeInsightFixtureTestCaseWrapper { + return getStore(namespace).getOrComputeIfAbsent(LightJavaCodeInsightFixtureTestCaseWrapper::class.java.name) { + LightJavaCodeInsightFixtureTestCaseWrapper() + } as LightJavaCodeInsightFixtureTestCaseWrapper + } + + override fun beforeEach(context: ExtensionContext) { + context.getTestCaseWrapper().beforeEach() + } + + override fun afterEach(context: ExtensionContext) { + context.getTestCaseWrapper().afterEach() + } + + override fun supportsParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Boolean { + return parameterContext.parameter.type == JavaCodeInsightTestFixture::class.java || + parameterContext.parameter.type == CodeInsightTestFixture::class.java || + parameterContext.parameter.type == Project::class.java || + parameterContext.parameter.type == KotlinPluginMode::class.java + } + + override fun resolveParameter( + parameterContext: ParameterContext, + extensionContext: ExtensionContext + ): Any? { + if (parameterContext.parameter.type == JavaCodeInsightTestFixture::class.java || + parameterContext.parameter.type == CodeInsightTestFixture::class.java + ) { + val testCaseWrapper = extensionContext.getTestCaseWrapper() + return testCaseWrapper.fixture + } + + if (parameterContext.parameter.type == Project::class.java) { + return extensionContext.getTestCaseWrapper().fixture.project + } + + return null + } +}