- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
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 Info
Table of Contents
-
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
Taskdata 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
applicationdirectory already contains the task tracker's complete source code, together with ansrc/testdirectory 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 valuesA Kotlin
data classgenerates 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@Testannotation marks a method as a test that JUnit discovers and runs on its own; inside it, you construct the object under test and then useorg.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.ktalso defines extension functions such astoDisplayString(), plain functions attached to theTasktype 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 functionfun Widget.label(): Stringlooks likeassertEquals("expected", widget.label()). ### Covering the complementary branchtoDisplayString()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. -
Challenge
Step 2: Add parameterized tests for input variation
With the
Taskmodel 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,@CsvSourceand@MethodSource, to the task tracker's priority-label and display-formatting logic. ### Supplying multiple cases with @CsvSource@ParameterizedTestreplaces@Teston a method that takes parameters instead of hardcoding values inline, and@CsvSourcesupplies 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@MethodSourcepoints 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 aStream<Arguments>, where eachArguments.of(...)call bundles one set of parameters for a single run, and it needs to be reachable statically, typically from acompanion objectmarked 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") ) } -
Challenge
Step 3: Test validation and error-handling paths
The
Taskclass already rejects invalid data through Kotlin'srequirechecks in itsinitblock, but nothing in the test suite yet confirms those checks fire when they should. Here, you'll construct aTaskwith 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 exceptionJUnit 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'srequire, that's exactly the shape you want: construct the invalid object inside the lambda and check that anIllegalArgumentExceptioncomes out. The exception'smessageproperty holds the text passed torequire, which you can assert on directly:### Combining parameterized tests with exception assertionsExample Code
@Test fun shouldRejectInvalidInput() { val exception = assertThrows(IllegalArgumentException::class.java) { Widget(name = "") } assertEquals("Name cannot be blank", exception.message) }@ValueSourceis the simplest parameterized source: a flat list of values of one type, each run through the test method once. Combined withassertThrows, a single parameterized method can confirm that several invalid inputs all trigger the same validation error, rather than writing one@Testper 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) } } -
Challenge
Step 4: Isolate the service layer with MockK
Domain-level tests now catch bad input before a
Taskis 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 mockTaskRepositoryand hand it toTaskService, 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 MockKmockk<Interface>()creates a test double that implements an interface without any real logic behind it, so a class likeTaskServicethat depends on theTaskRepositoryinterface can be tested without a real repository at all. Once created,every { mock.someFunction(...) } returns valuetells the mock what to return when a matching call happens, andevery { mock.someFunction(...) } just Runsdoes 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:### Stubbing a lookup that returns an existing valueExample 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) }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. StubbingfindByIdto return aTaskyou construct yourself lets you exercisecompleteTaskend to end without a real lookup:### Stubbing a lookup that finds nothingExample 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) }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 nulltells the mock to behave as if nothing matched. Pair that withassertThrowsto confirm the service layer reacts to a missing task the way you expect. It's good practice to also stubsave()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:### Testing a guard against an already-completed stateExample 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) } }completeTaskalso needs to reject a task that's already done, a state you can set up directly by constructing the stubbedTaskwith its completion flag already set. This is the sameassertThrowspattern as the previous task, aimed at a different guard inside the same function. -
Challenge
Step 5: Verify mock interactions and finalize the test suite
The service-layer tests you've written so far check what
TaskServicereturns, 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 whencompleteTaskrejects 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 correctlyWhere
every {}controls what a mock returns,verify { mock.someFunction(...) }checks, after the fact, that a call actually happened, andverify { mock.someFunction(match { ... }) }lets you inspect the arguments a call was made with instead of matching any call. Adding averifyblock to an existing test adds a second kind of assertion, about behavior rather than return value, to the same method. Building on the earliershouldCreateWidgetUsingRepositorytest, that looks like this:### Verifying a mock was never calledExample 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" }) } }verify(exactly = 0) { mock.someFunction(...) }confirms the opposite of a normal verification: that a particular call never happened at all. For a function likecompleteTaskthat 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 earliershouldThrowWhenWidgetNotFoundtest, that looks like this:### Running the full test suiteExample 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()) } }Gradle's
testtask 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'sTaskmodel andTaskService, 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
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.