Automating build tasks is crucial for any software project, and Gradle, with its Kotlin DSL, offers exceptional flexibility. During our conversation, we explored how to centralize and reuse build logic, particularly for test reports, by leveragingallprojects, buildSrc, and custom tasks in Kotlin.

Centralize Configurations with allprojects and `subprojects

The blocks`allprojects` et `subprojects`in your`build.gradle.kts`The root is fundamental for applying common configurations across your multi-module project.

*allprojects { …​ }: Apply the configuration tothe root project and all its subprojects. Ideal for defining an`group`, a`version`, or some`repositories`common. *subprojects { …​ }`Apply the configurationonly to the subprojects, excluding the root project. Perfect for applying module-specific plugins (like`java ou kotlin-jvm) or dependencies common to your libraries.

Here is an illustrative example:

// build.gradle.kts (projet racine)
plugins {
    base // Appliqué au projet racine
}

allprojects {
    group = "com.example"
    version = "1.0.0"

    repositories {
        mavenCentral()
    }

    tasks.withType<org.gradle.api.tasks.testing.Test> {
        useJUnitPlatform() // Configuration commune des tests pour tous les projets
    }
}

subprojects {
    apply(plugin = "java")
    apply(plugin = "org.jetbrains.kotlin.jvm")

    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    }

}

\==`buildSrc`The Swiss Army Knife of Build Logic

When the logic of your tasks becomes complex or needs to be reused,buildSrcIt is the preferred solution. It is a special Gradle module that is compiled before the main build scripts, making its classes available on the classpath of your entire build.

\=== Why use`buildSrc`for the tasks?

  • Reusability: A task defined in`buildSrc`can be applied to any project in the build

  • OrganizationCentralizes the build code, making it cleaner and more maintainable.

  • Type Safety and Autocompletion: The Kotlin code in`buildSrc`is compiled, offering error checking and autocompletion of your IDE, improving the development experience.

\=== Flow Diagram`buildSrc`(PlantUML code)

To generate this diagram, copy the code below and paste it into a tool that supports PlantUML.

[source,plantuml]

@startuml skinparam handwritten true skinparam monochrome true

rectangle "Gradle Build Process" { component "buildSrc" as BS { file "MyCustomTask.kt" as T file "MyConventionPlugin.kt" as P } component "Root Project" as RP component "Subproject A" as SA component "Subproject B" as SB }

T -→ P : "is defined in P -→ RP : "is applied to P -→ SA : "is applied to P --\> SB : "is applied to

RP -→ SA: "contains RP --|\> SB : "contains

RP -up→ BS : "depends on (for build logic) SA -up-\> BS : "depends on (for build logic) SB -up-\> BS : "depends on (for build logic)

note to the right of T Custom task classes (example: OpenTestReportTask) end note

note right of P Convention Plugins who record the tasks end note

@enduml

\== Creation of Abstract Report Tasks

To manage test reports, we have designed a modular approach using an abstract task class in`buildSrc`Depending on your needs, this class can inherit from`DefaultTask` ou de Exec.

\=== Abstract Task`OpenTestReportTask`(inheriting from`DefaultTask`)

This approach is recommended if you need custom Kotlin logic that interacts with the file system or other Gradle APIs, and then runs an external command.

[source,kotlin]

package com.yourpackage

import org.gradle.api.DefaultTask import org.gradle.api.tasks.Input import org.gradle.api.tasks.TaskAction import java.io.File

abstract class OpenTestReportTask : DefaultTask() {

----
----
init {
    group = "verification"
    description = "Opens a test report in Firefox."
    dependsOn("check") // Assure que les rapports sont générés
}

@get:Input
abstract var reportPath: String

@TaskAction
fun openReport() {
    val separator = File.separator
    val reportFile = project.layout.projectDirectory.asFile.toPath()
        .resolve(reportPath.replace("/", separator))
        .toAbsolutePath()
        .toFile()

    if (!reportFile.exists()) {
        logger.warn("Report file does not exist: $reportFile. Ensure 'check' ran.")
        return
    }

    project.exec {
        commandLine("firefox", "--new-tab", reportFile.absolutePath)
    }
    logger.lifecycle("Opened test report: ${reportFile.absolutePath}")
}
----

## }

\=== Concrete Implementations

These classes inherit from the abstract task and define the specific report path.

## [source,kotlin]

package com.yourpackage

abstract class ReportUnitTestsTask : OpenTestReportTask() init { description = "Opens the unit test report in Firefox. reportPath = "build/reports/tests/test/index.html } }

package com.yourpackage

## abstract class ReportFunctionalTestsTask : OpenTestReportTask() { init { description = "Opens the functional test report in Firefox." reportPath = "build/reports/tests/functionalTest/index.html" } }

\=== Recording of Tasks

In the`build.gradle.kts`of your root project :

## [source,kotlin]

## tasks.register\<com.yourpackage.ReportUnitTestsTask\>("reportTests") {} tasks.register\<com.yourpackage.ReportFunctionalTestsTask\>("reportFunctionalTests") {}

\=== UML Diagram of Reporting Tasks (PlantUML Code)

Copy the code below and paste it into a tool that supports PlantUML.

## [source,plantuml]

@startuml skinparam handwritten true skinparam monochrome true

abstract class DefaultTask { }

abstract class Exec { \+ commandLine(args: String...) \+ exec() }

abstract class OpenTestReportTask extends DefaultTask { \+ group: String = "verification \+ description: String \+ dependsOn("check") \+ abstract reportPath: String \+ openReport() : void }

abstract class AbstractJbakeExecTask extends Exec { \+ group: String = "verification \+ description: String \+ dependsOn("check") \+ abstract reportRelativePath: String \+ exec() : void }

class ReportUnitTestsTask extends OpenTestReportTask { \+ reportPath: String = "build/reports/tests/test/index.html }

class ReportFunctionalTestsTask extends OpenTestReportTask { \+ reportPath: String = "build/reports/tests/functionalTest/index.html }

class ReportJbakeTestsTask extends AbstractJbakeExecTask { \+ reportRelativePath: String = "build/reports/tests/test/index.html }

class ReportJbakeFunctionalTestsTask extends AbstractJbakeExecTask { \+ reportRelativePath: String = "build/reports/tests/functionalTest/index.html }

OpenTestReportTask \<-- ReportUnitTestsTask OpenTestReportTask <-- ReportFunctionalTestsTask

AbstractJbakeExecTask \<-- ReportJbakeTestsTask AbstractJbakeExecTask \<-- ReportJbakeFunctionalTestsTask

DefaultTask \<|-- OpenTestReportTask Execute \<|-- AbstractJbakeExecTask DefaultTask \<|-- Exec

## @enduml

\== Abstract Task`AbstractJbakeExecTask`(inheriting from`Exec`)

If your task mainly consists of executing an external command with variable arguments, inherit directly from**Exec**is more direct.

## [source,kotlin]

package com.yourpackage

import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import java.io.File

abstract class AbstractJbakeExecTask : Exec() {

----

init { group = "verification" description = "Opens a Jbake project report in Firefox." dependsOn("check") }

@get:Input abstract var reportRelativePath: String

override fun exec() { val separator = File.separator val reportFile = project.layout.projectDirectory.asFile.toPath() .resolve(reportRelativePath.replace("/", separator)) .toAbsolutePath() .toFile()

if (!reportFile.exists()) { logger.warn("Report file does not exist: $reportFile. Ensure 'check' ran.") return }

commandLine("firefox", "--new-tab", reportFile.absolutePath) logger.lifecycle("Attempting to open report: ${reportFile.absolutePath}") super.exec() // Appelle la méthode exec() de la super-classe Exec }

## }

Concrete Implementations for`Exec`

## [source,kotlin]

package com.yourpackage

abstract class ReportJbakeTestsTask : AbstractJbakeExecTask() {} init { description = "Opens the Jbake unit test report in Firefox. reportRelativePath = "build/reports/tests/test/index.html } }

package com.yourpackage

abstract class ReportJbakeFunctionalTestsTask : AbstractJbakeExecTask() { init { description = "Opens the functional test report in Firefox." reportRelativePath = "build/reports/tests/functionalTest/index.html" } }

\== Visibility of Tasks`buildSrc`

The tasks and classes you define in**buildSrc**are :

***Visible and usable**for all the projects in your main build (root and subprojects). That's why you can use`com.yourpackage.ReportUnitTestsTask`in`allprojects { ... }`. ***Non-executable**directly as buildSrc tasks (for example,`gradle :buildSrc:reportTests`it would not work if the task is not specifically recorded in`buildSrc/build.gradle.kts`).`buildSrc`is a compilation module, not an executable application module for these main build tasks.

\=== Run a report for tests of`buildSrc`himself

Si `buildSrc`has its own tests and generates reports, you can record a reporting task directly in`buildSrc/build.gradle.kts`:

python import sys

def main(): data = sys.stdin.read() sys.stdout.write(data)

if __name__ == '__main__': main()

plugins { `kotlin-jvm` }

repositories { mavenCentral() }

tasks.withType<Test> { useJUnitPlatform() reports.html.outputLocation.set(layout.buildDirectory.dir("reports/tests")) }

import com.yourpackage.ReportJbakeTestsTask // Import your task class

## tasks.register<ReportJbakeTestsTask>("reportBuildSrcTests") { // The path is already defined in the class, it will point to the buildSrc reports }

You will then be able to run:`./gradlew :buildSrc:test`followed by`./gradlew :buildSrc:reportBuildSrcTests`.

By adopting these practices, you will build Gradle build systems in Kotlin DSL that are not only powerful, but also incredibly modular, maintainable, and easy to understand.

Related articles