Test a Java App with JUnit 5

Build a task manager app and write a full JUnit 5 test suite for it.

Introduction

30 Second Summary

Every developer has pushed code that seemed to work, only to watch it break somewhere else. Automated testing catches those bugs before they ever leave your computer.

In this project, you will build a task management application in Java. You will write a complete JUnit 5 test suite that proves every feature works correctly.

What You'll Build

A task management application paired with a professional-grade JUnit 5 test suite.

By the end of this project, you'll have:

  • A TaskManager application with methods to add, complete, find, and count tasks.
  • A JUnit 5 test suite with 10+ tests covering happy paths, edge cases, and error handling.
  • Parameterized tests that verify your code against multiple inputs with a single test method.
  • A priority system with tests organized using nested test classes (Secret Mission).

Are there any prerequisites?

This project assumes basic Java knowledge, including classes, methods, and ArrayList. You should have IntelliJ IDEA and a JDK installed on your machine.

Before We Start

Before We Start...

Create a Maven Project with JUnit 5

Before you can write automated tests, you need a project that cleanly separates your application code from your test code. Maven, a popular Java build tool, handles this automatically with its standard directory layout.

In this step, you'll create a Maven project in IntelliJ IDEA and add JUnit 5 as your testing framework. This gives you everything you need to start writing and running tests.

In this step, get ready to:
  • Create a new Maven project named task-manager in IntelliJ IDEA.
  • Add the JUnit 5 testing dependency to your project.
  • Set up the com.taskmanager package in both source directories.
Create a new Maven project in IntelliJ IDEA
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • Click File in the top menu bar.
  • Click New, then select Project.
  • Enter task-manager in the Name field.
  • Select Java as the Language.
  • Select Maven as the Build system.
  • Select a JDK 17 or later from the JDK dropdown.
  • Expand Advanced Settings at the bottom of the dialog.
  • Enter com.taskmanager in the GroupId field.
  • Click Create.

What is Maven?

Maven is a build tool that manages your project structure and dependencies. It uses a file called pom.xml to track which libraries your project needs. When you add a library to pom.xml, Maven downloads it automatically.

Maven also creates a standard directory layout: src/main/java for your application code and src/test/java for your test code.

IntelliJ opens your new project. You should see a Project tool window on the left showing task-manager with src/main/java and src/test/java directories, plus a pom.xml file.

Don't see the project structure?

  • Make sure you selected Maven as the build system, not IntelliJ or Gradle.
  • If the Project tool window is not visible, press Cmd+1 (macOS) or Alt+1 (Windows) to open it.
  • My IntelliJ project doesn't show the expected directory structure.
Add the JUnit 5 dependency

Your project needs JUnit 5 to run tests. Maven manages this through dependencies listed in pom.xml.

  • In the Project tool window, double-click pom.xml to open it.
  • Find the closing </properties> tag.
  • Add the following <dependencies> block directly below it:
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.14.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

What does this dependency do?

  • junit-jupiter is an aggregator dependency. It bundles three JUnit 5 libraries in one: the API for writing tests, the engine for running them, and the params module for parameterized tests.
  • <scope>test</scope> means this library is only available in your test code. It will not be included in your production application.
  • Save pom.xml.
  • Press Cmd+Shift+O (macOS) or Ctrl+Shift+O (Windows) to load the Maven changes.

IntelliJ downloads the JUnit 5 library in the background. You can check the progress in the status bar at the bottom of the window.

Dependency not downloading?

  • Make sure you pasted the <dependencies> block below </properties>, not inside the <properties> block.
  • Check that your internet connection is active. Maven needs to download the library from a remote repository.
  • If the keyboard shortcut did not work, look for a small floating Maven icon (a blue refresh arrow) in the top-right corner of the pom.xml editor and click it.
  • IntelliJ isn't downloading my Maven dependency after editing pom.xml.

Awesome, I've got everything!

Great. Double check you've saved pom.xml and that the Maven import finished successfully.

I'd like to double check the full code

Here is the complete pom.xml file. Make sure yours matches:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.taskmanager</groupId>
    <artifactId>task-manager</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.14.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>
Create the package structure

Java uses packages to organize classes into logical groups. You need the same package in both source directories so your test classes can access your application classes.

  • In the Project tool window, right-click on the java folder inside src/main.
  • Select New, then Package.
  • Type com.taskmanager and press Enter.
  • Right-click on the java folder inside src/test.
  • Select New, then Package.
  • Type com.taskmanager and press Enter.

Why the same package in both directories?

When your test classes are in the same package as your application classes, they can access package-private methods without any extra configuration. This is a standard Java testing convention.

Your Project tool window should now show com.taskmanager under both src/main/java and src/test/java.

Don't see the package folders?

  • IntelliJ sometimes flattens empty packages in the display. Click the gear icon at the top of the Project tool window and uncheck Compact Middle Packages if you want to see each folder separately.
  • Make sure you right-clicked the java folder, not the src or main folder.
  • I can't see my com.taskmanager package in IntelliJ's project view.

Your project is set up and ready for code. Next up, you'll build the Task and TaskManager classes that form the foundation of your application.

Build the Task and TaskManager Classes

Your Maven project is set up and JUnit 5 is ready to go. Now it's time to build the application you'll be testing.

In this step, you'll create two Java classes that form the core of your task manager. Task represents a single to-do item, while TaskManager handles creating, completing, and looking up tasks.

In this step, get ready to:
  • Create a Task class that stores an ID, title, and completion status.
  • Create a TaskManager class that adds, completes, finds, and counts tasks.
  • Run the application to verify everything compiles and works.
Create the Task class

Every task manager needs a way to represent a single task. Your Task class holds three pieces of information: a unique ID, a title, and whether the task is completed.

  • In IntelliJ's Project panel on the left, expand src/main/java/com/taskmanager.
  • Right-click the taskmanager package.
  • Select New, then Java Class.
  • Enter Task as the class name and press Enter.
  • Replace the entire contents of Task.java with the following code:
package com.taskmanager;

public class Task {
    // Each task has a unique ID, a title, and a completion status
    private final String id;
    private final String title;
    private boolean completed;

    // New tasks always start as not completed
    public Task(String id, String title) {
        this.id = id;
        this.title = title;
        this.completed = false;
    }

    public String getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }

    // Mark this task as done
    public void complete() {
        this.completed = true;
    }
}

What does this code do?

  • id and title are marked final so they can never change after a task is created.
  • The constructor sets completed to false because every new task starts off unfinished.
  • The complete() method is the only way to mark a task as done. This keeps the completion logic in one place.
  • Getter methods (getId(), getTitle(), isCompleted()) let other classes read the task's data without modifying it directly.
  • Save Task.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check that IntelliJ shows no red underlines in the editor. The file should compile without errors.

Seeing red underlines in Task.java?

  • Make sure the first line says package com.taskmanager; and matches the folder path you created in Step 1.
  • Check for missing semicolons at the end of each statement and make sure every opening brace { has a matching closing brace }.
  • help me fix red underlines in my Task.java file

Awesome, I've got everything!

Double check you've saved Task.java before moving on.

I'd like to double check the full code

package com.taskmanager;

public class Task {
    private final String id;
    private final String title;
    private boolean completed;

    public Task(String id, String title) {
        this.id = id;
        this.title = title;
        this.completed = false;
    }

    public String getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }

    public void complete() {
        this.completed = true;
    }
}
Create the TaskManager class

The TaskManager class is the brains of your application. It keeps a list of all tasks and provides methods to add, find, complete, and count them.

  • Right-click the com.taskmanager package in the Project panel again.
  • Select New, then Java Class.
  • Enter TaskManager as the class name and press Enter.
  • Replace the entire contents of TaskManager.java with the following code:
package com.taskmanager;

import java.util.ArrayList;
import java.util.List;

public class TaskManager {
    // Store all tasks in a list
    private final List<Task> tasks = new ArrayList<>();

    // Create a new task and add it to the list
    public Task addTask(String id, String title) {
        Task task = new Task(id, title);
        tasks.add(task);
        return task;
    }

    // Mark a task as completed, or throw an error if the ID doesn't exist
    public void completeTask(String id) {
        Task task = getTaskById(id);
        if (task == null) {
            throw new IllegalArgumentException("Task not found: " + id);
        }
        task.complete();
    }

    // Search for a task by its ID, return null if not found
    public Task getTaskById(String id) {
        for (Task task : tasks) {
            if (task.getId().equals(id)) {
                return task;
            }
        }
        return null;
    }

    // Count how many tasks have been completed
    public int countCompletedTasks() {
        int count = 0;
        for (Task task : tasks) {
            if (task.isCompleted()) {
                count++;
            }
        }
        return count;
    }
}

What does this code do?

  • addTask() creates a new Task object and stores it in the internal list. It also returns the task so the caller can use it right away.
  • completeTask() looks up a task by ID and marks it complete. If the ID doesn't match any task, it throws an IllegalArgumentException. You'll write a test for this exact behavior later.
  • getTaskById() loops through all tasks and returns the one with a matching ID. It returns null if nothing matches.
  • countCompletedTasks() counts how many tasks in the list have isCompleted() == true. This gives you a quick summary of progress.

Why are these methods designed this way?

The application is intentionally simple. It uses basic loops and an ArrayList instead of streams or advanced patterns. The goal is to keep the application code easy to follow so you can focus on learning how to test it.

  • Save TaskManager.java.
  • Check that IntelliJ shows no red underlines in the editor. The Task import should resolve automatically since both classes are in the same package.

Seeing "cannot find symbol" for Task?

  • Make sure Task.java is saved and lives in the same com.taskmanager package as TaskManager.java.
  • Verify that Task.java has no compile errors of its own. A broken Task class causes errors in any file that uses it.
  • help me fix a "cannot find symbol" error in TaskManager.java

Awesome, I've got everything!

Double check you've saved TaskManager.java before moving on.

I'd like to double check the full code

package com.taskmanager;

import java.util.ArrayList;
import java.util.List;

public class TaskManager {
    private final List<Task> tasks = new ArrayList<>();

    public Task addTask(String id, String title) {
        Task task = new Task(id, title);
        tasks.add(task);
        return task;
    }

    public void completeTask(String id) {
        Task task = getTaskById(id);
        if (task == null) {
            throw new IllegalArgumentException("Task not found: " + id);
        }
        task.complete();
    }

    public Task getTaskById(String id) {
        for (Task task : tasks) {
            if (task.getId().equals(id)) {
                return task;
            }
        }
        return null;
    }

    public int countCompletedTasks() {
        int count = 0;
        for (Task task : tasks) {
            if (task.isCompleted()) {
                count++;
            }
        }
        return count;
    }
}
Run the application with a Main class

Before writing tests, you should verify that your application code actually works. A quick Main class lets you try out the TaskManager manually and see real output in the console.

  • Right-click the com.taskmanager package in the Project panel.
  • Select New, then Java Class.
  • Enter Main as the class name and press Enter.
  • Replace the entire contents of Main.java with the following code:
package com.taskmanager;

public class Main {
    public static void main(String[] args) {
        TaskManager manager = new TaskManager();

        // Add three tasks
        manager.addTask("task-1", "Buy groceries");
        manager.addTask("task-2", "Walk the dog");
        manager.addTask("task-3", "Write tests");

        // Complete two of them
        manager.completeTask("task-1");
        manager.completeTask("task-3");

        // Print summary to verify everything works
        System.out.println("Completed tasks: " + manager.countCompletedTasks());
        System.out.println("Task-2 completed? " + manager.getTaskById("task-2").isCompleted());
    }
}

What does this code do?

  • The main method creates a TaskManager and adds three tasks to it.
  • It completes two of the three tasks (task-1 and task-3), then prints the completed count and checks whether task-2 is still incomplete.
  • This exercises addTask(), completeTask(), countCompletedTasks(), and getTaskById() all in one quick test drive.
  • Save Main.java.
  • Click the green play button in the gutter next to public static void main.
  • Select Run 'Main.main()'.

You should see this output in IntelliJ's Run panel at the bottom of the window:

Completed tasks: 2
Task-2 completed? false

The count is 2 because you completed task-1 and task-3. task-2 was never completed, so it correctly returns false.

Don't see the green play button?

  • Make sure you saved all three files: Task.java, TaskManager.java, and Main.java.
  • Check that the main method signature is exactly public static void main(String[] args). IntelliJ only shows the play button when it detects a valid entry point.
  • If you see compile errors in any file, fix those first. IntelliJ won't offer to run a project that doesn't compile.
  • help me run my Main class in IntelliJ

Awesome, I've got everything!

All three files are saved and your application runs correctly.

I'd like to double check the full code

Here are all three files for this step.

package com.taskmanager;

public class Task {
    private final String id;
    private final String title;
    private boolean completed;

    public Task(String id, String title) {
        this.id = id;
        this.title = title;
        this.completed = false;
    }

    public String getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }

    public void complete() {
        this.completed = true;
    }
}
package com.taskmanager;

import java.util.ArrayList;
import java.util.List;

public class TaskManager {
    private final List<Task> tasks = new ArrayList<>();

    public Task addTask(String id, String title) {
        Task task = new Task(id, title);
        tasks.add(task);
        return task;
    }

    public void completeTask(String id) {
        Task task = getTaskById(id);
        if (task == null) {
            throw new IllegalArgumentException("Task not found: " + id);
        }
        task.complete();
    }

    public Task getTaskById(String id) {
        for (Task task : tasks) {
            if (task.getId().equals(id)) {
                return task;
            }
        }
        return null;
    }

    public int countCompletedTasks() {
        int count = 0;
        for (Task task : tasks) {
            if (task.isCompleted()) {
                count++;
            }
        }
        return count;
    }
}
package com.taskmanager;

public class Main {
    public static void main(String[] args) {
        TaskManager manager = new TaskManager();

        manager.addTask("task-1", "Buy groceries");
        manager.addTask("task-2", "Walk the dog");
        manager.addTask("task-3", "Write tests");

        manager.completeTask("task-1");
        manager.completeTask("task-3");

        System.out.println("Completed tasks: " + manager.countCompletedTasks());
        System.out.println("Task-2 completed? " + manager.getTaskById("task-2").isCompleted());
    }
}

Your task manager application is up and running. Next up, you'll write your first JUnit 5 tests to verify every one of these methods automatically.

Write Your First JUnit 5 Tests

Your TaskManager compiles and runs, but you are trusting console output to prove it works.

Automated tests verify every feature with a single click. In this step, you'll create your first JUnit 5 test class and write tests that prove your TaskManager can add tasks, complete them, and count them correctly.

In this step, get ready to:
  • Create a test class with @BeforeEach lifecycle setup.
  • Write assertion tests using assertNotNull, assertTrue, and assertEquals.
  • Run the tests and see green results in IntelliJ's test runner.
Create your test class with lifecycle setup

Every test method should start with a clean slate. If one test adds data, that data should not leak into the next test and cause false results.

What is @BeforeEach?

JUnit 5's @BeforeEach annotation marks a method that runs automatically before every test method in the class. By creating a fresh TaskManager in this method, each test gets its own empty manager with no leftover tasks.

  • In IntelliJ IDEA, expand src/test/java in the Project sidebar on the left.
  • Right-click the com.taskmanager package.
  • Select New, then Java Class.
  • Name the class TaskManagerTest and press Enter.
  • Replace the entire file contents with the following code:
package com.taskmanager;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("TaskManager Tests")
class TaskManagerTest {

    private TaskManager taskManager;

    @BeforeEach
    void setUp() {
        // Create a fresh TaskManager before every test
        taskManager = new TaskManager();
    }

    @Test
    @DisplayName("Should add a task and find it by ID")
    void shouldAddTask() {
        // Add a task to the manager
        taskManager.addTask("task-1", "Buy groceries");

        // Try to find it by ID
        Task result = taskManager.getTaskById("task-1");

        // Verify the task exists and has the correct title
        assertNotNull(result);
        assertEquals("Buy groceries", result.getTitle());
    }
}

What does this code do?

  • @BeforeEach void setUp() creates a brand new TaskManager before each test runs. No test can be affected by leftover data from another test.
  • @Test marks shouldAddTask() as a test method that JUnit discovers and runs automatically.
  • assertNotNull(result) checks that getTaskById returned an actual object, not null.
  • assertEquals("Buy groceries", result.getTitle()) verifies the returned task has the exact title you passed in.
  • Save TaskManagerTest.java.
  • Click the green play icon in the gutter next to the class name, then select Run 'TaskManagerTest'.
  • You should see 1 test passing with a green checkmark in IntelliJ's Run panel at the bottom of the screen.

Test not found or not running?

  • Make sure TaskManagerTest.java is inside src/test/java/com/taskmanager/, not inside src/main/java. JUnit only discovers tests in the test source root.
  • Check that your pom.xml has the junit-jupiter dependency with scope test. Press Ctrl+Shift+O to reimport if you recently changed it.
  • If you see red underlines on the import statements, Maven may not have downloaded the dependency yet. Press Ctrl+Shift+O and wait for the download to complete.
  • Still stuck? Help me figure out why my JUnit test is not being discovered in IntelliJ.
Add assertion tests for completing and counting tasks

One passing test is a great start. Now add two more tests that exercise different TaskManager features.

  • In TaskManagerTest.java, add the following test method below shouldAddTask():
    @Test
    @DisplayName("Should mark a task as completed")
    void shouldMarkTaskAsCompleted() {
        // Add a task and then complete it
        taskManager.addTask("task-1", "Buy groceries");

        taskManager.completeTask("task-1");

        // Retrieve the task and check its status
        Task result = taskManager.getTaskById("task-1");
        assertTrue(result.isCompleted());
    }

What does this test do?

This test exercises the completeTask method. It adds a task, completes it, then uses assertTrue to confirm that isCompleted() returns true.

You won't run this test yet. The next chunk adds one more test, then you'll run them all together.

  • Below shouldMarkTaskAsCompleted(), add this test method:
    @Test
    @DisplayName("Should count completed tasks correctly")
    void shouldCountCompletedTasks() {
        // Add three tasks
        taskManager.addTask("task-1", "Buy groceries");
        taskManager.addTask("task-2", "Walk the dog");
        taskManager.addTask("task-3", "Write tests");

        // Complete two of them
        taskManager.completeTask("task-1");
        taskManager.completeTask("task-3");

        // Verify the count matches
        assertEquals(2, taskManager.countCompletedTasks());
    }

What does this test do?

This test adds three tasks but only completes two. assertEquals(2, taskManager.countCompletedTasks()) verifies the count method returns exactly 2, not 3 or 1.

  • Save TaskManagerTest.java.

What is @DisplayName?

Each test has a @DisplayName annotation with a human-readable description. Without it, IntelliJ shows the raw method name like shouldCountCompletedTasks. With it, you see "Should count completed tasks correctly" in the test runner output.

Seeing red squiggly lines under an assertion?

  • Make sure the static import line import static org.junit.jupiter.api.Assertions.* is at the top of your file. This line brings in all assertion methods like assertTrue and assertEquals.
  • Check that each test method is inside the TaskManagerTest class body (between the opening and closing curly braces).
  • Need help? Help me fix a red underline in my JUnit test class.

Awesome, I've got everything!

Great. Double check you've saved TaskManagerTest.java before moving on.

I'd like to double check the full code

Here is the full TaskManagerTest.java file so far. Compare your code against this:

package com.taskmanager;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("TaskManager Tests")
class TaskManagerTest {

    private TaskManager taskManager;

    @BeforeEach
    void setUp() {
        taskManager = new TaskManager();
    }

    @Test
    @DisplayName("Should add a task and find it by ID")
    void shouldAddTask() {
        taskManager.addTask("task-1", "Buy groceries");

        Task result = taskManager.getTaskById("task-1");

        assertNotNull(result);
        assertEquals("Buy groceries", result.getTitle());
    }

    @Test
    @DisplayName("Should mark a task as completed")
    void shouldMarkTaskAsCompleted() {
        taskManager.addTask("task-1", "Buy groceries");

        taskManager.completeTask("task-1");

        Task result = taskManager.getTaskById("task-1");
        assertTrue(result.isCompleted());
    }

    @Test
    @DisplayName("Should count completed tasks correctly")
    void shouldCountCompletedTasks() {
        taskManager.addTask("task-1", "Buy groceries");
        taskManager.addTask("task-2", "Walk the dog");
        taskManager.addTask("task-3", "Write tests");

        taskManager.completeTask("task-1");
        taskManager.completeTask("task-3");

        assertEquals(2, taskManager.countCompletedTasks());
    }
}
Run your tests and review the results
  • Right-click TaskManagerTest in the Project sidebar on the left.
  • Select Run 'TaskManagerTest'.

IntelliJ's Run panel appears at the bottom with your test results. You should see 3 tests, each with a green checkmark next to its @DisplayName text.

What do the green checkmarks mean?

Each green checkmark means every assertion in that test method passed. If any assertion had failed, you would see a red X with a message showing the expected value versus the actual value.

If any test shows red, read the failure message. It tells you exactly which assertion failed and what value it received instead of what it expected.

Seeing a red X on one of your tests?

  • Read the failure output carefully. JUnit shows the expected value and the actual value side by side, which tells you exactly what went wrong.
  • Compare your test code against the double-check tab above. A common cause is a typo in a task ID like "task-1" versus "task1".
  • Also check your Task.java and TaskManager.java files from the previous step. A bug in the application code causes test failures too.
  • Still stuck? Help me understand why my JUnit test is failing.

You have 3 passing tests covering the happy paths. Next up, you'll test what happens when things go wrong.

Test Exceptions and Edge Cases

Your TaskManager has three passing tests that confirm the happy path works. Adding tasks, completing them, and counting them all check out.

Production code faces misuse. Users pass invalid IDs, look up tasks that don't exist, or trigger the same action twice. In this step, you'll write tests that verify your TaskManager handles these edge cases correctly.

In this step, get ready to:
  • Write an exception test using assertThrows to verify error handling.
  • Write edge case tests for null returns and idempotent operations.
  • Run all six tests and review the test runner output.
Verify exception handling with assertThrows

Your completeTask method throws an IllegalArgumentException when the given ID doesn't match any task. You need a way to verify that the exception actually fires.

JUnit 5 provides assertThrows for exactly this. It takes the expected exception class and a lambda wrapping the code that should throw.

  • In TaskManagerTest.java, add this test method below your existing tests:
@Test
@DisplayName("Should throw exception when completing a non-existent task")
void shouldThrowExceptionForInvalidTaskId() {
    // Verify that completing a non-existent task throws the right exception
    assertThrows(IllegalArgumentException.class, () -> {
        taskManager.completeTask("non-existent-id");
    });
}

What does this test do?

  • assertThrows takes two arguments: the exception class you expect, and a lambda containing the code that should trigger it.
  • If completeTask("non-existent-id") throws an IllegalArgumentException, the test passes. If it throws a different exception or none at all, the test fails.
  • The lambda (() -> { ... }) is needed because the exception must be thrown inside the assertion. Calling the method directly would crash the test before assertThrows gets a chance to catch it.

You won't see a change yet. You'll run all six tests together at the end of this step.

Seeing a red underline on assertThrows?

  • Make sure your import line at the top of the file reads import static org.junit.jupiter.api.Assertions.*. The static wildcard import gives you access to all assertion methods.
  • Check that the lambda arrow -> has no spaces between the hyphen and the greater-than sign.
  • Help me fix a red underline on assertThrows in my JUnit 5 test.
Test null returns and idempotent operations

Exception handling is one kind of edge case. Two more common patterns are methods that return null when nothing is found, and operations that should be safe to call multiple times without side effects.

  • In TaskManagerTest.java, add these two test methods below shouldThrowExceptionForInvalidTaskId:
@Test
@DisplayName("Should return null for a non-existent task ID")
void shouldReturnNullForNonExistentTask() {
    // Look up a task ID that was never added
    Task result = taskManager.getTaskById("non-existent-id");

    assertNull(result);
}

@Test
@DisplayName("Should handle completing an already completed task")
void shouldNotCompleteAlreadyCompletedTask() {
    taskManager.addTask("task-1", "Buy groceries");

    // Complete the same task twice
    taskManager.completeTask("task-1");
    taskManager.completeTask("task-1");

    // Verify the task is still marked completed (not toggled or errored)
    Task result = taskManager.getTaskById("task-1");
    assertTrue(result.isCompleted());
}

What do these tests verify?

  • shouldReturnNullForNonExistentTask uses assertNull to confirm that looking up an ID that was never added returns null instead of crashing.
  • shouldNotCompleteAlreadyCompletedTask tests idempotency. Calling completeTask twice on the same task should not toggle it back to incomplete or throw an error.
  • Save TaskManagerTest.java.

You won't see a change yet. The next substep runs all six tests together.

Seeing a compile error?

  • If IntelliJ can't resolve Task in the test file, check that both Task.java and TaskManagerTest.java are in the com.taskmanager package. The package declaration at the top of each file must match.
  • Make sure each test method is placed inside the TaskManagerTest class body, not outside the closing brace.
  • Help me fix a compile error in my TaskManagerTest file.

Awesome, I've got everything!

Double check you've saved TaskManagerTest.java before running the tests.

I'd like to double check the full code

Here is the complete TaskManagerTest.java file with all six tests. Compare it with yours to spot any differences.

package com.taskmanager;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("TaskManager Tests")
class TaskManagerTest {

    private TaskManager taskManager;

    @BeforeEach
    void setUp() {
        taskManager = new TaskManager();
    }

    @Test
    @DisplayName("Should add a task and find it by ID")
    void shouldAddTask() {
        taskManager.addTask("task-1", "Buy groceries");

        Task result = taskManager.getTaskById("task-1");

        assertNotNull(result);
        assertEquals("Buy groceries", result.getTitle());
    }

    @Test
    @DisplayName("Should mark a task as completed")
    void shouldMarkTaskAsCompleted() {
        taskManager.addTask("task-1", "Buy groceries");

        taskManager.completeTask("task-1");

        Task result = taskManager.getTaskById("task-1");
        assertTrue(result.isCompleted());
    }

    @Test
    @DisplayName("Should count completed tasks correctly")
    void shouldCountCompletedTasks() {
        taskManager.addTask("task-1", "Buy groceries");
        taskManager.addTask("task-2", "Walk the dog");
        taskManager.addTask("task-3", "Write tests");

        taskManager.completeTask("task-1");
        taskManager.completeTask("task-3");

        assertEquals(2, taskManager.countCompletedTasks());
    }

    @Test
    @DisplayName("Should throw exception when completing a non-existent task")
    void shouldThrowExceptionForInvalidTaskId() {
        assertThrows(IllegalArgumentException.class, () -> {
            taskManager.completeTask("non-existent-id");
        });
    }

    @Test
    @DisplayName("Should return null for a non-existent task ID")
    void shouldReturnNullForNonExistentTask() {
        Task result = taskManager.getTaskById("non-existent-id");

        assertNull(result);
    }

    @Test
    @DisplayName("Should handle completing an already completed task")
    void shouldNotCompleteAlreadyCompletedTask() {
        taskManager.addTask("task-1", "Buy groceries");

        taskManager.completeTask("task-1");
        taskManager.completeTask("task-1");

        Task result = taskManager.getTaskById("task-1");
        assertTrue(result.isCompleted());
    }
}
Run all tests and review the output

You now have six tests covering happy paths, error handling, and edge cases. Time to run them all and confirm everything passes.

  • Right-click on TaskManagerTest in the editor or the Project sidebar.
  • Select Run 'TaskManagerTest'.

You should see six green checkmarks in IntelliJ's test runner panel. Each test name displays the text from its @DisplayName annotation, making it easy to identify what each test verifies.

Why do @DisplayName annotations matter?

Without @DisplayName, IntelliJ shows the raw method name like shouldThrowExceptionForInvalidTaskId. With it, you see a human-readable label like "Should throw exception when completing a non-existent task." When a test fails in a suite of hundreds, that readable name saves serious debugging time.

Seeing a test failure?

  • Click on the failed test name in the test runner panel. IntelliJ shows the expected value, the actual value, and a diff view.
  • If shouldThrowExceptionForInvalidTaskId fails, check that your completeTask method in TaskManager.java throws IllegalArgumentException when the task is not found.
  • If shouldReturnNullForNonExistentTask fails, verify that getTaskById returns null (not an exception) when no matching task exists.
  • Help me debug a failing test in my TaskManagerTest file.

Six tests, all green. Your TaskManager now has solid coverage for both expected behavior and unexpected input. Next up, you'll use parameterized tests to run the same test logic against multiple inputs automatically.

Write Parameterized Tests

Your tests now cover the happy path, edge cases, and error handling. That is a solid foundation for any test suite.

There is one more testing pattern that saves you from writing the same test over and over. Parameterized tests let you run a single test method with multiple different inputs automatically. Instead of copy-pasting a test for each value, you define the inputs once and let JUnit 5 do the repetition.

In this step, get ready to:
  • Create a parameterized test class with @ValueSource to test multiple task IDs.
  • Write a @CsvSource test to verify tasks with different IDs and titles.
  • Run the parameterized tests and observe the test runner output.
Create a parameterized test with @ValueSource

Think about the tests you wrote in the previous step. If you wanted to verify that addTask works for five different task IDs, you would need five nearly identical test methods. Parameterized tests solve this by running a single method once per input value.

  • Create a new Java class called TaskManagerParameterizedTest in src/test/java/com/taskmanager/.
  • Right-click the com.taskmanager package under src/test/java in the Project sidebar.
  • Select New, then Java Class.
  • Type TaskManagerParameterizedTest and press Enter.
  • Replace the contents of the new file with the following code:
package com.taskmanager;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("TaskManager Parameterized Tests")
class TaskManagerParameterizedTest {

    private TaskManager taskManager;

    // Create a fresh TaskManager before each test run
    @BeforeEach
    void setUp() {
        taskManager = new TaskManager();
    }

    // JUnit runs this test once for each string in @ValueSource
    @ParameterizedTest
    @ValueSource(strings = {"task-1", "task-2", "task-3"})
    @DisplayName("Should add and find a task by ID")
    void shouldAddAndFindTaskById(String taskId) {
        taskManager.addTask(taskId, "Sample task");

        Task result = taskManager.getTaskById(taskId);

        // Verify the task was stored and can be retrieved
        assertNotNull(result);
        assertEquals(taskId, result.getId());
    }
}

What does this code do?

  • The imports pull in @ParameterizedTest, @ValueSource, and @CsvSource from the junit-jupiter-params module. This module is already included because you added the junit-jupiter aggregator dependency earlier.
  • @ValueSource(strings = {"task-1", "task-2", "task-3"}) feeds three different string values into the test method. JUnit calls shouldAddAndFindTaskById three separate times, once per value.
  • The taskId parameter receives a different value on each run. One test method now covers three inputs without any copy-paste.

Why parameterized tests instead of separate methods?

Writing shouldAddAndFindTaskById_task1(), shouldAddAndFindTaskById_task2(), and shouldAddAndFindTaskById_task3() would test the same logic three times with duplicated code. If the assertion changes, you would have to update every copy.

Parameterized tests keep the logic in one place and let you add new test cases by adding a single value to the annotation.

  • Save TaskManagerParameterizedTest.java.
  • Right-click the file in the editor and select Run 'TaskManagerParameterizedTest'.
  • You should see 3 green checkmarks in the test runner panel, one for each value in @ValueSource.

Seeing import errors on @ParameterizedTest or @ValueSource?

  • Make sure your pom.xml uses the junit-jupiter aggregator artifact, not junit-jupiter-api alone. The aggregator bundles junit-jupiter-params which contains the parameterized test annotations.
  • Press Ctrl+Shift+O to reload Maven changes if you recently edited pom.xml.
  • Check that the import line reads org.junit.jupiter.params.ParameterizedTest, not org.junit.jupiter.api.ParameterizedTest.

Help me fix import errors for parameterized test annotations in my JUnit 5 project.

Write a @CsvSource parameterized test

@ValueSource works for single-value inputs, but your addTask method takes two parameters: an ID and a title. @CsvSource lets you pass multiple comma-separated values per test run, so you can test both parameters together.

  • In TaskManagerParameterizedTest.java, add the following method below the shouldAddAndFindTaskById method, inside the class body:
    // JUnit splits each CSV row into separate method parameters
    @ParameterizedTest
    @CsvSource({
        "task-1, Buy groceries",
        "task-2, Walk the dog",
        "task-3, Write tests"
    })
    @DisplayName("Should add a task with correct ID and title")
    void shouldAddTaskWithCorrectIdAndTitle(String taskId, String title) {
        taskManager.addTask(taskId, title);

        Task result = taskManager.getTaskById(taskId);

        // Verify both the ID and title were stored correctly
        assertNotNull(result);
        assertEquals(taskId, result.getId());
        assertEquals(title, result.getTitle());
    }

What does this code do?

  • Each string inside @CsvSource is one row of test data. JUnit splits "task-1, Buy groceries" at the comma and passes "task-1" as taskId and "Buy groceries" as title.
  • The method parameters match the order of values in each CSV row. The first value maps to the first parameter, the second value to the second parameter.
  • This test verifies that both the ID and title are stored correctly, not just that the task exists. Adding a new test case is as simple as adding another CSV row.
  • Save TaskManagerParameterizedTest.java.

Seeing a ParameterResolutionException?

  • Check that the number of values in each CSV row matches the number of method parameters. If you have two parameters (taskId and title), each row needs exactly two comma-separated values.
  • Make sure the method is annotated with @ParameterizedTest, not @Test. Using @Test with a parameter will cause a resolution error.

Help me debug my @CsvSource parameterized test.

Awesome, I've got everything!

Great. Double check you have saved your file before running the tests.

I'd like to double check the full code

Here is the complete TaskManagerParameterizedTest.java file. Compare it against yours to make sure everything matches.

package com.taskmanager;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("TaskManager Parameterized Tests")
class TaskManagerParameterizedTest {

    private TaskManager taskManager;

    @BeforeEach
    void setUp() {
        taskManager = new TaskManager();
    }

    @ParameterizedTest
    @ValueSource(strings = {"task-1", "task-2", "task-3"})
    @DisplayName("Should add and find a task by ID")
    void shouldAddAndFindTaskById(String taskId) {
        taskManager.addTask(taskId, "Sample task");

        Task result = taskManager.getTaskById(taskId);

        assertNotNull(result);
        assertEquals(taskId, result.getId());
    }

    @ParameterizedTest
    @CsvSource({
        "task-1, Buy groceries",
        "task-2, Walk the dog",
        "task-3, Write tests"
    })
    @DisplayName("Should add a task with correct ID and title")
    void shouldAddTaskWithCorrectIdAndTitle(String taskId, String title) {
        taskManager.addTask(taskId, title);

        Task result = taskManager.getTaskById(taskId);

        assertNotNull(result);
        assertEquals(taskId, result.getId());
        assertEquals(title, result.getTitle());
    }
}
Run the parameterized tests
  • Right-click TaskManagerParameterizedTest in the editor and select Run 'TaskManagerParameterizedTest'.

IntelliJ's test runner shows each parameterized invocation as a separate entry. You should see 6 green checkmarks total: 3 from the @ValueSource test and 3 from the @CsvSource test.

Notice how each input value is listed individually in the tree. If one specific input caused a failure, you would see exactly which one without guessing.

What should I see?

The test runner panel groups results under each test method. Under Should add and find a task by ID, you see three entries: [1] task-1, [2] task-2, and [3] task-3. Under Should add a task with correct ID and title, you see three entries showing each CSV pair.

Tests not appearing in the runner?

  • Make sure each test method uses @ParameterizedTest instead of @Test. A parameterized method annotated with @Test will fail or be skipped entirely.
  • Confirm your class is inside src/test/java/com/taskmanager/, not src/main/java/com/taskmanager/. IntelliJ only looks for tests under the test source root.

Help me figure out why my parameterized tests are not showing up in IntelliJ's test runner.

Secret mission

Add Priority Levels and Nested Test Classes

Your TaskManager works, but every task carries equal weight. Real projects have urgent bugs and low-priority nice-to-haves. Add a priority system to your Task and organize your growing test suite with @Nested classes for clean, hierarchical structure.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Maven project task-manager with application and test source files.
  • JUnit 5 dependency downloaded to your local Maven repository.
  • IntelliJ IDEA project configuration and run configurations.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your project files stay in the task-manager directory where you created them.
  • You can reopen the project in IntelliJ IDEA at any time.

Pause - I'll come back to this later

Since this project runs locally with no background processes, there is nothing to pause. Your files will be waiting for you when you come back.

  • Close the project in IntelliJ IDEA by selecting File, then Close Project.
  • To resume later, open IntelliJ IDEA and select the task-manager project from your recent projects list.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Close the project in IntelliJ IDEA by selecting File, then Close Project.
  • Delete the task-manager folder from your file system.
  • Optionally, clear the cached JUnit dependencies from your local Maven repository at ~/.m2/repository/org/junit.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully tested Java application from scratch using JUnit 5. Your TaskManager is backed by a professional-grade test suite that catches bugs before they ever reach production.

Here's what you learned:

  • Built a TaskManager application with clean, testable Java code and verified every feature using JUnit 5 assertions like assertEquals, assertNotNull, and assertTrue.
  • Tested error handling with assertThrows and used @BeforeEach lifecycle methods to keep every test isolated and repeatable.
  • Created parameterized tests with @ValueSource and @CsvSource to run the same test logic across multiple inputs automatically.
  • Added a priority system to Task and organized tests into @Nested classes for clean, hierarchical test structure.

Ready to quiz yourself?