[python][junit5] move PyDefaultTestApplication to the junit5Tests.framework module

Merge-request: IJ-MR-176009
Merged-by: Vitaly Legchilkin <Vitaly.Legchilkin@jetbrains.com>

GitOrigin-RevId: 4be35423f74375e1b37e0ea87fd6490a950060f6
This commit is contained in:
Vitaly Legchilkin
2025-09-20 13:14:32 +00:00
committed by intellij-monorepo-bot
parent 464bc08113
commit 0ce97b7649
15 changed files with 54 additions and 53 deletions
+5
View File
@@ -54,6 +54,11 @@ jvm_library(
"@lib//:python-community-junit5_tests-framework-uk-webcompere-system-stubs-jupiter",
"//platform/util/coroutines",
"@lib//:byte-buddy",
"//platform/testFramework",
"//platform/testFramework:testFramework_test_lib",
"//platform/editor-ui-api:editor-ui",
"//platform/editor-ui-ex:editor-ex",
"//platform/analysis-impl",
],
exports = [
"//python/setup-test-environment:community-testFramework-testEnv",
@@ -65,5 +65,9 @@
</orderEntry>
<orderEntry type="module" module-name="intellij.platform.util.coroutines" scope="TEST" />
<orderEntry type="library" scope="TEST" name="byte-buddy" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.editor.ui" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.editor.ex" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.analysis.impl" scope="TEST" />
</component>
</module>
@@ -0,0 +1,78 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.framework
import com.intellij.python.junit5Tests.framework.metaInfo.TestMetaInfoExtension.Companion.getTestClassInfo
import com.intellij.python.junit5Tests.framework.metaInfo.TestMetaInfoExtension.Companion.setTestCaseFilePath
import com.intellij.python.junit5Tests.framework.metaInfo.resolveTestName
import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.TestTemplate
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.TestTemplateInvocationContext
import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider
import java.nio.file.Files
import java.nio.file.Path
import java.util.stream.Stream
import kotlin.io.path.isDirectory
import kotlin.io.path.relativeTo
import kotlin.jvm.optionals.getOrNull
/**
* Test annotation that is used in conjunction with JUnit 5's [TestTemplate] to enable
* parameterized testing over files within a directory. Each test invocation corresponds
* to an individual file that matches the specified filter criteria.
*
* This annotation is processed by the [AllFilesInFolderTestCaseProvider] extension
*
* @param [fileNameFilter]: A string representing the regex pattern used to filter files
* in the folder for test execution. The default value is `FileNameFilter.ALL_FILES`,
* which means all files are included.
*/
@TestOnly
@TestTemplate
@ExtendWith(AllFilesInFolderTestCaseProvider::class)
annotation class FolderTest(val fileNameFilter: String = FileNameFilter.ALL_FILES)
@TestOnly
@Suppress("unused")
class FileNameFilter {
companion object {
const val ALL_FILES = """^.*$"""
const val PYTHON = """^.*\.(py)$"""
}
}
private class AllFilesInFolderTestCaseProvider : TestTemplateInvocationContextProvider {
private fun getTestFolderPath(context: ExtensionContext): Path {
val metaInfo = context.getTestClassInfo()
val testName = context.resolveTestName()
val testResourcePath = metaInfo.getTestResourcePath(fileName = testName)
return testResourcePath?.takeIf { it.isDirectory() }
?: error("Please make a folder with tests for \"${context.testMethod.getOrNull()?.name}\": ${metaInfo.testDataPath}/${testName}")
}
override fun supportsTestTemplate(context: ExtensionContext): Boolean {
return true
}
override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream<TestTemplateInvocationContext?>? {
val testFolderPath = getTestFolderPath(context)
val folderTest = context.testMethod?.getOrNull()?.getAnnotation(FolderTest::class.java)
?: error("there is no ${FolderTest::class.java} annotation, can't get file name pattern")
val fileNamePattern = folderTest.fileNameFilter.toRegex()
return Files.list(testFolderPath).filter { it.fileName.toString() matches fileNamePattern }.map { filePath ->
val path = filePath.relativeTo(testFolderPath.parent)
context.setTestCaseFilePath(path)
object : TestTemplateInvocationContext {
override fun getDisplayName(invocationIndex: Int): String? {
return "[$invocationIndex] ${filePath.fileName}"
}
}
}
}
}
@@ -0,0 +1,130 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.framework
import com.intellij.openapi.application.EDT
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiDirectory
import com.intellij.psi.PsiDocumentManager
import com.intellij.testFramework.EditorTestUtil
import com.intellij.testFramework.IndexingTestUtil
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.*
import com.intellij.testFramework.junit5.fixture.LookupFixtureExtension.Companion.getLookupFixtureManager
import com.intellij.testFramework.junit5.fixture.LookupFixtureExtension.Companion.registerImplicitFixtures
import com.jetbrains.python.PyNames
import com.intellij.python.junit5Tests.framework.metaInfo.TestMetaInfoExtension
import com.intellij.python.junit5Tests.framework.metaInfo.TestMetaInfoExtension.Companion.getTestClassInfo
import com.intellij.python.junit5Tests.framework.metaInfo.TestMetaInfoExtension.Companion.getTestMethodInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.jupiter.api.extension.*
import java.nio.file.Path
/**
* PyDefaultTestApplication is a test annotation used to initialize a shared application context
* and enrich it with various predefined test extensions (project, module, source root, PsiFile, editor, etc).
*
* It initializes a shared [com.intellij.openapi.application.Application] instance before any tests are run
* and disposes it after all tests finish, through the [TestApplication] annotation.
*/
@TestApplication
@ExtendWith(LookupFixtureExtension::class)
@ExtendWith(TestMetaInfoExtension::class)
@ExtendWith(PyWithDefaultFixturesExtension::class)
annotation class PyDefaultTestApplication
private const val DEFAULT_PROJECT: String = "DEFAULT_PROJECT"
private const val DEFAULT_PY_MODULE: String = "DEFAULT_PY_MODULE"
private const val DEFAULT_SOURCE_ROOT: String = "DEFAULT_SOURCE_ROOT"
private const val DEFAULT_EDITOR: String = "DEFAULT_EDITOR"
private class PyWithDefaultFixturesExtension : BeforeAllCallback, BeforeEachCallback, Extension {
/**
* Sets up the necessary fixtures and configurations before all tests in the given context. This involves:
* - Initializing necessary test project and module fixtures (reuses explicit fixtures if present).
* - Setting up project and source root fixtures (reuses explicit fixtures if present).
* - Registering all implicitly created fixtures.
* - Waiting until all indexes are fully ready for use.
*/
override fun beforeAll(context: ExtensionContext) {
val manager = context.getLookupFixtureManager()
val implicitFixtures = mutableListOf<LookupFixture>()
val project = manager.getOrDefault {
projectFixture(openAfterCreation = true).also {
implicitFixtures += LookupFixture(DEFAULT_PROJECT, it, true)
}
}
val module = manager.getOrDefault {
project.moduleFixture(name = context.uniqueId, moduleType = PyNames.PYTHON_MODULE_ID).also {
implicitFixtures += LookupFixture(DEFAULT_PY_MODULE, it, true)
}
}
manager.getOrDefault {
module.sourceRootFixture(
pathFixture = project.pathInProjectFixture(Path.of("")),
blueprintResourcePath = context.getTestClassInfo().testDataPath
).also {
implicitFixtures += LookupFixture(DEFAULT_SOURCE_ROOT, it, true)
}
}
runBlocking {
context.registerImplicitFixtures(implicitFixtures, static = true)
}
IndexingTestUtil.waitUntilIndexesAreReady(project.get())
}
/**
* Sets up the necessary fixtures and configurations for a test case before it is executed.
* This method initializes and registers implicit test fixtures (e.g., PSI file fixture, editor fixture),
* configures the editor caret and selection state, ensures documents are saved and committed,
* and waits for indexes to be ready.
*/
override fun beforeEach(context: ExtensionContext) {
val testMethodInfo = context.getTestMethodInfo()
val testCaseFilePath = testMethodInfo.testCaseFilePath ?: return
val implicitFixtures = mutableListOf<LookupFixture>()
val classLevelManager = context.parent.get().getLookupFixtureManager()
val sourceRoot: TestFixture<PsiDirectory> = classLevelManager.getRequired()
val psiFileFixture = sourceRoot.psiFileFixture(testCaseFilePath).also {
implicitFixtures += LookupFixture(testCaseFilePath.fileName.toString(), it, true)
}
val editorFixture = psiFileFixture.editorFixture().also {
implicitFixtures += LookupFixture(DEFAULT_EDITOR, it, true)
}
runBlocking {
context.registerImplicitFixtures(implicitFixtures, static = false)
}
val project = classLevelManager.getRequired<Project>().get()
runBlocking {
withContext(Dispatchers.EDT) {
val editor = editorFixture.get()
val document = editor.document
val caretState = EditorTestUtil.extractCaretAndSelectionMarkers(document)
EditorTestUtil.setCaretsAndSelection(editor, caretState)
FileDocumentManager.getInstance().saveDocument(document)
PsiDocumentManager.getInstance(project).commitAllDocuments()
}
}
IndexingTestUtil.waitUntilIndexesAreReady(project)
}
}
@@ -1,15 +1,20 @@
package com.intellij.python.junit5Tests.framework
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.readAction
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.vfs.resolveFromRootOrRelative
import com.intellij.platform.util.coroutines.childScope
import com.intellij.psi.PsiDirectory
import com.intellij.psi.PsiFile
import com.intellij.testFramework.junit5.fixture.TestFixture
import com.intellij.testFramework.junit5.fixture.testFixture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import org.jetbrains.annotations.TestOnly
import java.util.UUID
import java.nio.file.Path
import java.util.*
/**
@@ -26,3 +31,14 @@ fun applicationScope(name: String = UUID.randomUUID().toString()): TestFixture<C
}
}
@TestOnly
fun TestFixture<PsiDirectory>.psiFileFixture(fileRelativePath: Path): TestFixture<PsiFile> = testFixture { _ ->
val sourceRootDirectory = this@psiFileFixture.init()
val virtualFile = sourceRootDirectory.virtualFile.resolveFromRootOrRelative(fileRelativePath.toString())
?: error("Can't resolve VirtualFile for $fileRelativePath")
val psiFile = readAction {
sourceRootDirectory.manager.findFile(virtualFile)
?: error("Can't find PsiFile for $virtualFile")
}
initialized(psiFile) {}
}
@@ -0,0 +1,38 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.framework.helper
import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.Editor
import com.intellij.psi.PsiDocumentManager
import com.intellij.testFramework.fixtures.impl.CodeInsightTestFixtureImpl
import com.intellij.util.ArrayUtilRt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.TestOnly
/**
* @see CodeInsightTestFixtureImpl.doHighlighting
* @see CodeInsightTestFixtureImpl.instantiateAndRun
*/
@TestOnly
fun Editor.doHighlighting(
minimalSeverity: HighlightSeverity? = null,
canChangeDocument: Boolean = false,
readEditorMarkupModel: Boolean = false,
): List<HighlightInfo> = runBlocking {
val highlightInfos = withContext(Dispatchers.EDT) {
requireNotNull(project) { "PsiDocumentManager requires project to be not null" }
val psiFile = PsiDocumentManager.getInstance(project!!).getPsiFile(document)
CodeInsightTestFixtureImpl.instantiateAndRun(
psiFile!!, this@doHighlighting, ArrayUtilRt.EMPTY_INT_ARRAY, canChangeDocument, readEditorMarkupModel
)
}
if (minimalSeverity == null) highlightInfos
else {
highlightInfos.filter { it.severity >= minimalSeverity }
}
}
@@ -0,0 +1,63 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.framework.metaInfo
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.application.PluginPathManager
import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.extension.ExtensionContext
import java.nio.file.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.jvm.optionals.getOrNull
@TestOnly
enum class Repository(val contentRootResolver: (String) -> String) {
PY_COMMUNITY({ "${PathManager.getHomePath()}/community/python/${it}" }),
PY_PROFESSIONAL({ "${PathManager.getHomePath()}/python/${it}" }),
PLUGINS({ PluginPathManager.getPluginHomePath(it) })
}
/**
* Annotation for defining $CONTENT_ROOT variable in test classes runtime.
*
* @property repository Specifies the repository where the test content resides. .
* @property contentRootPath Specifies the path in the [repository] which will be used as $CONTENT_ROOT.
*/
@TestOnly
annotation class TestClassInfo(
val repository: Repository = Repository.PY_COMMUNITY,
val contentRootPath: String = "testSrc",
)
internal fun TestClassInfo.resolvePath(pathWithPlaceholders: String): Path {
val contentRootPlaceholder = $$"$CONTENT_ROOT"
if (!pathWithPlaceholders.contains(contentRootPlaceholder)) {
return Path.of(pathWithPlaceholders)
}
val contentRoot = repository.contentRootResolver(contentRootPath)
val testDataPath = pathWithPlaceholders.replace(contentRootPlaceholder, contentRoot)
return Path.of(testDataPath)
}
@TestOnly
data class TestClassInfoData(val testDataPath: Path?) {
fun getTestResourcePath(fileName: String): Path? {
val resources = testDataPath?.listDirectoryEntries("${fileName}{,.*}") ?: return null
return when (resources.size) {
0 -> null
1 -> resources.first()
else -> error("Multiple resources found for $fileName: ${resources.joinToString(", ")}")
}
}
}
@TestOnly
data class TestMethodInfoData(val testCaseFilePath: Path?)
internal fun <T : Annotation> getAnnotation(context: ExtensionContext?, clazz: Class<T>): T? {
return context?.testClass?.map { element -> element.getAnnotation(clazz) }?.getOrNull()
}
@@ -0,0 +1,118 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.framework.metaInfo
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.TestDataPath
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.Extension
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ExtensionContext.Namespace
import org.junit.jupiter.api.extension.ParameterContext
import org.junit.jupiter.api.extension.ParameterResolver
import java.nio.file.Path
import kotlin.io.path.relativeTo
import kotlin.jvm.optionals.getOrNull
internal fun ExtensionContext.resolveTestName(): String {
return testMethod.map { PlatformTestUtil.getTestName(it.name, true) }.getOrNull()
?: error("Can't resolve test name for ${testMethod.map { it.name }}")
}
/**
* A JUnit 5 extension that provides test metadata management capabilities at both class and method levels.
*
* Features of the extension include:
* - Resolves and processes class-level and method-level test data paths using custom annotations.
* - Injects [TestClassInfoData] and [TestMethodInfoData] as parameters into test methods for easy use.
* - Supports deriving test resource paths based on naming conventions and annotations.
*/
internal class TestMetaInfoExtension : BeforeAllCallback, BeforeEachCallback, Extension, ParameterResolver {
companion object {
fun ExtensionContext.getTestClassInfo(): TestClassInfoData {
val store = getStore(Namespace.GLOBAL)
return store.get(TestClassInfoData::class.java) as? TestClassInfoData
?: error("${TestClassInfoData::class.java} is not found / not valid in the context")
}
private fun ExtensionContext.setTestClassInfo(testClassInfo: TestClassInfoData) {
val store = getStore(Namespace.GLOBAL)
return store.put(TestClassInfoData::class.java, testClassInfo)
}
fun ExtensionContext.getTestMethodInfo(): TestMethodInfoData {
val store = getStore(Namespace.GLOBAL)
return store.get(TestMethodInfoData::class.java) as? TestMethodInfoData
?: error("${TestMethodInfoData::class.java} is not found / not valid in the context")
}
private fun ExtensionContext.setTestMethodInfo(testMethodInfo: TestMethodInfoData) {
val store = getStore(Namespace.GLOBAL)
return store.put(TestMethodInfoData::class.java, testMethodInfo)
}
fun ExtensionContext.setTestCaseFilePath(path: Path) {
val store = getStore(Namespace.GLOBAL)
return store.put(Path::class.java, path)
}
private fun ExtensionContext.getTestCaseFilePath(): Path? {
val store = getStore(Namespace.GLOBAL)
return store.get(Path::class.java) as? Path
}
}
/**
* Class level initialization.
* Calculates a real test data path based on class annotations (resolves $CONTENT_ROOT placeholder).
*/
override fun beforeAll(context: ExtensionContext) {
val testClassInfo = getAnnotation(context, TestClassInfo::class.java)
?: error("Add ${TestClassInfo::class} class level")
val testDataPathWithPlaceholders = getAnnotation(context, TestDataPath::class.java)?.value
val testDataPath = testDataPathWithPlaceholders?.let { testClassInfo.resolvePath(it) }
val data = TestClassInfoData(
testDataPath = testDataPath,
)
context.setTestClassInfo(data)
}
override fun beforeEach(context: ExtensionContext) {
val explicitPath = context.getTestCaseFilePath()
val testCaseFilePath = if (explicitPath != null) explicitPath
else {
val testClassInfo = context.getTestClassInfo()
testClassInfo.testDataPath?.let { testDataPath ->
val testName = context.resolveTestName()
testClassInfo.getTestResourcePath(testName)?.relativeTo(testDataPath)
}
}
val data = TestMethodInfoData(
testCaseFilePath = testCaseFilePath
)
context.setTestMethodInfo(data)
}
override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
return when (parameterContext.parameter.type) {
TestClassInfoData::class.java,
TestMethodInfoData::class.java,
-> true
else -> false
}
}
override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any {
when (parameterContext.parameter.type) {
TestClassInfoData::class.java -> extensionContext.getTestClassInfo()
TestMethodInfoData::class.java -> extensionContext.getTestMethodInfo()
else -> null
}?.also { return it }
error("Not supported parameter received ${parameterContext.parameter.type}")
}
}
@@ -0,0 +1,81 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.unit.showCase
import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.impl.DocumentMarkupModel
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.python.junit5Tests.framework.FolderTest
import com.intellij.python.junit5Tests.framework.PyDefaultTestApplication
import com.intellij.python.junit5Tests.framework.helper.doHighlighting
import com.intellij.python.junit5Tests.framework.metaInfo.Repository
import com.intellij.python.junit5Tests.framework.metaInfo.TestClassInfo
import com.intellij.testFramework.TestDataPath
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.fixture.projectFixture
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
@PyDefaultTestApplication
@TestClassInfo(Repository.PY_COMMUNITY)
@TestDataPath($$"$CONTENT_ROOT/../junit5Tests-framework/testResources/example/junit5")
class PythonJUnit5ExampleTest(
val project: Project, /* class-level, the value is projectFixture.get(), might be declared implicitly */
val module: Module, /* class-level implicitly declared in PyWithDefaultFixturesExtension */
) {
companion object {
// An explicit override of the default fixture, which can be omitted if the default is enough.
private val projectFixture = projectFixture(openAfterCreation = true)
}
/**
* test folder iteration is here [com.intellij.python.junit5Tests.framework.AllFilesInFolderTestCaseProvider.provideTestTemplateInvocationContexts]
*/
@FolderTest // runs testMyFolderTest on each file in the '@TestDataPath/myFolderTest' folder, filtering by regex is supported
fun testHighlighting(
file: PsiFile, /* implicitly declared in PyWithDefaultFixturesExtension on the test method level */
editor: Editor, /* implicitly declared in PyWithDefaultFixturesExtension on the test method level */
) {
editor.doHighlighting()
val doc = PsiDocumentManager.getInstance(project).getDocument(file)!!
val markupModel = DocumentMarkupModel.forDocument(doc, project, false)
val highlighters = markupModel.allHighlighters
assert(highlighters.isNotEmpty())
}
/**
* resource recognition by test name is here [com.intellij.python.junit5Tests.framework.metaInfo.TestClassInfoData.getTestResourcePath]
*/
@Test
fun testSingle(
psiFile: PsiFile, /* testMyTestName -> myTestName.* (should be a single file with this name) in the @TestDataPath folder */
) = timeoutRunBlocking {
withContext(Dispatchers.EDT) {
Assertions.assertEquals("print(\"Hello, world!\")\n", psiFile.text)
}
}
/**
* This is an example of a parametrized test with multiple cases provided.
* The one could write expectations among the parameters as well.
*
* @param fileName the name of the file being tested
* @param length the expected length of the file name
*/
@ParameterizedTest
@CsvSource(value = [
"first.py,8",
"second.py,9",
])
fun testMultipleParameters(fileName: String, length: Int) {
Assertions.assertEquals(length, fileName.length)
}
}
@@ -0,0 +1,5 @@
import hello_world
if __name__ == "__main__":
main = Main()<caret>
print("Hello, World!")
@@ -0,0 +1,2 @@
class Main:
pass<caret>
@@ -0,0 +1 @@
print("Hello, world!")<caret>