Kotlin DSL - let's express code in "mini-language" - Part 5 of 5

Kotlin DSL - let's express code in "mini-language" - Part 5 of 5

In this post, we take a look at building our test cases by using a simpler language-like DSL.

I wanted a “simple” low overhead way of setting up, expressing and testing many combinations of inputs and outputs.

The goal is simple , create DSL for expressing the tests clearly and to find a concise way of writing tests that makes creating new cases a breeze.

An example of a DSL test case :

@Test
fun logsInWhenUserSelectsLogin() {
    ...
    resetLoginInPref() //sets login pref key as false

    instrumentation.startActivitySync(loginIntent)

    onView(allOf(withId(R.id.login_button), withText(R.string.login)))
          .perform(click())

    val expectedText = context.getString(R.string.is_logged_in, "true")
    onView(AllOf.allOf(withId(R.id.label), withText(expectedText)))
          .perform(ViewActions.click())
}

With this , its still pretty good test case , we could improve the syntax to take advantage of a DSL

Benefits : Converting Tests to DSL

  1. Correctness: Fix tests that are not exercising the intended target.
  2. Build Speed: Remove Robolectric and PowerMock where they are not needed.
  3. Cruft Clean Up: Clean up test code , annotations and throws that are unnecessary or unneeded.
  4. Readability: Further enhancement of the test style such as increased readability of tests in either // Given or // Then sections by use of method extraction.
  5. Readability Again: Restating the tests in sentences revealed missing assumptions in their names.

Simple RxTest is an example of how internal DSL support can build specific domain grammar to test.

// Example of RxTest
Observable.just("Hello RxTest!")
 .test {
        it shouldEmit "Hello RxTest!"  
        it should complete() 
        it shouldHave noErrors() 
}

Let's break down our test case

  • Update preferences to make sure that the user is logged out before the test starts
  • The user launches the app
  • The user clicks on “Log In”
  • We assert that the user sees the logged in text

Setup, actions and assertions.

Given the user is logged out
When the user launches the app
When the user clicks “Log In”
Then the user sees the logged in text

DSL implementation of the same is as follows :

@Test
fun logsInWhenUserSelectsLogin() {
    
    given(user).has().loggedOut();
    
    when(user).launches().app();
    when(user).selects().login();

    then(user).sees().loggedIn();

}

For this we would use :

infix fun Any.given(block: () -> Unit) = block.invoke()

infix fun Any.whenever(block: () -> Unit) = block.invoke()

infix fun Any.then(block: () -> Unit) = block.invoke()

We have an infix & extension function which accepts a function block block :()->Unit and executes it.

What this lets us do is chain the ‘given, when, then’ calls like a sentence and gets us one step closer to our DSL.

Next we have our User class which is an object class

An object class is not "a static class per-say", but rather it is a static instance of a class that there is only one of, otherwise known as a singleton.

Perhaps the best way to show the difference is to look at the decompiled Kotlin code in Java form.

Here is a Kotlin object and class:

object ExampleObject {
  fun example() {
  }
}

class ExampleClass {
  fun example() {
  }
}

In order to use the ExampleClass, you need to create an instance of it: ExampleClass().example(), but with an object, Kotlin creates a single instance of it for you, and you don't ever call it's constructor, instead you just access it's static instance by using the name: ExampleObject.example().

object User {
  infix fun selects(block: SelectsActions.() -> Unit): User {
    block.invoke(SelectsActions)
    return this
  }
}

Similarly this is an infix function on User. It takes a function with SelectsActions as the receiver, letting us call functions on SelectsActions in the lambda passed in. We invoke the function and return the User so that we can chain actions. The whole function is an infix function so that we can have spaces and makes the call read more like a sentence.

This just leaves the actions and assertions of the test. This is where the actual Espresso code lives, as below :

object SelectsActions {
    fun logout() {
        onView(allOf(withId(R.id.login_button), withText(R.string.logout)))
        .perform(click())
    }

    fun login() {
        onView(allOf(withId(R.id.login_button), withText(R.string.login)))
        .perform(click())
    }
}

When you put all of the above pieces together you can write nice, human-readable tests.

This concludes this series , Part 5 of 5 - Thanks for sticking around till the end.

Summary

[On Kotlin] A general language with lambda receivers and invoke convention means the ability to support internal DSL’s. Internal DSL’s give the ability for higher level readability and understandability through the use of structured grammar, but the additional benefit — that declared languages cannot provide easily — is type safely through compilation.

Extras :

Some great libraries available to us that provide a DSL interface are :

Spek

KotlinX.Html

Anko