Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Test a Kotlin CLI Application with JUnit

In this lab, you'll strengthen a Kotlin CLI task tracker application by writing focused JUnit 5 tests for its domain logic, service behavior, and validation paths. You'll build parameterized tests that cover valid, boundary, and invalid inputs in a single test method, and use MockK to isolate the service layer from its repository dependency. By the end, you'll be equipped to design fast, repeatable unit tests for real-world Kotlin applications without relying on a database or manual console testing.

Lab platform
Lab Info
Level
Intermediate
Last updated
Jul 21, 2026
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Test core domain logic

    You're picking up a Kotlin command-line task tracker whose production code already runs correctly, but its test suite doesn't back that up yet. Over the course of this lab, you'll close that gap yourself, one layer of the application at a time.

    You'll write JUnit 5 tests for the task tracker's domain model, add parameterized tests that cover several inputs through a single method, exercise the validation and error paths built into the domain and service layers, and use MockK to test the service layer without writing to a real file. To begin, you'll focus on the Task data class and the small pure functions built around it, the simplest piece of the application and the one every other test in this lab builds on.

    The application directory already contains the task tracker's complete source code, together with an src/test directory where you'll add your own test methods.

    Feeling stuck? Check out the matching solution/stepN/ folder for the step you're on to see a working implementation. Give it a try on your own first. The solution folder is your safety net, not your starting point.

    info> This lab experience was developed by the Pluralsight team using an internally developed AI tool. All sections were verified by human experts for accuracy prior to publications. However, content may still contain errors or inaccuracies, and we recommend independent verification.

    To report a problem or provide feedback, click here. Feedback may be used to improve accuracy in accordance with our Privacy Policy. ### Testing a data class's stored values

    A Kotlin data class generates a property getter for every constructor parameter, so testing that it stores its values correctly just means constructing an instance and reading each property back. JUnit 5's @Test annotation marks a method as a test that JUnit discovers and runs on its own; inside it, you construct the object under test and then use org.junit.jupiter.api.Assertions.assertEquals(expected, actual) to compare each property against the value you expect. Check properties individually rather than relying on one combined comparison, so a failure points at exactly which property is wrong.

    A test method built this way generally has three parts: build the object or inputs you're testing (arrange), call the behavior under test (act), and assert on the result (assert). For a type like Widget, that looks like this:

    Example Code
    @Test
    fun shouldCalculateWidgetTotal() {
        val widget = Widget(price = 10, quantity = 3)
    
        assertEquals(30, widget.total())
    }
    

    The method name describes the behavior under test in camelCase, and the body follows the same arrange-act-assert shape regardless of what class it's testing.

    The tasks in this lab name each test method for you; use that exact name so it lines up with what the rest of the lab expects.

    warning> The lab environment on the right may take up to a minute to finish loading. Continue after the terminal and project files appear. ### Testing a pure extension function

    Task.kt also defines extension functions such as toDisplayString(), plain functions attached to the Task type that compute a value from its properties without changing any state. Testing a pure function like this is as simple as constructing the input and asserting on the function's return value, the same shape you'd use for any function with no dependencies. For example, testing a function fun Widget.label(): String looks like assertEquals("expected", widget.label()). ### Covering the complementary branch

    toDisplayString() branches on the task's completion state, so one test per branch gives full coverage of that logic. This test mirrors the previous one with the opposite completion state, closing out the two possible ways the checkbox can render.

  2. Challenge

    Step 2: Add parameterized tests for input variation

    With the Task model now covered by three focused tests, adding a separate test method for every additional valid input would mean a lot of near-identical code. JUnit 5's parameterized tests let a single test method run once per row of supplied data, so you can cover several inputs without repeating yourself. Here, you'll apply two different ways of supplying that data, @CsvSource and @MethodSource, to the task tracker's priority-label and display-formatting logic. ### Supplying multiple cases with @CsvSource

    @ParameterizedTest replaces @Test on a method that takes parameters instead of hardcoding values inline, and @CsvSource supplies one row of comma-separated values per run. Each row maps to the method's parameters in order, so this method runs twice, once per row:

    Example Code
    @ParameterizedTest
    @CsvSource(
        "1, one",
        "2, two"
    )
    fun check(input: Int, expected: String) {
        assertEquals(expected, describe(input))
    }
    

    This is a natural fit for priorityLabel(), which maps a small, fixed set of input values to expected output strings. ### Supplying multiple cases with @MethodSource

    @MethodSource points a parameterized test at a method that builds and returns the argument sets in code instead of writing them inline, which suits values too varied for a compact CSV row. That source method returns a Stream<Arguments>, where each Arguments.of(...) call bundles one set of parameters for a single run, and it needs to be reachable statically, typically from a companion object marked with @JvmStatic:

    Example Code
    @ParameterizedTest
    @MethodSource("cases")
    fun check(input: String, flag: Boolean, expected: String) {
        assertEquals(expected, describe(input, flag))
    }
    
    companion object {
        @JvmStatic
        fun cases(): Stream<Arguments> = Stream.of(
            Arguments.of("a", false, "a not flagged"),
            Arguments.of("a", true, "a flagged")
        )
    }
    
  3. Challenge

    Step 3: Test validation and error-handling paths

    The Task class already rejects invalid data through Kotlin's require checks in its init block, but nothing in the test suite yet confirms those checks fire when they should. Here, you'll construct a Task with invalid values, such as a blank title or an out-of-range priority, and assert that construction throws the exception you expect. This step also reapplies the parameterized technique from step 2 to a set of invalid priority values, rounding out the domain model's coverage before the service layer takes over in step 4. ### Asserting that code throws an exception

    JUnit 5's Assertions.assertThrows(ExceptionClass::class.java) { ... } runs the code inside the lambda and fails the test unless it throws an instance of the given exception class, returning the thrown exception so you can inspect it further. For validation logic built on Kotlin's require, that's exactly the shape you want: construct the invalid object inside the lambda and check that an IllegalArgumentException comes out. The exception's message property holds the text passed to require, which you can assert on directly:

    Example Code
    @Test
    fun shouldRejectInvalidInput() {
        val exception = assertThrows(IllegalArgumentException::class.java) {
            Widget(name = "")
        }
    
        assertEquals("Name cannot be blank", exception.message)
    }
    
    ### Combining parameterized tests with exception assertions

    @ValueSource is the simplest parameterized source: a flat list of values of one type, each run through the test method once. Combined with assertThrows, a single parameterized method can confirm that several invalid inputs all trigger the same validation error, rather than writing one @Test per bad value. Choose values that pin down the edges of the accepted range, not just one value comfortably outside it:

    Example Code
    @ParameterizedTest
    @ValueSource(ints = [0, 100, -1])
    fun shouldRejectInvalidRange(input: Int) {
        assertThrows(IllegalArgumentException::class.java) {
            Widget(count = input)
        }
    }
    
  4. Challenge

    Step 4: Isolate the service layer with MockK

    Domain-level tests now catch bad input before a Task is even created, but the service layer's behavior still isn't covered, and testing it against the real file-backed repository would mean reading and writing an actual file on every run. Here, you'll use MockK to create a mock TaskRepository and hand it to TaskService, then write tests for adding and completing tasks that tell the mock how to respond instead of touching the filesystem. By the end of this step, the service layer's core success and failure paths are covered by tests that run entirely in memory. ### Mocking a dependency with MockK

    mockk<Interface>() creates a test double that implements an interface without any real logic behind it, so a class like TaskService that depends on the TaskRepository interface can be tested without a real repository at all. Once created, every { mock.someFunction(...) } returns value tells the mock what to return when a matching call happens, and every { mock.someFunction(...) } just Runs does the same for a function that returns nothing. Stub only the calls the path you're testing will actually make; an unstubbed call on a mock throws by default:

    Example Code
    @Test
    fun shouldCreateWidgetUsingRepository() {
        val repository = mockk<WidgetRepository>()
        every { repository.nextId() } returns 1
        every { repository.save(any()) } just Runs
        val service = WidgetService(repository)
    
        val widget = service.createWidget("Gadget")
    
        assertEquals(1, widget.id)
        assertEquals("Gadget", widget.name)
    }
    
    ### Stubbing a lookup that returns an existing value

    Where nextId() in the previous task returned a fixed value regardless of input, findById(id) needs to return a specific object only when called with a matching argument, and MockK matches stubbed calls by their arguments the same way the real function would be invoked. Stubbing findById to return a Task you construct yourself lets you exercise completeTask end to end without a real lookup:

    Example Code
    @Test
    fun shouldUpdateWidgetUsingRepository() {
        val repository = mockk<WidgetRepository>()
        val existing = Widget(id = 1, name = "Gadget", active = false)
        every { repository.findById(1) } returns existing
        every { repository.save(any()) } just Runs
        val service = WidgetService(repository)
    
        val activated = service.activateWidget(1)
    
        assertEquals(true, activated.active)
    }
    
    ### Stubbing a lookup that finds nothing

    A repository lookup that doesn't find a match is itself a case worth testing, and MockK handles it the same way as any other stub: every { mock.findById(id) } returns null tells the mock to behave as if nothing matched. Pair that with assertThrows to confirm the service layer reacts to a missing task the way you expect. It's good practice to also stub save() defensively in this test, even though the current code path doesn't call it, so the test doesn't crash on an unstubbed call if that ever changes:

    Example Code
    @Test
    fun shouldThrowWhenWidgetNotFound() {
        val repository = mockk<WidgetRepository>()
        every { repository.findById(99) } returns null
        every { repository.save(any()) } just Runs
        val service = WidgetService(repository)
    
        assertThrows(NoSuchElementException::class.java) {
            service.activateWidget(99)
        }
    }
    
    ### Testing a guard against an already-completed state

    completeTask also needs to reject a task that's already done, a state you can set up directly by constructing the stubbed Task with its completion flag already set. This is the same assertThrows pattern as the previous task, aimed at a different guard inside the same function.

  5. Challenge

    Step 5: Verify mock interactions and finalize the test suite

    The service-layer tests you've written so far check what TaskService returns, but not whether it actually asked the repository to do the right thing, or held back from doing so when an operation fails. Here, you'll add MockK verification to two of those existing tests, confirming the repository is called with the right data and confirming it's left alone entirely when completeTask rejects a request. To close out the lab, you'll run the full test suite from the terminal and see everything you've written pass together. ### Verifying a mock was called correctly

    Where every {} controls what a mock returns, verify { mock.someFunction(...) } checks, after the fact, that a call actually happened, and verify { mock.someFunction(match { ... }) } lets you inspect the arguments a call was made with instead of matching any call. Adding a verify block to an existing test adds a second kind of assertion, about behavior rather than return value, to the same method. Building on the earlier shouldCreateWidgetUsingRepository test, that looks like this:

    Example Code
    @Test
    fun shouldCreateWidgetUsingRepository() {
        val repository = mockk<WidgetRepository>()
        every { repository.nextId() } returns 1
        every { repository.save(any()) } just Runs
        val service = WidgetService(repository)
    
        val widget = service.createWidget("Gadget")
    
        assertEquals(1, widget.id)
        assertEquals("Gadget", widget.name)
        verify { repository.save(match { it.name == "Gadget" }) }
    }
    
    ### Verifying a mock was never called

    verify(exactly = 0) { mock.someFunction(...) } confirms the opposite of a normal verification: that a particular call never happened at all. For a function like completeTask that should short-circuit before touching the repository when a task can't be found, this is how you confirm the failure path genuinely has no side effect, rather than assuming it based on the exception alone. Building on the earlier shouldThrowWhenWidgetNotFound test, that looks like this:

    Example Code
    @Test
    fun shouldThrowWhenWidgetNotFound() {
        val repository = mockk<WidgetRepository>()
        every { repository.findById(99) } returns null
        every { repository.save(any()) } just Runs
        val service = WidgetService(repository)
    
        assertThrows(NoSuchElementException::class.java) {
            service.activateWidget(99)
        }
        verify(exactly = 0) { repository.save(any()) }
    }
    
    ### Running the full test suite

    Gradle's test task discovers and runs every test method in the project, printing a summary of how many passed and how many failed. Running it now, after adding tests across the domain model and service layer, runs the entire suite together for the first time. You've built out the test suite for the task tracker's Task model and TaskService, and just watched the entire suite run and pass from the terminal.

    Along the way, you wrote focused unit tests for a data class and its pure functions, used parameterized tests to cover several inputs through a single method, asserted on the exceptions a validation rule throws, and used MockK to test a service class without standing up a real repository. Those are the same techniques you'll reach for on any Kotlin codebase where a class depends on collaborators you don't want to run for every test.

    The suite you just watched pass is now a safety net for anyone who touches this codebase next, including you.

About the author

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight