Kotlin Compile Testing

repository·main·Indexed 41 days ago

https://github.com/zacsweers/kotlin-compile-testing

A library for in-process compilation of Kotlin and Java code, designed for testing annotation processors, compiler plugins, and code generation. It provides tools like KotlinCompilation and KotlinJsCompilation to configure sources, run kapt or KSP processors, and inspect results via JvmCompilationResult and JsCompilationResult.

Tokens
4.5K
Snippets
14
Records
17
Agent score
52%

What's inside Kotlin Compile Testing

  1. Compatibility and dependency constraints

    main

    Kotlin-Compile-Testing is compatible with all local compiler versions. However, if your project or its dependencies depend directly on compiler artifacts like kotlin-compiler-embeddable or kotlin-annotation-processing-embeddable, these must match the version used by Kotlin-Compile-Testing to avoid transitive dependency conflicts.

    Because internal Kotlin compiler APIs change frequently, the library only supports one kotlin-compiler-embeddable version at a time.

  2. How to use Kotlin Compile Testing

    main

    Kotlin Compile Testing allows you to perform in-process compilation of Kotlin and Java code. This is useful for testing annotation processors, compiler plugins, or code generation.

    To use it, you typically follow three steps:

    1. Create sources: Use SourceFile.kotlin() or SourceFile.java() to define your source code.
    2. Configure compilation: Use KotlinCompilation() to specify sources, annotation processors, compiler plugins, and other settings.
    3. Assert results: Check the ExitCode, inspect messages for diagnostics, or use the classLoader to load and inspect compiled classes via reflection.
    class TestEnvClass {}
    
    @Test
    fun `test my annotation processor`() {
      val kotlinSource = SourceFile.kotlin(
        "KClass.kt", """
            class KClass {
                fun foo() {
                    val testEnvClass = TestEnvClass()
                }
            }
        """
      )
    
      val javaSource = SourceFile.java(
        "JClass.java", """
            public class JClass {
                public void bar() {
                    KClass kClass = new KClass();
                }
          }
        """
      )
    
      val result = KotlinCompilation().apply {
        sources = listOf(kotlinSource, javaSource)
        annotationProcessors = listOf(MyAnnotationProcessor())
        compilerPlugins = listOf(MyComponentRegistrar())
        commandLineProcessors = listOf(MyCommandlineProcessor())
        inheritClassPath = true
        messageOutputStream = System.out
      }.compile()
    
      assertThat(result.exitCode).isEqualTo(ExitCode.OK)
      assertThat(result.messages).contains("My annotation processor was called")
      
      val kClazz = result.classLoader.loadClass("KClass")
      assertThat(kClazz).hasDeclaredMethods("foo")
    }
  3. Fix IllegalAccessError in Java 16+

    main

    When running on Java 16 or later, KAPT may encounter java.lang.IllegalAccessError because it attempts to access internal javac classes that are not exported by the jdk.compiler module.

    Because compilation runs in the same process as your test runner, you must manually add --add-opens JVM arguments to your test task in build.gradle or build.gradle.kts to allow access to the necessary jdk.compiler packages.

    if (JavaVersion.current() >= JavaVersion.VERSION_16) {
        tasks.withType<Test>().configureEach {
            jvmArgs(
                "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
                "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
            )
        }
    }
  4. Test KSP processors

    main

    To test Kotlin Symbol Processing (KSP) processors, you must include the KSP dependency. The KotlinCompilation class provides a symbolProcessorProviders property to register your SymbolProcessorProvider implementations. All code generated by the KSP processor is written to the KotlinCompilation.kspSourcesDir directory.

    dependencies {
        testImplementation("dev.zacsweers.kctfork:ksp:<version>>")
    }
    class MySymbolProcessorProvider : SymbolProcessorProvider {
        // implementation of the SymbolProcessorProvider from the KSP API
    }
    
    val compilation = KotlinCompilation().apply {
        sources = listOf(source)
        symbolProcessorProviders = listOf(MySymbolProcessorProvider())
    }
    val result = compilation.compile()
  5. Add a previous compilation result to the classpath

    main

    You can chain compilations by adding the output directory of a previous JvmCompilationResult to the current KotlinCompilation's classpath using the addPreviousResultToClasspath extension function.

    val firstResult = compilation1.compile()
    val secondCompilation = compilation2.addPreviousResultToClasspath(firstResult)
    this.classpaths += previousResult.outputDirectory
  6. Inspect the results of a compilation with CompilationResult

    main

    The CompilationResult sealed interface represents the outcome of a compilation process. It provides access to the exit code, raw messages, diagnostic messages with severity, and the output directory. You can use messagesWithSeverity to filter the diagnosticMessages list for specific DiagnosticSeverity levels.

    Depending on the target, you will receive either a JvmCompilationResult or a JsCompilationResult.

    val result: CompilationResult = // ... run compilation
    
    if (result.exitCode == KotlinCompilation.ExitCode.OK) {
      println("Compilation successful!")
    }
    
    // Filter for errors only
    val errors = result.messagesWithSeverity(DiagnosticSeverity.ERROR)
    println(errors)
  7. Configure and run Kotlin JVM compilations with KotlinCompilation

    main

    The KotlinCompilation class is the primary entry point for configuring and executing Kotlin JVM compilations. It manages a multi-step compilation process that includes generating stubs, running annotation processors (kapt), compiling Kotlin source files, and finally compiling Java source files.

    Compilation Lifecycle

    1. Stubs and APT: Generates stubs and runs annotation processors via kapt.
    2. Kotlin Compilation: Compiles the original Kotlin sources along with any sources generated by kapt.
    3. Java Compilation: Compiles Java source files, ensuring they can see the previously compiled Kotlin classes.

    Key Configuration Options

    • sources: The source files to be compiled.
    • classpaths: The classpath for the compilation.
    • kaptArgs: A map of arbitrary arguments passed to kapt.
    • kaptFlags: A set of KaptFlag values passed to kapt.
    • annotationProcessors: A list of Processor instances to be used by kapt.
    • jvmTarget: The target JVM bytecode version (e.g., 1.8, 11).
    • includeRuntime: Whether to include the Kotlin runtime in the resulting JAR.
    • jdkHome: The path to the JDK to be used for compilation.
    • kotlinStdLibJar, kotlinReflectJar, etc.: Paths to specific Kotlin standard library or reflection JARs.

    Running the Compilation

    Call the compile() method to execute the process. It returns a JvmCompilationResult containing the ExitCode and compilation messages.

    val compilation = KotlinCompilation().apply {
        sourcesDir = File("test-sources")
        // ... configure other properties
    }
    val result = compilation.compile()
    if (result.exitCode == KotlinCompilation.ExitCode.OK) {
        // Success
    }
  8. Configure and run Kotlin JS compilations with KotlinJsCompilation

    main

    The KotlinJsCompilation class allows you to programmatically run Kotlin/JS compilations. It extends AbstractKotlinCompilation and is designed to work with the IR backend.

    Key configuration options include:

    • irProduceJs: (Default: true) Generates the JS file using the IR backend.
    • irProduceKlibDir: Generates an unpacked KLIB into the parent directory of the output JS file.
    • irProduceKlibFile: Generates a packed klib into the file specified by -output.
    • irDce: Enables experimental dead code elimination.
    • generateDts: Generates TypeScript .d.ts declarations alongside the JS file (IR backend only).
    • moduleName / irModuleName: Specifies the compilation module name.
    • kotlinStdLibJsJar: Path to kotlin-stdlib-js.jar. If not provided, it is searched for in the host process classpaths.

    The resulting files (classes, jars, resources) are placed in the outputDir, which is a subdirectory named output within your workingDir.

    To execute the compilation, call the compile() method, which returns a JsCompilationResult containing the exitCode, messages, and diagnostics.

    val compilation = KotlinJsCompilation(
        sources = listOf(sourceFile1, sourceFile2),
        workingDir = File("build/test-compilation"),
        inheritClassPath = true
    ).apply {
        irProduceJs = true
        generateDts = true
        moduleName = "my-module"
    }
    
    val result = compilation.compile()
    if (result.exitCode == KotlinCompilation.ExitCode.OK) {
        println("Compilation successful!")
    } else {
        println("Compilation failed: ${result.messages}")
    }
  9. Configure kapt for annotation processing in KotlinCompilation

    main

    If your compilation requires annotation processing, you can configure kapt within KotlinCompilation using the following properties:

    • annotationProcessors: A List<Processor> containing the annotation processors to run.
    • kaptArgs: A MutableMap<String, String> for arbitrary kapt arguments.
    • kaptFlags: A MutableSet<KaptFlag> for kapt flags.
    • useKapt4: A boolean to enable the new Kapt 4 implementation for K2 support.
    • correctErrorTypes: A boolean (defaults to true) to make kapt correct error types.
    • processingClasspaths: A List<File> specifying additional classpaths for annotation processors.

    Generated files are stored in the kapt directory within the workingDir. You can access:

    • kaptSourceDir: Where Java files generated by kapt are placed.
    • kaptKotlinGeneratedDir: Where Kotlin files generated by kapt are placed.
    • kaptStubsDir: Where stubs are generated.
  10. Configure KSP2 via onBuilder

    main

    For advanced configuration requirements, the Ksp2PrecursorTool provides an onBuilder hook. This allows you to directly access and modify the KSPJvmConfig.Builder before the KSP execution begins. This is useful for settings not explicitly exposed by the standard KspTool interface.

    val tool = Ksp2PrecursorTool().apply {
        onBuilder = { 
            // Access KSPJvmConfig.Builder properties here
            // e.g., custom configuration for the KSP engine
        }
    }