Tuesday 16 August 2022

Kotlin: creating a project and getting some code (and tests) to run

G'day

This whole thing is gonna be a cross between a note-to-self and a pseudo-stream-of-consciousness as I set up a new Kotlin project in IntelliJ IDEA, and demonstrate to myself I can make some code run, as well as some tests. Why am I doing this? Well because in the last eight months I've been through this exercise half a dozen times, with a month or two between each one, and every time it's been a right pain in the arse to work out what I need to do. Context: I am 100% new to Kotlin, and I am 100% new to Gradle, and I am my past has been the luxury of writing apps in scripting languages (CFML, PHP) that need close to zero messing about to get to the "G'day World" stage (and tests thereof) into production. It's easy. Any idiot can do it (ahem). So it's annoying that I need to do all this faffing about. I hasten to add I am not going as far as "getting into production" here: I don't even know what I need to put into production if I wanted to. I'm just getting to the point where code runs in the IDE.

I worked through all this yesterday evening and early this evening, and I think I know the steps now. So I've blitzed my previous project and am starting again.

New Project

I've opened IntelliJ IDEA and clicked "New Project", and completed the form on the ensuing screen:

And I click "Create".

Project files

Having done that, I get this lot:

/mnt/c/src/kotlin/scratch$ tree
.
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── Main.kt
    │   └── resources
    └── test
        ├── kotlin
        └── resources

9 directories, 8 files

scratch/src/main/kotlin/Main.kt

It's created a G'day world function for me, but there's a typo in it:

fun main(args: Array<String>) {
    println("Hello World!")

    // Try adding program arguments via Run/Debug configuration.
    // Learn more about running applications: https://www.jetbrains.com/help/idea/running-applications.html.
    println("Program arguments: ${args.joinToString()}")
}

Fixed:

println("G'day World!")


scratch/gradle.properties

kotlin.code.style=official

OK. Shrug.

scratch/settings.gradle.kts

rootProject.name = "scratch"

Yup. Another shrug.

scratch/gradlew.bat / scratch/gradlew

These are shell scripts Gradle seems to create to "do stuff". It's boilerplate and I won't be touching it.

scratch/.gradle / scratch/.idea / scratch/gradle directories

More boilerplace that I don't seem to need to know about ATM. Well the .idea dir is the project config dir for Intellif IDEA, I know that.

scratch/build.gradle.kts

This is the file I seem to spend all my time in. The default contents are:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.7.10"
    application
}

group = "me.adamcameron"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}

application {
    mainClass.set("MainKt")
}

That's all lovely. Other than recognising some of the words, I have NFI what's going on really. Well I superficially do, but exactly the reason it is the way it is: NFI. As far as I can tell this is the Gradle equivalent of composer.json or packages.json or some such. Except it's doing a bunch more than just package management.

Build and run

Anyway, I guess the new project wizard knows what it's doing, so I'm gonna try to build and run the project.

And in the build window I get this lot:

  21:24:31: Executing ':classes :testClasses'...

> Task :wrapper

BUILD SUCCESSFUL in 295ms
1 actionable task: 1 executed
> Task :processResources NO-SOURCE
> Task :processTestResources NO-SOURCE
> Task :compileKotlin
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin NO-SOURCE
> Task :compileTestJava NO-SOURCE
> Task :testClasses UP-TO-DATE

BUILD SUCCESSFUL in 4s
1 actionable task: 1 executed
21:24:37: Execution finished ':classes :testClasses'.

OK. That seems promising in that it's not screaming at me that I've done it all wrong, so. Um. I go back to Main.kt and run it:

Result:

C:\Users\camer\.jdks\openjdk-17.0.1\bin\java.exe …
G'day World!
Program arguments: 

Process finished with exit code 0

Excellent. The default project config will run.

Installing Kotest

It would not be me if I didn't do some testing. I actually feel a bit bad running the app before having tests for it, but… small steps.

I wanna use Kotest for my testing, so I'll work out how to install that.

Distilling the info from that quickstart, I've made these changes to my build.gradle.kts:


// ...
dependencies {
    testImplementation(kotlin("test"))
    testImplementation("io.kotest:kotest-runner-junit5:5.4.2")
    testImplementation("io.kotest:kotest-assertions-core:5.4.2")
}

tasks.test {
    useJUnitPlatform()
}
tasks.withType<Test>().configureEach {
    useJUnitPlatform()
}

Note that the docs actually said to use literally $version but after having done that and it trying to use my app's version:

version = "1.0-SNAPSHOT"

I inferred that that was just unfortunate shorthand, and they mean "the version you want to install". Checking io.kotest:kotest-runner-junit5 and io.kotest:kotest-assertions-core in the Maven repo, it's 5.4.2 for each, so that's why I'm using that for the version.

That rebuilt OK.

Testing Kotest install

So some tests!

// scratch/src/test/kotlin/SystemTest.kt
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith

class SystemTest : DescribeSpec({
    describe("Tests of kotest installation") {
        it("should return the size of a string") {
            "hello".length shouldBe 5
        }
        it("should test for the prefix of a string") {
            "world".shouldStartWith("wor")
        }
    }
})

These are just testing that Kotest will do something, and use a couple of the assertions from its assertion lib.

Unfortunately I've gone backwards and forwards with this file whilst testing what I was saying above, but I don't think I have missed any steps here. Anyways, after a rebuild I was able to tell IntelliJ to run the tests:

And the results:

I also have the Kotest plugin for IntelliJ installed, so I can run them from there too:

Testing main

And now I need to get back to the "red" part of "red-green-refactor" of my initial main method. This is a tricky one cos it doesn't return a value, it outputs to stdout (println("G'day World!") etc). I thougth it must be possible to capture stdout from a test and test it, so I sniffed around.

I came across this project on Github (can't remember how I found it): stefanbirkner / system-lambda. This was an easy install:

dependencies {
    testImplementation(kotlin("test"))
    testImplementation("io.kotest:kotest-runner-junit5:5.4.+")
    testImplementation("io.kotest:kotest-assertions-core:5.4.+")
    testImplementation("com.github.stefanbirkner:system-lambda:1.2.+")
}

(Also note how I've worked out that version number is a constraint, so I've changed the all to get the latest in the given point release, with the +).

After a rebuild I can add a quick system test in to test the installation of the module:

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith
import com.github.stefanbirkner.systemlambda.SystemLambda.*

class SystemTest : DescribeSpec({
    describe("Tests of kotest installation") {
        it("should return the size of a string") {
            "hello".length shouldBe 5
        }
        it("should test for the prefix of a string") {
            "world".shouldStartWith("wor")
        }
    }
    describe("tests of system-lambda") {
        it("checks system-lambda is working OK") {
            var testString = "string to capture"
            val output = tapSystemOut {
                print(testString)
            }
            output shouldBe testString
        }
    }
})

It's quite pleasing how the Kotest plugin picked up the new test automatically:

And see it passing:

And now a test actually testing main:

import com.github.stefanbirkner.systemlambda.SystemLambda
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe

class MainTest : DescribeSpec ({
    describe("Tests of Main class") {
        it("outputs G'day World & its args") {
            val testArgs = arrayOf("some arg", "some other arg")
            val output = SystemLambda.tapSystemOut {
                main(testArgs)
            }
            output.trim() shouldBe "G'day World!\r\nProgram arguments: some arg, some other arg"
        }
    }
})

And watch it pass:

Whilst writing that, I figured I should have a test where I don't pass any args. Not least of all cos I had no idea how to make an empty typed array in Kotlin. It's dead easy as it turns out:

it("works OK with no args") {
    val testArgs = arrayOf<String>()
    val output = SystemLambda.tapSystemOut {
        main(testArgs)
    }
    output.trim() shouldBe "G'day World!\r\nProgram arguments:"
}

Done

And that's about it. I've been through this process three times in the last 24hrs now, and I'm having fewer problems. I still don't really "get" what Gradle is doing (or how), but I know enough to be getting on with some language testing now anyhow. And I'm sure the more I need to mess around with stuff, the more Gradle expertise I will pick-up.

And I'm quite pleased to have a "Kotlin" blog article. Finally. Even though it's just the travails of a noob.

All the code in the project is here: https://github.com/adamcameron/kotlin_scratch/tree/1.0. I have not yet re-cloned it and created a project from that to see if it all works A-OK. I will report back later. Time for one last beer and bed now though.

Righto.

--
Adam