this post was submitted on 16 Jun 2023
12 points (100.0% liked)

Kotlin

672 readers
2 users here now

Kotlin is a statically typed programming language for the JVM, Android, JavaScript, and native.

Subreddit rules:

Resources:

founded 1 year ago
MODERATORS
 

In a recent Spring Boot project I decided to try a new pattern in my integration tests and it worked out pretty well. Curious if others have had success with this too; I haven't seen it written about anywhere.

The basic idea: Use by lazy to create graphs of canned test fixtures, and use those lazy values as defaults in the fixture setup code.

To make up a simple example, say you have "books" and "authors" tables in a database, where every book has to have an author. You might have a couple functions in your test code to populate those tables and return the IDs of the newly-inserted rows so you can use them in tests:

fun insertAuthor(name: String): Long { ... }
fun insertBook(title: String, authorID: Long): Long { ... }

fun testCheckOut() {
    val authorID = insertAuthor("John Steinbeck")
    val bookID = insertBook("Cannery Row", authorID)
    
    library.checkOut(bookID)
    // ...then assert the book is checked out
}

The "by lazy" pattern reduces boilerplate in cases where you just need a book and an author but it's fine for them to be canned values.

val cannedAuthorID: Long by lazy { insertAuthor("John Steinbeck") }
val cannedBookID: Long by lazy { insertBook("Cannery Row") }

// This is the same as before
fun insertAuthor(name: String): Long { ... }

// But this has a default if your test doesn't care who the author is
fun insertBook(title: String, authorID: Long = cannedAuthorID): Long { ... }

// Referencing cannedBookID will insert both the book and the author
fun testCheckOut() {
    library.checkOut(cannedBookID)
    // ...then assert the book is checked out
}

// The canned IDs are inserted exactly once
fun testCheckOutTwoBooks() {
    library.checkOut(cannedBookID)

    // This will use the already-inserted author ID
    val secondBookID = insertBook("Of Mice and Men")

    assertThrows<TooManyBooksException> {
        library.checkOut(secondBookID)
    }
}

The benefit isn't too big with this simple example, but my real project has a more complex data model with multiple layers of dependencies, and it ended up making my tests considerably less cluttered with incidental boilerplate.

Of course, the other approach to this class of problem is to spin up a fully-populated set of test fixtures that gets shared by all the tests. For example, a test database that gets reset with a known set of example data for each test run. That can work well too, and it's a technique I sometimes use, but I prefer to have tests construct the environments they need.

Anyone else used this kind of setup? Are there any additional tricks I'm missing?

top 4 comments
sorted by: hot top controversial new old
[–] [email protected] 3 points 1 year ago (1 children)

This is an interesting approach. My personal preference would be to be a bit more explicit about what is being setup for the tests though. I'd be concerned that it would be lost in a complex test when and whether a book was inserted. Think of when somebody inevitable copy/pastes a test and loses the context of how that variable is initialized.

We would, for example, create something like insertDefaultBook() where there are DEFAULT_BOOK_ID and other values defined that can be used where "you don't care which book, just 'a' book will do" (e.g. DEFAULT_BOOK_TITLE, DEFAULT_BOOK_ISBN, etc.).

Then in test you have something like:

val bookId = insertDefaultBook()

So it's clear where it's coming from and when/where it's inserted.

[–] [email protected] 3 points 1 year ago (1 children)

That's how I'd done it previously (though it was using default parameter values in the insert functions, rather than separate functions to insert the default values). What I found was that I was ending up with a lot of irrelevant boilerplate in the tests. For a simple dependency graph like my example, it wasn't a big deal, but with a more complex structure, I would often find that my tests were 80% "create some dependencies that are only needed so that I can create the thing the test is actually concerned with." An example that looks more like the real project where I was doing this:

fun testRemoveFromCart() {
    val userId = insertUser()
    val customerId = insertCustomer(owner = userId)
    val categoryId = insertProductCategory()
    val productId = insertProduct(category = categoryId)
    val orderId = insertOrder(customerId = customerId)
    val lineItemId = insertLineItem(orderId = orderId, productId = productId)

    shoppingCart.removeItem(lineItemId)
    assertEquals(emptyList<Long>(), shoppingCart.getLineItemIds())
}

versus

fun testRemoveFromCart() {
    val lineItemId = insertLineItem()

    shoppingCart.removeItem(lineItemId)
    assertEquals(emptyList<Long>(), shoppingCart.getLineItemIds())
}

The second version is one line longer than it could be; I could use a canned line item ID too. But I'm inserting it explicitly for the reason you mention: the line item is directly relevant to the test and I want it to be clear that the test case is concerned with the fact that a line item exists.

Of course, one could move some of the boilerplate into a setup function that's run before each test case, but that's kind of a miniature version of loading the example database with test data that might not be needed by a particular test.

I did initially have a concern about the tests being hard to follow. During the code review the first time I used this pattern, I asked the reviewers to evaluate that by looking at the tests before they looked at the helper functions. The feedback I got was that people did scratch their heads for a moment initially, but once they looked at one of the helper function declarations, it became clear what was going on, and then after that, they found the tests easier to read than the old style. So this definitely does have the downside of adding a bit of a learning curve, which might be a bigger consideration on some projects than others.

That said, you may be right about the copy/paste issue; this is still a new thing in my project's code base and while it's fine so far, not enough time has passed to tell how it will work out as the code is maintained by different people long term.

[–] [email protected] 3 points 1 year ago

I see where you're coming from. I think it's a bit of a personal taste. I don't mind "boilerplate" so long as it is informative and/or useful to see.

I'll typically even add comments at the "setup" and "test" sections of a test to separate them out so my eye can sorta skip over that section if I want. Something like

   // Setup
    val userId = insertUser()
    val customerId = insertCustomer(owner = userId)
    val categoryId = insertProductCategory()
    val productId = insertProduct(category = categoryId)
    val orderId = insertOrder(customerId = customerId)
    val lineItemId = insertLineItem(orderId = orderId, productId = productId)
   
   // test logic
    shoppingCart.removeItem(lineItemId)
    assertEquals(emptyList<Long>(), shoppingCart.getLineItemIds())

But I'm sure you're doing something similar. Another thought - if it makes writing tests easier then it may promote more/better tests which could itself be a win even if it comes at the expense of them being a bit more difficult to follow at first (and once people are familiar with the pattern that should fade).

I'll be curious how it goes.

[–] jnovinger 2 points 1 year ago

It's been a couple of years since I've done anything with Kotlin (was doing Android dev), but I used by lazy anywhere I could.