Advanced

Java SE Unit Testing with JUnit

One aspect of software development that has been important in every job I've had, in every role, is testing. Unit testing of code is synonymous with development. It's possible to code wit...

Technology: Java SE 17 · JUnit 5.8.2 · Maven 3.8+


Table of Contents

  1. Course Overview
  2. Understanding JUnit
  1. Installing and Running JUnit
  1. Creating Tests
  1. Writing Test Methods and Using Assertions
  1. Leveraging Test Lifecycle
  1. Controlling Test Execution
  1. Managing Test Reporting
  1. Running Unit Tests as Part of a Build Pipeline
  1. Learning More
  2. Demo Code

1. Course Overview

My name is Jim Weaver, and I work primarily as a software developer, but have also held management, tester, architect, and customer representative roles. One aspect of software development that has been important in every job I’ve had, in every role, is testing. Unit testing of code is synonymous with development. It’s possible to code without unit tests, but in most cases it’s not a good idea, and your managers probably won’t find it a good idea either. This is perhaps the most essential technical skill for software developers to acquire, outside of simply learning to write good code in itself.

This course will bring you up to speed on the most widely used unit testing tool in the Java world: JUnit. It will prepare you to write tests with JUnit alongside your production code. This course is practical and concretely focused on the aspects of JUnit and unit testing that you will use most often.

What you will learn in this course

  • Configure JUnit for a Java project
  • Run JUnit from Maven and from an IDE
  • The basics of writing unit tests, including applications of the most commonly used assertions
  • The JUnit test lifecycle (@BeforeEach, @AfterEach, @BeforeAll, @AfterAll)
  • Control test execution (parameterized tests, disabled tests, tags, conditional execution)
  • Improve test output reports (display names, failure messages, nested tests)
  • Exploit test coverage data (test coverage)
  • Use double test to isolate dependencies

At the end of this course, you will be able to write unit tests for your own Java projects.


2. Understanding JUnit

2.1 Introduction

Before we get into the course, let’s establish the versions of the software we’ll be using throughout. This course was created with Java Standard Edition 17. If you want to follow along without modifying the example code, you should use Java SE 17 or later. However, since the focus of this course is on unit testing, very little of the content is Java version specific, and what you will learn is entirely applicable to earlier and later versions of Java, particularly any version of Java after Java 8.

The version of JUnit we will use is more important: it is JUnit 5.8.2, the latest version of JUnit at the time of registration. Much of this course will also apply to earlier major versions of JUnit, versions 3 and 4, but a good portion is specific to JUnit 5, which was a rewrite of JUnit. During the course, features that have been added with JUnit 5 will be pointed out.

What is JUnit?

JUnit is the most widely used unit testing library for Java. You can write test code with JUnit which allows you to check production code and make sure it works the way you think it should. Testing libraries like JUnit provide the tools needed to verify the results of the production code you want to test, often called code under test. There are other Java testing libraries, but JUnit is by far the most common one you’ll encounter in Java stores.

JUnit is also important in that it propagated unit testing as a development practice. It was the very first widely used testing library in any language. And after JUnit appeared, many language stacks copied JUnit to create their own unit testing libraries. JUnit isn’t just a Java unit testing library — it’s basically what launched unit testing as a practice for the entire software development industry. As Martin Fowler summed up regarding JUnit: “Never in software development have so many owed so much to so few lines of code.”*

In this course, you will learn how to create and run unit tests, and apply a wide range of assertions made available in JUnit to verify production code. We will learn to control the execution of tests by applying the features of the JUnit life cycle and also through conditional and repeated execution of tests. We will also learn how to improve reporting of test results.

This course will not cover why you should unit test — this course is focused on the how of using JUnit. But nevertheless, working with our examples, we will definitely understand some of the benefits of writing and having unit tests for a project. This course will not cover test-driven development (TDD) specifically, but another course on this learning path will.

In terms of prerequisites: you need to know a little Java.

2.2 Understanding the JUnit Library

JUnit is simply a Java project dependency. It is included in a project by declaring a dependency on the Java Archive (JAR) files that make up JUnit. The necessary JUnit JARs simply need to be on the Java classpath to be able to write and run JUnit tests.

Java projects often rely on third-party products, especially open source libraries, and these are most often included in projects via JAR file dependencies. JUnit is no different, although it is a dependency you only need when testing and building the application, not when running in production.

Unit tests themselves are simply contained in Java class files — your test code is simply Java code. Classes containing tests are part of a Java project, but are normally kept separate from production classes in a separate directory. A single unit test class can and usually contains multiple tests, one method for each test.

Structure of a test class

class DurationUnitTest {

    @Test
    public void matchUnitBySingularString() {
        assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("week"));
    }

    @Test
    public void matchUnitByPluralString() {
        assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("weeks"));
    }
}

Note: with modern versions of JUnit and JUnit test runners, there is nothing magical about the class name. It is not required to have “test” at the end of the class name, but it is a common convention.

JUnit 5 — A complete rewrite

JUnit 5 represented a significant change from earlier versions of JUnit. It was published in 2017. It was also a complete rewrite of the JUnit codebase, and one of the main goals was to take advantage of some of the dramatic new features released with Java 8, particularly Java 8’s support for lambda expressions and methods that could be treated as data.

JUnit 5 also consists of multiple JAR files, whereas earlier versions of JUnit were all a single JAR file. The three components of JUnit 5 are:

  • Jupiter: contains the API for writing tests
  • Platform: to run them and provides an API for other tools like IDEs to integrate with JUnit
  • Vintage: Provides support for running tests from older versions of JUnit with the JUnit 5 runner, allowing a way for projects to use JUnit 5 without having to convert existing JUnit 3 or 4 tests to JUnit 5 syntax

This division into several JARs by JUnit 5 allows it to better support integration with other tools than previous versions.

2.3 Following Along

To follow with your own machine you will need:

  • Java SE 17 or later
  • Maven 3.8 or later (although it is possible for this project to work on any earlier version of Maven 3)
  • An IDE — this course uses IntelliJ, but Eclipse will also work if you are familiar with it
  • Git to clone the source repository (or download a ZIP file of the project)

The source code for this course is available at: github.com/weaverj/testingwithjunit

There is a README file with follow-up instructions and you can get this code either by cloning (if you have Git) or by downloading a ZIP archive. There are also ZIP files in the Files tab of this course on Pluralsight.

Note: there are two branches:

  • The Main branch for the start of the course
  • The Complete branch which contains all the code and tests that we will work on during the rest of the course

3. Installing and Running JUnit

3.1 Adding JUnit as a Project Dependency

In this module, we will configure JUnit for our example project and run our first unit test.

The sample project is a Java Maven project. In Maven, Project Object Model (POM) files declare how the project should be built and what its third-party dependencies are. The POM file for this project is located at the top level, pom.xml.

Declaration of JUnit dependency in pom.xml

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

The scope test for the JUnit dependency tells Maven that this dependency is only used to test the project, it is not needed by production code. Maven will then ensure that production code cannot use JUnit classes, and this also ensures that JUnit libraries do not end up in the production artifact that is built for the project.

maven-surefire plugin

In the build section, note the declaration of the plugins maven-surefire and failsafe with a specified version. These two Maven plugins both have to do with running unit tests while building the project. They are part of Maven, and it is not normally necessary to specify them this way. But if you leave them out, Maven will use a default version of these plugins. The problem with this is that some older versions of these plugins do not support discovering and running JUnit 5 tests, so it is safer to specify them and ensure a recent version is used.

<build>
    <plugins>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>2.22.2</version>
            <configuration>
                <groups></groups>
                <excludedGroups></excludedGroups>
            </configuration>
        </plugin>
    </plugins>
</build>

JUnit Documentation

JUnit documentation is available at junit.org/junit5. The user guide contains excellent documentation. For installation instructions in a Maven project, look under “Build Support” → “Maven” in the guide navigation bar.

As you take this course, if newer versions are referenced, feel free to modify the sample project POM file to reference these newer versions.

3.2 Running Tests in an IDE

When programming in an IDE, you frequently run unit tests from the IDE rather than through Maven. If we make changes to a small set of classes, we repeatedly run the test for just those classes rather than building the entire system between incremental changes.

Running in IntelliJ IDEA

To explore the code, open the source tree in the project sidebar and go down to the rxwriter.prescription package. The DurationUnit class is a Java enumeration, and it has three instances representing duration units of days, weeks, and months, each with their own multiplier integer value. There is also a static method that returns one of these three instances of duration units based on a string passed as a parameter.

A unit test for this code is already created in the test folder, in the DurationUnitTest class, in the same rxwriter.prescription package as the enumeration it is testing.

Ways to run tests in IntelliJ:

  • Right click on the class name → “Run DurationUnitTest”: executes all tests of the class
  • Right click on a method name: executes an individual test
  • Double-click on a test in the plan to navigate directly to that method
  • Green arrow in the Run window: re-executes the last run

The results are displayed in the IntelliJ Run window with an outline showing the test classes and methods executed. Green checkmarks indicate successful tests.

First test failed

To see a failure, we can go to the DurationUnit production class and modify the plural form of “weeks” to corrupt it. The matchUnitByPluralString test then fails. We can see the expected value and the actual value returned by the production code. You can double-click on the failed test to navigate to it in the editor.

We correct the code, we re-run the tests, and we are back to green.

3.3 Running Tests with Maven

We can run the tests via our Maven build system. From a terminal at the root of the project, the following command will compile the application and run all the tests:

mvn test

Maven reports a successful build and how many tests were found and run, along with the results.

Alternative in IntelliJ: there is a Maven panel in IntelliJ (accessible from the right side) that shows the different Lifecycle lifecycle goals. The test objective can be executed directly from this panel, without opening a terminal.

Recommended practice:

  • Run tests via IDE during active programming
  • Before committing your changes to the source code repository, perform a local Maven build (which runs all the tests)
  • Build automation and DevOps tools will often use Maven to run unit tests of a Java project before deployment

3.4 Debugging from a Test in an IDE

Sometimes, when a test fails, it may be useful to enter a debug session from that test. In the DurationUnitTest in IntelliJ, we right-click and select “Debug test”.

The test runs, but we do not enter the debugger because there is no breakpoint defined. We open the DurationUnit class and in the method under test, we add a breakpoint. Then, by re-running the test, the execution stops at the breakpoint and we can inspect the value of the variables, as well as control the execution line by line.

This capability is not used often because we typically conduct code changes from unit tests in small increments — which generally reduces the need to enter a debugging session — but it can be very useful from time to time.

3.5 Viewing Test Coverage

IDEs like IntelliJ often offer a quick way to see what production code has been covered by the unit tests you have just run.

In the context menu of the DurationUnitTest, under the Run and Debug options, there is a “More” item which contains an option to run the selected test with coverage (“Run with Coverage”).

The result includes:

  • The Coverage panel indicating what percentage of the code base, as well as for particular packages, was covered by the test
  • In production code, green bars on the left indicate lines of code executed during testing
  • pink bars indicating lines that have not been executed — these are gaps in test coverage and indicate places where further testing may be needed

For example, this method under test may return null, which is an interesting design decision and this case is not covered by existing test methods.


4. Creating Tests

4.1 Understanding When to Create Tests

What type of code requires testing? Should all code have tests created for it?

Code that deserves unit testing

Certainly, code with important, non-trivial business logic is worth unit testing. Here are some examples:

  • Prescription validation logic: a method that validates a medical prescription and returns an RxValidation object. Validation logic is critical to the application.
  • Code with conditions and transformations: like the DurationParser class which translates duration strings into number of days.

Lower priority code for unit tests

  • Very trivial or pass-through code with no logic of its own — for example, an IDE-generated getter method
  • Thin technology layers without business logic — for example, an initDrugEndpoint method that just calls third-party library functions to serialize data into JSON

There are other types of tests (integration tests, component tests, end-to-end tests) that you can rely on outside of unit tests to cover the full functionality of your application. However, if you mix business logic into this technology layer, you will probably need unit testing.

The goal of 100% test coverage is often not really useful. Prioritize important business logic, domain logic for your unit tests.

When to create the tests?

Create them early. Old code written a long time ago without any testing can be very difficult to test later. It’s best to write tests for a feature while you’re initially working on it. Starting a test even before writing production code for a feature can be useful in some cases — this is TDD (Test-Driven Development), which is the subject of a separate course on this learning path.

4.2 Creating Test Classes

In the example project, the DurationParser class is in the rxwriter.prescription package under the src/main/java directory. This is production code, but there is no unit test for it yet.

parseDays method — the code under test

public static int parseDays(String durationString) {
    String[] parts = durationString.split(" ");
    if (parts.length == 2) {
        return unitValueCalculation(parts[1], parts[0]);
    }
    else if (parts.length == 1) {
        if (parts[0].equalsIgnoreCase("once")) return 1;
    }
    return 0;
}

This method translates a simple string like “2 weeks” or “3 days” or “1 month” into an integer representing a number of days. The logic has conditionals based on the number of words in the duration string and a default of returning 0. This is business logic that requires unit testing.

Creating a test class with IntelliJ

The quick way to create a test class in IntelliJ:

  1. Place the cursor in the source Java class (e.g. DurationParser)
  2. Menu NavigateTest (or use the keyboard shortcut)
  3. Select “Create New Test”
  4. A dialog box opens with options — by default, JUnit 5 is selected
  5. The default test class name is DurationParserTest — this is the convention

The test class is created in the correct part of the test directory tree and in the same Java package as the production class being tested — these conventions are important and make navigation easier.

Naming convention: TestClassName with Test at the end of the class name.

Note: If you return to production code and press the Test shortcut, you now have an option to navigate to this test or create yet another test class. Most of the time, you just have one test class for a production class, but JUnit supports multiple test classes for a single production class.

4.3 Creating Test Methods

To create a test method, you must precede the method declaration with the JUnit annotation @Test.

class DurationParserTest {

    @Test
    public void parseDurationWithValidUnitAndQuantity() {
        // une méthode de test vide - elle passe (sans assertion = pas d'échec)
    }
}

Characteristics of a test method

  • Method is annotated with @Test (imported from Jupiter API JAR)
  • This is a void method — it doesn’t need to return anything
  • You can declare it public, but this is not obligatory — it can be at package or default level
  • Only methods annotated with @Test are tests — unannotated methods can be in the class and called from test methods, but JUnit will not execute them

Important rule

If a test contains no assertions, it will still pass but be meaningless. You can run an empty test to check that everything is configured correctly, but you will then need to add assertions to make it useful.

It is not necessary to inherit from a superclass to have tests. You can also subclass and inherit test methods from superclasses, but in over 20 years of experience, the instructor has never done this once.


5. Writing Test Methods and Using Assertions

5.1 Using JUnit Assertions

Assertions are JUnit’s way of allowing you to compare the actual results of calling production code with the expected results and monitor whether the tests pass or fail.

All JUnit assertions are static methods on the Assertions class of the JUnit Jupiter API JAR. There are many JUnit assertions — you can scroll for a very long time through the API documentation without exhausting the assertions, although many are overloads of assertEquals or similar assertions for different data types.

General form of an assertion

assertEquals(valeurAttendue, valeurRéelle);
assertEquals(valeurAttendue, valeurRéelle, "message d'échec optionnel");
assertEquals(valeurAttendue, valeurRéelle, () -> "message d'échec en lambda");
  • First argument: the expected value — what we expect the code to return
  • Second argument: the real value — the result we obtained by calling our production code
  • Third argument (optional): a custom failure message

Warning: if you cross the expected and actual arguments, the assertion will still work, but the failure message will be confused. Always expected first, actual second.

Behavior on assertion failure

If an assertion fails, it bypasses the test method — no further code will be executed. JUnit throws an AssertionError which the test runner collects, then continues running the other test methods.

If you have two assertions in the same test method and the first fails, the second will never be executed. This is one of the reasons why it is better to have only one assertion per test method.

5.2 Asserting Equality and Identity

assertEquals — Value equality

Comparing the expected result to the actual result in a unit test with assertEquals is by far the most common use of JUnit.

@Test
public void parseDurationWithValidUnitAndQuantity() {
    assertEquals(14, DurationParser.parseDays("2 weeks"));
    assertEquals(30, DurationParser.parseDays("1 month"));
}

Note: We always test via the main public method of the class. The private methods that the parser uses to do its job will be tested as part of this test. This is typical — testing public methods and package-level methods. In situations where you find yourself wanting to test a private method separately, this usually means there is a design problem in the production code.

There is also assertNotEquals which is the opposite, but it is rarely needed.

assertSame — Object identity (same reference in memory)

For Java enums, which are single object instances, we use assertSame to verify that the expected and actual values ​​are the same exact object in memory:

@Test
public void matchUnitBySingularString() {
    assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("week"));
}

@Test
public void matchUnitByPluralString() {
    assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("weeks"));
}

There is also assertNotSame, but it is rare to need it.

assertNull and assertNotNull

The DurationUnit.getByTextValue() method may return null if it cannot match the string passed to one of its enumeration instances. We can test this functionality:

@Test
public void returnsNullForUnmatchedUnit() {
    assertNull(DurationUnit.getByTextValue("boop"));
}

The instructor uses assertNotNull even more often than assertNull.

5.3 Asserting Boolean Values

assertTrue and assertFalse are two easy JUnit assertions with the expected value embedded in the assertion name.

Example: DrugConcept.isDrugInConcept()

The DrugConcept class represents a broader grouping of drugs. The isDrugInConcept method takes a drug as a parameter and determines if that drug has any associated classifications that would match a concept’s classifications. Mathematically, it looks for a non-empty intersection between the list of classifications of the concept and that of the drug.

class DrugConceptTest {

    private final static DrugConcept TEST_CONCEPT = new DrugConcept(new DrugClassification[]{
            DrugClassification.ANTIANXIETY,
            DrugClassification.ANALGESICS_NARCOTIC,
            DrugClassification.NARCOTIC_ANTHISTAMINE});

    @Test
    void drugBelongsInConceptIfOneClassMatches() {
        DispensableDrug drug = new DispensableDrug("adrug",
                new DrugClassification[] {DrugClassification.ANALGESIC,
                        DrugClassification.ANTIANXIETY}, false);
        assertTrue(TEST_CONCEPT.isDrugInConcept(drug));
    }

    @Test
    void drugNotInConceptIfNoClassesMatch() {
        DispensableDrug drug = new DispensableDrug("adrug",
                new DrugClassification[] {DrugClassification.ANALGESIC,
                        DrugClassification.NASAL_CORTICOSTEROIDS}, false);
        assertFalse(TEST_CONCEPT.isDrugInConcept(drug));
    }
}

Important practice: we use a TEST_CONCEPT defined in the test class rather than the OPIATES concept of production, because this production concept could change for good reasons but in a way that would cause our test to fail. We don’t want the test to fail because the production data changes in an expected way.

The usefulness of tests for refactoring

Now that tests are in place, we can consider the initial implementation (which used retainAll on temporary collections) and try an alternative using Java 8 streams and the anyMatch operation. Testing provides confidence to change existing code — if the tests still pass, the new implementation is correct.

5.4 Asserting Collections

Verifying the contents of collections is a fairly common testing need. JUnit never supports this check as well. We apply the learned assertions to validate the collections.

Example: DrugService.findDrugsStartingWith()

The DrugService class has a public method whose job is to find and return drugs whose name begins with the string passed as a parameter. It calls a drug database, converts the returned records into DispensableDrug instances and returns these in a sorted list.

@Test
void drugsAreReturnedSorted() {
    DrugService drugService = new DrugService(new DrugDatabase());
    List<DispensableDrug> foundDrugs = drugService.findDrugsStartingWith("as");
    
    assertNotNull(foundDrugs);
    assertEquals(2, foundDrugs.size());
    assertEquals("asmanex", foundDrugs.get(0).drugName());
    assertEquals("aspirin", foundDrugs.get(1).drugName());
}

Incremental multiple assertion approach

The approach with assertNotNull first (to avoid a NullPointerException on subsequent assertions), then checking the size, then checking each element individually, is often the most practical.

Alternative by assertEquals on an expected list:

One could try to compare directly with assertEquals(expectedList, actualList), but this may fail due to the fact that DispensableDrug contains an array of DrugClassification[]. Arrays do not support a deep equality comparison via equals() by default, so the list comparison fails.

Recommendation: The instructor usually uses incremental multiple assertions for collections, because directly comparing an expected list to an actual list can cause unexpected problems.

There is the AssertJ library which can be used as an extension to JUnit and provides more convenience methods for checking collections.

5.5 Understanding Common Test Method Structure

After writing a few tests, let’s take a step back and consider some common aspects of writing test methods. A well-structured unit test generally follows three steps:

The “Setup, Kick, Verify” pattern (Arrange-Act-Assert)

1. Setup

Preparation before calling production code:

  • Configuring expected results
  • Initializing production code to call
  • Configuring arguments to pass to production code
// Setup
DrugConcept testConcept = new DrugConcept(...);
DispensableDrug drug = new DispensableDrug("adrug", new DrugClassification[] {...}, false);

2. Kick (Action)

Calling production code to see what it does:

// Kick
boolean result = testConcept.isDrugInConcept(drug);

In a unit test, the “kick” is often directly integrated into the assertion as an argument. Extracting it on a separate line improves readability.

3. Verify

Ensure that the code returned the expected results or is in the correct state. In JUnit, this is done with assertions:

// Verify
assertTrue(result);

4. Teardown — optional

Cleaning required between tests for the next test to start in a clean state. Teardown logic is rarely needed for typical unit tests.

Test types: state-based vs. behavior-based

  • State-based testing: we check the returned values ​​or the state of a returned object. This is 90-99% of tests written for most systems.
  • Behavioral/interaction-based testing: we check what the production code does when it is called, what its interactions are, what other methods are called. Often used to verify that third-party libraries or remote systems are called correctly. mock frameworks help enormously with this type of testing.

5.6 Asserting Expected Exceptions

Sometimes you are testing code that is supposed to throw an exception in certain scenarios and you want to make sure that it actually throws that exception.

Example: DrugService and argument validation

The findDrugsStartingWith method has a guard clause at the beginning:

public List<DispensableDrug> findDrugsStartingWith(String startsWith) {
    if (startsWith == null || startsWith.isBlank()) {
        throw new IllegalArgumentException("Starts with string must be non-null, non-blank, " +
                "and at least two characters.  String provided: [" + startsWith + "]");
    }
    // ...
}

assertThrows — New in JUnit 5

@Test
void throwsExceptionOnBlankStartsWith() {
    DrugService drugService = new DrugService(new DrugDatabase());
    Exception thrown = assertThrows(IllegalArgumentException.class,
            () -> drugService.findDrugsStartingWith("  "));
    System.out.println(thrown.getMessage());
}
  • The first argument: the type of exception we expect to see thrown
  • The second argument: a lambda expression which invokes the code that we expect to see throw the exception

The fact that this is a lambda expression allows the JUnit framework to catch the exception and validate it rather than blowing the exception up to the test method and terminating it at runtime. JUnit 5 has many features that take advantage of lambda expressions introduced with Java 8.

assertThrows returns the exception that was thrown, allowing the message to be validated on the exception or other properties if it is important.

JUnit 4 note: We could say that exceptions are thrown with JUnit 4 using an annotation, but it is not as simple and clean as what we do here with JUnit 5.

5.7 Grouping Assertions with assertAll

Sometimes you need multiple assertions to validate data that is logically a single validation, and you would prefer not to have a short circuit if one of those assertions fails.

Problem with multiple assertions

If you have multiple assertions in a test method, the first one that fails stops execution — the others are never executed.

Solution: assertAll

@Test
@DisplayName("return dispensable drugs with all properties set correctly from database")
void setsDrugPropertiesCorrectly() {
    List<DispensableDrug> foundDrugs = drugService.findDrugsStartingWith("aspirin");
    DrugClassification[] expectedClassifications = new DrugClassification[] {
            DrugClassification.ANALGESIC, DrugClassification.PLATELET_AGGREGATION_INHIBITORS
    };
    DispensableDrug drug = foundDrugs.get(0);
    assertAll("DispensableDrug properties",
            () -> assertEquals("aspirin", drug.drugName()),
            () -> assertFalse(drug.isControlled()),
            () -> assertEquals(2, drug.drugClassifications().length),
            () -> assertArrayEquals(expectedClassifications, drug.drugClassifications())
    );
}
  • The first argument: a title text for the assertion group
  • The following arguments: individual lambda expressions each containing an assertion

With assertAll, JUnit executes all of these individual assertions without a failure of any of them stopping execution. If you cause more than one of these assertions to fail, both are reported.

assertAll is used infrequently by the instructor, but in cases where you are testing code that populates an object’s properties from another data source, it is very handy.

5.8 Understanding Test Doubles

Using test doubles is a crucial testing skill for a Java developer — in fact, for any object-oriented developer. This is not specific to JUnit, but it is very relevant for unit testing.

The dependency problem

The dependencies of the code that we are testing can cause problems:

  • They can access an external system or a database over which we have no control
  • The data they return may be unreliable, causing the test to fail even if the logic being tested has not changed and is still correctly correct
  • Dependency can also be slow
  • Sometimes this dependency is down

Concrete example: The DrugService class that we tested calls a drug database. If a vendor adds a new drug starting with “as” to the database, our test that expected two drugs starting with “as” will now fail — even though the DrugService logic is perfectly correct.

The solution: the double test

Introduce a double test of the dependency from the test. The unit test replaces the real dependency with a fake one that looks exactly like the real dependency for the code being tested, allowing the test to control what this duplicate does.

Instead of the drug service calling the real database to get raw drug data, it is fooled. We give it a fake database to call instead, and this fake returns the data our test needs. The false one is also completely immune to changes external to our system.

This technique relies on Java interfaces and an object-oriented technique called dependency injection.

5.9 Using Test Doubles

Step 1: Prepare the dependency for injection

Modify the DrugService production code to accept a dependency via the constructor:

public class DrugService {

    private DrugSource drugSource;

    public DrugService(DrugSource drugSource) {
        this.drugSource = drugSource;
    }

    public List<DispensableDrug> findDrugsStartingWith(String startsWith) {
        // ...
        List<DrugRecord> records = this.drugSource.findDrugsStartingWith(startsWith);
        // ...
    }
}

The DrugSource interface has only one method defined on it — the findDrugsStartingWith method. By using the interface as the type of the constructor parameter, we can pass any implementation, including a false one.

Step 2: The Self-Shunt Technique

A clever old technique: make the test class itself implement the DrugSource interface.

@DisplayName("DrugService should")
class DrugServiceTest implements DrugSource {

    private DrugService drugService;

    @BeforeEach
    void setup() {
       drugService = new DrugService(this);  // on passe "this" comme DrugSource
    }

    // Implémentation de DrugSource - les données de test sont ici
    @Override
    public List<DrugRecord> findDrugsStartingWith(String startingString) {
        List<DrugRecord> records = new ArrayList<>();
        if (startingString.equals("as")) {
            records.add(new DrugRecord("asmanex", new int[] {301}, 0));
            records.add(new DrugRecord("aspirin", new int[] {3645, 3530}, 0));
        }
        else if (startingString.equals("aspirin")) {
            records.add(new DrugRecord("aspirin", new int[] {3645, 3530}, 0));
        }
        return records;
    }
}

We create a loop: the test calls the DrugService and the DrugService calls its drugSource to obtain raw data on drugs. But without the DrugService knowing it, this call returns directly to the test instance. Confusing, but effective.

Test data is now fully under the control of the test class. If a vendor adds new drugs to the actual database, our tests continue to pass.

Alternatives for creating test doubles

  • Self-shunt (as above) — old technique, little used today
  • Inner class on the test that implements the interface
  • Separate class in the test source tree, a helper class
  • Mock library — the most common way today

With Mockito (mock library)

// Avec Mockito (dépendance Maven supplémentaire nécessaire)
DrugSource mockDrugSource = mock(DrugSource.class);
when(mockDrugSource.findDrugsStartingWith("as"))
    .thenReturn(List.of(
        new DrugRecord("asmanex", new int[] {301}, 0),
        new DrugRecord("aspirin", new int[] {3645, 3530}, 0)
    ));
DrugService drugService = new DrugService(mockDrugSource);

This is an advanced technique — no matter how you do it, it can be confusing. But it’s a crucial technique and one that you’ll need to use often on real projects.


6. Leveraging Test Lifecycle

6.1 Understanding JUnit Test Lifecycle

How are test classes instantiated?

The default JUnit behavior is per-method test class instantiation. When a test class is executed, separate instances of the class are created, one to execute each annotated test method.

Why? The isolation achieved by running each test method against a new and distinct test class instance tends to keep the tests much more independent of each other. If you have a property on a test class (a member variable), that property will be initialized and fresh every time the test method executes — which prevents state from being kept between tests.

Per-class mode: There is a per-class lifecycle if you need it. If you set this option (@TestInstance(TestInstance.Lifecycle.PER_CLASS)), a given test class is instantiated only once and all test methods on that class are executed on that same instance.

For real unit testing, the default per-method mode is the safest and easiest. The per-class mode for keeping state between multiple test method executions can be used for certain types of non-unit testing.

Lifecycle Methods

A lifecycle method is any test class method annotated with a JUnit lifecycle annotation:

AnnotationWhen it runs
@BeforeAllOnly once, before all test methods (must be static with lifecycle per-method)
@BeforeEachOnce before each test method
@AfterEachOnce after each test method
@AfterAllOnly once, after all test methods (must be static with lifecycle per-method)

Execution order for a class with two test methods

Here is what happens when running a class that has @BeforeAll, @BeforeEach, two methods @Test, @AfterEach, and @AfterAll:

BeforeAll s'exécute (une seule fois)
├── BeforeEach s'exécute
├── testOne s'exécute
└── AfterEach s'exécute
├── BeforeEach s'exécute
├── testTwo s'exécute
└── AfterEach s'exécute
AfterAll s'exécute (une seule fois)

Method names don’t matter — the annotation is what makes them lifecycle methods.

JUnit Note 3/4: Similar annotations exist in JUnit 4 (@Before, @After, @BeforeClass, @AfterClass). In JUnit 3 there are no annotations but the same hooks are provided via special method names.

6.2 Setting up Tests with @BeforeEach

The most common use of a lifecycle method when unit testing with JUnit 5 is a @BeforeEach to perform the common setup code needed for each test method.

Problem: Repeated configuration code

In the DrugServiceTest, each test method instantiates a DrugService to test:

@Test
void drugsAreReturnedSorted() {
    DrugService drugService = new DrugService(this);  // code dupliqué dans chaque test
    // ...
}

Solution: @BeforeEach

@DisplayName("DrugService should")
class DrugServiceTest implements DrugSource {

    private DrugService drugService;  // variable d'instance

    @BeforeEach
    void setup() {
       drugService = new DrugService(this);  // un seul endroit pour la configuration
    }

    @Test
    @DisplayName("return drugs from the database sorted by drug name")
    void drugsAreReturnedSorted() {
        List<DispensableDrug> foundDrugs = drugService.findDrugsStartingWith("as");
        // ...
    }
}

Lifecycle reminder: Based on the default per-method lifecycle of JUnit 5, to run the drugsAreReturnedSorted test:

  1. A new instance of DrugServiceTest is created
  2. The setup method is called on this instance, creating the DrugService
  3. The drugsAreReturnedSorted test method is executed on this instance

Recommendation: Even if you only have a single line of configuration code shared between each test method, this is still the recommended way to handle this type of code — use a @BeforeEach method.

6.3 Creating Other Tests Lifecycle Methods

To see all the lifecycle hooks in action, here is an example of a skeleton test class:

public class LifecycleTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("BeforeAll executed");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("BeforeEach executed");
    }

    @Test
    void testOne() {
        System.out.println("Test one executed");
    }

    @Test
    void testTwo() {
        System.out.println("Test two executed");
    }

    @AfterEach
    void afterEach() {
        System.out.println("AfterEach executed");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("AfterAll executed");
    }
}

Exit at runtime:

BeforeAll executed
BeforeEach executed
Test one executed
AfterEach executed
BeforeEach executed
Test two executed
AfterEach executed
AfterAll executed

Exercise: try to concatenate the keyword this to the println lines of all these methods except @BeforeAll and @AfterAll. You should see that the object ID of the test class instance in each BeforeEach-test-AfterEach group is distinct — two test class instances were used in total.


7. Controlling Test Execution

7.1 Understanding Test Execution Options

JUnit offers many test execution control options. Here is an overview of the available capacities.

New test types

TypeDescription
Parameterized testsCall the same test method multiple times with different arguments passed as parameters
Repeated testsRun the same test method multiple times without variation in arguments (useful for load or performance testing)
Dynamic testsAllows you to create new test methods on the fly at runtime

Execution options applicable to all tests

OptionsDescription
Conditional executionWide variety of conditions for running or not running a test
Disabled testsTemporarily disable a test
Test orderingControl the order of test execution
TimeoutsImpose wait times on tests
Parallel executionRun tests in parallel rather than in sequence

For true unit testing, the most useful features are parameterized tests and conditional execution via tags and @Disabled. Parallel testing and repeated testing are generally more applicable to integration or performance testing.

7.2 Parameterizing Tests

The ability to parameterize tests in JUnit is one of the most useful runtime control features. This feature allows a single test method to be run multiple times with different arguments passed each time.

Parameterized tests require the JUnit 5 junit-jupiter-params JAR. The way our example project declares the JUnit dependency in the Maven POM includes this JAR.

JUnit 4 note: JUnit 4 also includes this functionality, but its implementation is quite different from that of JUnit 5.

Available argument sources

  • @ValueSource: simple inline values (includes null and empty values)
  • @EnumSource: enumeration instances
  • @MethodSource: a method in the test class provides the arguments programmatically
  • @CsvSource: inline multiple values in CSV format (comma separated values)
  • @CsvFileSource: an external CSV file provides the arguments
  • Class ArgumentsProvider: a separate class implementing a provided interface

Each invocation of a parameterized test method takes part in the normal JUnit lifecycle — the @BeforeEach and @AfterEach methods will frame each invocation.

Progressive example

Step 1 — Simple test with @ValueSource:

@ParameterizedTest
@ValueSource(strings = {"2 weeks", "1 month"})
public void parseDurationWithValidUnitAndQuantity(String durationString) {
    assertEquals(14, DurationParser.parseDays(durationString));  // hard-coded, donc incorrect pour "1 month"
}

Step 2 — Two parameters with @CsvSource:

@ParameterizedTest
@CsvSource({"2 weeks, 14", "1 month, 30", "5 days, 5", "once, 1"})
public void parseDurationWithValidUnitAndQuantity(String durationString, int expectedDays) {
    assertEquals(expectedDays, DurationParser.parseDays(durationString));
}

In the test report, each invocation is listed separately with the argument value.

Two important considerations:

  1. Too many arguments passed in a single test method can make cases difficult to read — use this feature with caution
  2. Special cases may merit their own test methods — for example, the “once” case is a special case that does not take the form of DurationUnit and DurationQuantity like the other cases, so it might merit its own test method to make the requirement stand out more

7.3 Disabling Tests

Sometimes a test temporarily fails, but you don’t want to delete it. JUnit 5 provides an ability to disable tests with an annotation.

@Test
@Disabled("Feature not yet fully implemented")
void someTest() {
    // ...
}
  • A reason string can be provided for disabling the test — recommended, especially if the test is going to be disabled for a while
  • If we run the test class, the report still shows the test but indicates that it was not run and also shows the reason provided
  • We can also disable an entire test class by placing the same annotation above the class declaration

Advantage over code commenting: when you run the tests, you are not reminded of the tests that are commented out so it is easy to forget them. Disabling testing via the unit test framework allows the test runner to skip these tests as desired, but continues to remind you of the presence of these disabled tests.

JUnit 4: the @Ignore annotation in JUnit 4 works almost identically to @Disabled.

7.4 Tagging and Filtering Tests

Tagging tests and then using those tags to control which tests are run for a particular test execution allows tests to be run in increments, in layers. This can be particularly useful if you have mainly fast unit tests in a project, but also some slower integration or database layer tests.

Apply tags

@Test
@Tag("Database")
void drugsAreReturnedSorted() {
    // ...
}

@Test
@Tag("Database")
@Tag("Integration")
void setsDrugPropertiesCorrectly() {
    // ...
}

The instructor recommends using this feature with caution. If you have too many tag names, remembering and using them effectively can become difficult. In most codebases, we rarely need tags in our unit tests at all, and when we did, it was typically just a handful.

Filter by tag in IntelliJ

In the Run configuration, you can specify a tag expression to control which JUnit 5 tests to include or exclude. The expression can include multiple tags delimited by commas, the NOT operator, the AND and OR operators, and other typical expression syntax.

Filter by tag in Maven

In the Maven Surefire plugin:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>2.22.2</version>
    <configuration>
        <groups>Database</groups>
        <excludedGroups>SlowTests</excludedGroups>
    </configuration>
</plugin>

For Maven with multiple test runs targeting different tag expressions, you will need to use Maven profiles to create these different run targets.

7.5 Conditional Test Execution

JUnit provides a large number of ways to run tests conditionally.

Full range of options

  • @Disabled: disable tests
  • Filtering by tags
  • @EnabledOnOs / @DisabledOnOs: target by operating system
  • @EnabledOnJre / @DisabledOnJre: target by JRE version
  • @EnabledIfSystemProperty: enable/disable based on system properties
  • @EnabledIfEnvironmentVariable: enable/disable based on environment variables
  • Custom Conditions: Programmatically create custom conditions in JUnit for complete flexibility

Examples

@Test
@EnabledOnJre(JRE.JAVA_8)
void testOnlyOnJava8() {
    // s'exécute seulement si JRE version Java 8
}

@Test
@EnabledOnJre(minVersion = JRE.JAVA_11, maxVersion = JRE.JAVA_17)
void testOnJava11To17() {
    // s'exécute si JRE version 11 à 17
}

@Test
@EnabledOnOs(OS.WINDOWS)
void testOnlyOnWindows() {
    // s'exécute seulement sur Windows
}

@Test
@EnabledOnOs({OS.WINDOWS, OS.MAC})
void testOnWindowsAndMac() {
    // s'exécute sur Windows et Mac
}

Corresponding @Enabled* and @Disabled* annotations exist for each condition.

Of all the options, tagged testing and tag filtering is the most used conditional execution feature on real projects.


8. Managing Test Reporting

8.1 Understanding the Importance of Test Reports

As your application grows, test reports provide a way to be aware of what tests are running, how long they are taking, and of course, if they fail, they are your entry point to identify and correct the problem.

Maven Report

A Maven JUnit report shows:

  • Each test class executed and the number of tests in each class
  • Time spent running tests in each class (helps identify slow tests)
  • On failure, the JUnit assertion message for that failure

Individual method names are not listed in Maven test summaries (with Surefire plugin version 2.22).

IDE Report (IntelliJ)

  • More useful than simple console output from Maven
  • Provides each test method executed
  • Allows you to take advantage of JUnit reporting features
  • Navigable — you can click directly through a report entry to the associated test

Guidelines for Better Reporting

  1. Minimize console output in tests — this makes reports harder to navigate and takes up more disk space
  2. Make the purpose of each test clear in the report
  3. Use JUnit 5 display names and nested tests to improve readability
  4. Clear failure messages when a test fails

8.2 Providing Failure Messages

Providing custom failure messages for JUnit assertions can help you find the source of problems more quickly.

The three forms of assertions

// 1. Sans message personnalisé
assertEquals(2, foundDrugs.size());

// 2. Avec une chaîne de message
assertEquals(2, foundDrugs.size(), "two drugs starting with 'as' should be returned");

// 3. Avec un Supplier<String> (lambda) - plus efficace si le message est coûteux à construire
assertEquals(2, foundDrugs.size(), () -> "two drugs starting with 'as' should be returned from test data.");

The lambda form is only evaluated if the assertion fails — which can save time if you have a lot of failure messages in many tests or if something about the failure message is expensive to construct.

Important: the custom failure message does not override the normal JUnit message of expected vs. real. It’s just added on top of this JUnit explanation by default.

When to use a custom failure message?

  • To make it clearer what a test is for when it fails
  • To provide more details about the scenario that failed (what arguments were passed to production code)
  • In general: when there is a failure, a custom message should help clarify what happened

The instructor uses custom failure messages with assertions much less often since using JUnit 5 regularly, because the display name feature is a great way to provide information about what a test is for.

8.3 Leveraging Display Names

display names are the instructor’s favorite JUnit 5 feature. The @DisplayName annotation can be placed in front of a test class declaration or a test method declaration.

@DisplayName("DrugService should")
class DrugServiceTest implements DrugSource {

    @BeforeEach
    void setup() {
       drugService = new DrugService(this);
    }

    @Test
    @DisplayName("return drugs from the database sorted by drug name")
    void drugsAreReturnedSorted() {
        // ...
    }

    @Test
    @DisplayName("throw an illegal argument exception when passed a blank string for startingWith")
    void throwsExceptionOnBlankStartsWith() {
        // ...
    }

    @Test
    @DisplayName("return dispensable drugs with all properties set correctly from database")
    void setsDrugPropertiesCorrectly() {
        // ...
    }
}

In the test report:

  • The display name text of the class essentially prefixes the display name text of the method
  • Result: “DrugService should return drugs from the database sorted by drug name”

Advantages:

  • Human-readable descriptions of each test method, rather than CamelCase names
  • Keeps test method names short while still providing lots of detail about what a scenario is
  • Reduces the need for custom failure messages

Note: version 2.22 of the Maven Surefire plugin does not use display names in the output of Maven tests. This is fixed in version 3 of the Surefire plugin. But having display names in the IDE is enough of a benefit.

8.4 Nesting Tests

If you have a test class with a lot of test methods, grouping them together using nested inner classes can be handy.

For JUnit to recognize and execute nested tests, you must add the @Nested annotation above the nested inner class.

@DisplayName("DrugService should")
class DrugServiceTest implements DrugSource {

    @Test
    @DisplayName("return drugs from the database sorted by drug name")
    void drugsAreReturnedSorted() {
        // ...
    }

    @Nested
    @DisplayName("throw an illegal argument exception")
    class ThrowsExceptionTests {

        @Test
        @DisplayName("when passed a blank string for startingWith")
        void throwsExceptionOnBlankStartsWith() {
            Exception thrown = assertThrows(IllegalArgumentException.class,
                    () -> drugService.findDrugsStartingWith("  "));
            System.out.println(thrown.getMessage());
        }

        @Test
        @DisplayName("when passed a empty string for startingWith")
        void throwsExceptionOnEmptyStartsWith() {
            Exception thrown = assertThrows(IllegalArgumentException.class,
                    () -> drugService.findDrugsStartingWith(""));
            System.out.println(thrown.getMessage());
        }
    }

    @Test
    @DisplayName("return dispensable drugs with all properties set correctly from database")
    void setsDrugPropertiesCorrectly() {
        // ...
    }
}

In the test report, the nested tests appear indented by an additional level, and the display names work fine:

  • “DrugService should throw an illegal argument exception when passed a blank string for startingWith”
  • “DrugService should throw an illegal argument exception when passed an empty string for startingWith”

Alternative: a parameterized test might be more appropriate if you want to test multiple illegal arguments, passing different illegal arguments via @ValueSource. Nested tests are useful when you have several distinct testing methods that you want to group together thematically.


9. Running Unit Tests as Part of a Build Pipeline

9.1 Capitalizing on Unit Tests

We have mentioned several benefits of unit testing throughout this course:

  1. Design tool: helps test your solution’s API
  2. Feature Documentation
  3. Confidence that the code we are working on or refactoring always works as expected
  4. Deployment automation and integration of code changes from multiple developers

Typical build pipeline

Here is an example of an automated build pipeline for a team:

Développeurs poussent du code → dépôt source
         ↓
Outil de pipeline détecte les changements
         ↓
Compilation et construction de l'application
         ↓ (si échec → halte et notification)
Exécution des tests unitaires
         ↓ (si échec → halte et notification)
Déploiement vers environnement sandbox
         ↓
Tests end-to-end et API tests
         ↓ (si échec → halte et notification)
Déploiement vers staging
         ↓
(optionnel) Test humain
         ↓
Déploiement en production

The goal of this automated pipeline is to take all code coming from all developers’ code repositories, test it, and finally deploy it where customers can interact with the application itself — including all those recent developer changes.

Unit testing is one of the fundamental building blocks that enables this type of automation and rapid deployment.

9.2 Touring a Sample Build Pipeline

To simulate a developer making a code modification, we push a simple change to the project repository. Build tools like Bamboo (Atlassian), Jenkins, or similar can detect these changes and automatically trigger a build.

The tool performs the Maven clean test objective:

mvn clean test

The tool understands the output format of unit tests and is able to detect and report whether tests pass or not. Notifications can be configured in these tools so that developers and other interested parties are alerted if the build fails for any reason (for example, a unit test that does not pass).

These tools can be configured to monitor changes in the source code repository, and there are a variety of ways to do this. Basically, any changes pushed by developers to the monitored branch will be automatically uploaded to the build tool and compiled and unit tested. We can also configure an automatic deployment if the compilation and unit testing step succeeds.


10. Learning More

There is lots of additional content on Pluralsight to help you with unit testing.

On the same Java SE learning path

CoursesAuthorContent
Java Best PracticesAndrejs DoroninsModule full of tips and guidelines for creating better unit tests
Test-Driven DevelopmentPaolo PerrottaUse unit testing to drive feature development (TDD), as opposed to just testing after the code is already written

Out of this learning path

CoursesAuthorContent
Getting Started with MockitoNicolae CaprarescuUse the Mockito library to provide test doubles for dependencies
Test Driven Development with JUnit 5Catalin TudoseIn-depth TDD with the latest version of JUnit
JUnit 5 FundamentalsEsteban HerreraMore details on the JUnit 5 library, including less commonly used features like repeated testing and what-ifs
Unit Testing Legacy Code in JavaJim Weaver (instructor of this course)Testing old code that was originally created without unit tests

11. Demo Code

11.1 Project structure

The sample project is called testingwithjunit and is a Java Maven project. There are two versions:

  • testingwithjunit-main: starting version of the course
  • testingwithjunit-complete: complete version with all tests
rxwriter/
├── drug/
│   ├── DispensableDrug.java          (record Java — données d'un médicament dispensable)
│   ├── DrugClassification.java       (enum — classifications thérapeutiques)
│   ├── DrugConcept.java              (classe — regroupement logique de médicaments)
│   ├── DrugService.java              (classe — service de recherche de médicaments)
│   └── database/
│       ├── DrugDatabase.java         (classe — base de données simulée)
│       ├── DrugRecord.java           (record Java — enregistrement brut d'un médicament)
│       └── DrugSource.java           (interface — abstraction de la source de données)
└── prescription/
    ├── DurationParser.java           (classe — analyse des durées en texte)
    └── DurationUnit.java             (enum — unités de durée)

test/
└── rxwriter/
    ├── drug/
    │   ├── DrugConceptTest.java      (teste DrugConcept)
    │   ├── DrugServiceTest.java      (teste DrugService + test double via self-shunt)
    │   └── LifecycleTest.java        (démontre le cycle de vie JUnit)
    └── prescription/
        ├── DurationParserTest.java   (teste DurationParser — tests paramétrés)
        └── DurationUnitTest.java     (teste DurationUnit)

11.2 pom.xml

<?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>org.testingwithjunit</groupId>
    <artifactId>rxwriter</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.8.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
                <configuration>
                    <groups></groups>
                    <excludedGroups></excludedGroups>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Key points:

  • maven.compiler.source and maven.compiler.target at 17 (Java SE 17)
  • junit-jupiter version 5.8.2 dependency with scope test — only available during testing
  • Plugin maven-surefire version 2.22.2 explicitly specified to ensure JUnit 5 support
  • The <groups> and <excludedGroups> fields allow you to filter tests by tags during a Maven build

11.3 Production classes — package rxwriter.prescription

DurationUnit.java

Enumeration representing duration units for medical prescriptions.

package rxwriter.prescription;

import java.util.Objects;

public enum DurationUnit {

    DAY("day", "days", 1),
    WEEK("week", "weeks", 7),
    MONTH("month", "months", 30);

    private String singularForm;
    private String pluralForm;
    private int multiplier;

    DurationUnit(String singularForm, String pluralForm, int multiplier) {
        this.singularForm = singularForm;
        this.pluralForm = pluralForm;
        this.multiplier = multiplier;
    }

    public int getMultiplier() {
        return multiplier;
    }

    public static DurationUnit getByTextValue(String durationString) {
        Objects.requireNonNull(durationString, "Duration string must be non-null");
        for (DurationUnit unit : DurationUnit.values()) {
            if ((unit.singularForm.equals(durationString.toLowerCase())) ||
                    (unit.pluralForm.equals(durationString.toLowerCase()))) {
                return unit;
            }
        }
        return null;
    }
}

Notable points:

  • Three instances: DAY (×1), WEEK (×7), MONTH (×30)
  • getByTextValue() supports singular and plural forms (“day” and “days”)
  • Returns null if no units match — null return case to test

DurationParser.java

Parses a prescription duration string (e.g. “2 weeks”) in number of days.

package rxwriter.prescription;

public class DurationParser {

    /**
     * A duration string will typically have two parts:  an integer quantity
     * and a unit, i.e. "2 weeks" or "3 days", for which this method should
     * return 14 and 3 respectively.
     *
     * Returns 0 for strings not parseable to days by this logic.
     *
     * TODO: Try to rewrite this method using a Java Switch Expression!
     */
    public static int parseDays(String durationString) {
        String[] parts = durationString.split(" ");
        if (parts.length == 2) {
            return unitValueCalculation(parts[1], parts[0]);
        }
        else if (parts.length == 1)
        {
            if (parts[0].equalsIgnoreCase("once")) return 1;
        }
        return 0;
    }

    private static int unitValueCalculation(String unitString, String valueString) {
        DurationUnit unit = DurationUnit.getByTextValue(unitString);
        if (unit == null) {
            return 0;
        }
        return (parseValue(valueString) * unit.getMultiplier());
    }

    private static int parseValue(String valueString) {
        try {
            return Integer.parseInt(valueString);
        }
        catch (Throwable t) {
            return 0;
        }
    }
}

Notable points:

  • Public static method parseDays() — single entry point
  • Supports two formats: “X unit” (e.g. “2 weeks”) and “once”
  • The private methods unitValueCalculation() and parseValue() are helper code tested indirectly via parseDays()
  • Returns 0 by default for non-parsable strings

11.4 Production classes — package rxwriter.drug

DispensableDrug.java

Java record (Java 14+) representing a drug dispensed in pharmacies.

package rxwriter.drug;

public record DispensableDrug(String drugName, DrugClassification[] drugClassifications, boolean isControlled) {
}

Notable points:

  • record Java: compact data class with automatically generated constructor, accessors, equals(), hashCode() and toString()
  • Properties: name, therapeutic classification table, controlled substance indicator

DrugClassification.java

Enumeration representing therapeutic drug classifications.

package rxwriter.drug;

import java.util.Arrays;

public enum DrugClassification {
    ANALGESIC(3645, "Analgesic, Anti-inflammatory, or Antipyretic"),
    ANALGESICS_NARCOTIC(582, "Analgesics - Narcotic"),
    ANTIANXIETY(523, "Antianxiety Agent - Benzodiazepines"),
    ANTIBACTERIAL(2549, "Antibacterial Agents"),
    ANTIFUNGAL(2602, "Antigungal Agents"),
    VITAMINS_WATER_SOLUBLE(658, "Vitamins - Water Soluble"),
    NASAL_CORTICOSTEROIDS(301, "Nasal Corticosteroids"),
    ACE_INHIBITORS(6108, "ACE Inhibitors and ACE Inhibitor Combinations"),
    ANTIHYPERLIPIDEMICS(263, "Antihyperlipidemics"),
    NARCOTIC_ANTHISTAMINE(338, "Narcotic Antitussive-Antihistamine Combinations"),
    PLATELET_AGGREGATION_INHIBITORS(3530, "Platelet Aggregation Inhibitors and Combinations");

    private final int classificationCode;
    private final String description;

    DrugClassification(int code, String description) {
        this.classificationCode = code;
        this.description = description;
    }

    public int getClassificationCode() {
        return classificationCode;
    }

    public String getDescription() {
        return description;
    }

    public static DrugClassification getClassificationByCode(int therapeuticCode) {
        return Arrays.stream(DrugClassification.values()).filter(c -> c.getClassificationCode() == therapeuticCode)
                .findFirst().orElse(null);
    }
}

DrugConcept.java

Represents a logical grouping of drugs by therapeutic classifications.

package rxwriter.drug;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;

public class DrugConcept {

    public final static DrugConcept OPIATES = new DrugConcept(new DrugClassification[]{
            DrugClassification.ANTIANXIETY,
            DrugClassification.ANALGESICS_NARCOTIC,
            DrugClassification.NARCOTIC_ANTHISTAMINE});

    private final DrugClassification[] drugClassesInConcept;

    public DrugConcept(DrugClassification[] drugClassesInConcept) {
        this.drugClassesInConcept = drugClassesInConcept;
    }

    public boolean isDrugInConcept(DispensableDrug drug) {
        return Arrays.stream(drugClassesInConcept).toList().stream().anyMatch(
                drugClass -> Arrays.stream(drug.drugClassifications()).toList().contains(drugClass));
    }
}

Notable points:

  • Constant OPIATES: example of production concept (not to be used directly in tests)
  • isDrugInConcept(): returns true if at least one drug classification belongs to the concept (non-empty intersection)
  • Refactored implementation with Java 8 streams and anyMatch

DrugService.java

Dependence injection drug search service.

package rxwriter.drug;

import rxwriter.drug.database.DrugDatabase;
import rxwriter.drug.database.DrugRecord;
import rxwriter.drug.database.DrugSource;

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

public class DrugService {

    private DrugSource drugSource;

    public DrugService(DrugSource drugSource) {
        this.drugSource = drugSource;
    }

    public List<DispensableDrug> findDrugsStartingWith(String startsWith) {
        if (startsWith == null || startsWith.isBlank()) {
            throw new IllegalArgumentException("Starts with string must be non-null, non-blank, " +
                    "and at least two characters.  String provided: [" + startsWith + "]");
        }
        List<DrugRecord> records = this.drugSource.findDrugsStartingWith(startsWith);
        List<DispensableDrug> matchedDrugs = convertRecords(records);
        matchedDrugs.sort(Comparator.comparing(DispensableDrug::drugName));
        return matchedDrugs;
    }

    private List<DispensableDrug> convertRecords(List<DrugRecord> records) {
        ArrayList<DispensableDrug> dispensableDrugs = new ArrayList<>();
        for (DrugRecord record : records) {
            dispensableDrugs.add(convertRecord(record));
        }
        return dispensableDrugs;
    }

    private DispensableDrug convertRecord(DrugRecord record) {
        List<DrugClassification> classifications = new ArrayList<>();
        for (int code : record.classCodes()) {
            classifications.add(DrugClassification.getClassificationByCode(code));
        }
        return new DispensableDrug(record.drugName(), classifications.toArray(new DrugClassification[0]),
                record.deaSchedule() > 0);
    }
}

Notable points:

  • Constructor with dependency injection — accepts DrugSource (interface)
  • Validating argument with throwing IllegalArgumentException
  • Sorting results alphabetically with Comparator.comparing(DispensableDrug::drugName)
  • Converting raw DrugRecord to DispensableDrug with classification code resolution
  • deaSchedule > 0 → controlled drug

11.5 Production classes — package rxwriter.drug.database

DrugSource.java — Interface

package rxwriter.drug.database;

import java.util.List;

public interface DrugSource {
    List<DrugRecord> findDrugsStartingWith(String startingString);
}

Key interface for test duplicates. By defining this interface, we can replace the real database with a test duplicate.

DrugRecord.java — Record

package rxwriter.drug.database;

public record DrugRecord(String drugName, int[] classCodes, int deaSchedule) {
}

Represents a raw drug record as stored in the database. deaSchedule is the Drug Enforcement Administration (DEA) classification number — a value greater than 0 indicates a controlled substance.

DrugDatabase.java — Concrete implementation

package rxwriter.drug.database;

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

public class DrugDatabase implements DrugSource {

    private static final List<DrugRecord> dataList = new ArrayList<>();

    static {
        dataList.add(new DrugRecord("fluticasone", new int[] {301}, 0));
        dataList.add(new DrugRecord("simvastatin", new int[] {263}, 0));
        dataList.add(new DrugRecord("aspirin", new int[] {3645, 3530}, 0));
        dataList.add(new DrugRecord("aspirin, children's", new int[] {3645, 3530}, 0));
        dataList.add(new DrugRecord("lorazepam", new int[] {523}, 4));
        dataList.add(new DrugRecord("oxycodone", new int[] {582}, 2));
        dataList.add(new DrugRecord("methadone", new int[] {582}, 2));
        dataList.add(new DrugRecord("asmanex", new int[] {301}, 0));
        dataList.add(new DrugRecord("fluconazole", new int[] {2602}, 0));
    }

    public List<DrugRecord> findDrugsStartingWith(String startingString) {
        return dataList.stream().filter(rec -> rec.drugName().startsWith(startingString)).toList();
    }
}

Teaching note: In the course, adding “aspirin, children’s” simulates a database change that breaks a test — thus demonstrating why test duplicates are necessary.

11.6 Test classes

DurationUnitTest.java

package rxwriter.prescription;

import org.junit.jupiter.api.Test;

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

class DurationUnitTest {

    @Test
    public void matchUnitBySingularString() {
        assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("week"));
    }

    @Test
    public void matchUnitByPluralString() {
        assertSame(DurationUnit.WEEK, DurationUnit.getByTextValue("weeks"));
    }

    @Test
    public void returnsNullForUnmatchedUnit() {
        assertNull(DurationUnit.getByTextValue("boop"));
    }
}

Assertions used: assertSame (object identity, suitable for enums), assertNull

DurationParserTest.java

package rxwriter.prescription;

import org.junit.jupiter.api.Test;
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.*;

class DurationParserTest {

    @ParameterizedTest
    @CsvSource({"2 weeks, 14", "1 month, 30", "5 days, 5", "once, 1"})
    public void parseDurationWithValidUnitAndQuantity(String durationString, int expectedDays) {
        assertEquals(expectedDays, DurationParser.parseDays(durationString));
    }
}

Features used:

  • @ParameterizedTest: JUnit 5 parameterized test
  • @CsvSource: inline CSV values — each line = an invocation with its arguments
  • Four cases covered: weeks, months, days, and the special case “once”

DrugConceptTest.java

package rxwriter.drug;

import org.junit.jupiter.api.Test;

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

class DrugConceptTest {

    private final static DrugConcept TEST_CONCEPT = new DrugConcept(new DrugClassification[]{
            DrugClassification.ANTIANXIETY,
            DrugClassification.ANALGESICS_NARCOTIC,
            DrugClassification.NARCOTIC_ANTHISTAMINE});

    @Test
    void drugBelongsInConceptIfOneClassMatches() {
        DispensableDrug drug = new DispensableDrug("adrug",
                new DrugClassification[] {DrugClassification.ANALGESIC,
                        DrugClassification.ANTIANXIETY}, false);
        assertTrue(TEST_CONCEPT.isDrugInConcept(drug));
    }

    @Test
    void drugNotInConceptIfNoClassesMatch() {
        DispensableDrug drug = new DispensableDrug("adrug",
                new DrugClassification[] {DrugClassification.ANALGESIC,
                        DrugClassification.NASAL_CORTICOSTEROIDS}, false);
        assertFalse(TEST_CONCEPT.isDrugInConcept(drug));
    }
}

Notable points:

  • TEST_CONCEPT static final: test concept defined in the test class, independent of production data
  • Two scenarios: drug in concept (a common classification) and drug out of concept
  • assertTrue / assertFalse for methods returning a boolean

DrugServiceTest.java

Most comprehensive test class in the project — demonstrates many JUnit 5 features:

package rxwriter.drug;

import org.junit.jupiter.api.*;
import rxwriter.drug.database.DrugRecord;
import rxwriter.drug.database.DrugSource;

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

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

@DisplayName("DrugService should")
class DrugServiceTest implements DrugSource {

    private DrugService drugService;

    @BeforeEach
    void setup() {
       drugService = new DrugService(this);
    }

    @Test
    @DisplayName("return drugs from the database sorted by drug name")
    void drugsAreReturnedSorted() {
        List<DispensableDrug> foundDrugs = drugService.findDrugsStartingWith("as");
        assertNotNull(foundDrugs);
        assertEquals(2, foundDrugs.size(), ()-> "two drugs starting with 'as' should be returned from test data.");
        assertEquals("asmanex", foundDrugs.get(0).drugName());
        assertEquals("aspirin", foundDrugs.get(1).drugName());
    }

    @Nested
    @DisplayName("throw an illegal argument exception")
    class ThrowsExceptionTests {

        @Test
        @DisplayName("when passed a blank string for startingWith")
        void throwsExceptionOnBlankStartsWith() {
            Exception thrown = assertThrows(IllegalArgumentException.class,
                    () -> drugService.findDrugsStartingWith("  "));
            System.out.println(thrown.getMessage());
        }

        @Test
        @DisplayName("when passed a empty string for startingWith")
        void throwsExceptionOnEmptyStartsWith() {
            Exception thrown = assertThrows(IllegalArgumentException.class,
                    () -> drugService.findDrugsStartingWith(""));
            System.out.println(thrown.getMessage());
        }
    }

    @Test
    @DisplayName("return dispensable drugs with all properties set correctly from database")
    void setsDrugPropertiesCorrectly() {
        List<DispensableDrug> foundDrugs = drugService.findDrugsStartingWith("aspirin");
        DrugClassification[] expectedClassifications = new DrugClassification[] {
                DrugClassification.ANALGESIC, DrugClassification.PLATELET_AGGREGATION_INHIBITORS
        };
        DispensableDrug drug = foundDrugs.get(0);
        assertAll("DispensableDrug properties",
                () -> assertEquals("aspirin", drug.drugName()),
                () -> assertFalse(drug.isControlled()),
                () -> assertEquals(2, drug.drugClassifications().length),
                () -> assertArrayEquals(expectedClassifications, drug.drugClassifications())
        );
    }

    // Implémentation de DrugSource — le test double via self-shunt
    @Override
    public List<DrugRecord> findDrugsStartingWith(String startingString) {
        List<DrugRecord> records = new ArrayList<>();
        if (startingString.equals("as")) {
            records.add(new DrugRecord("asmanex", new int[] {301}, 0));
            records.add(new DrugRecord("aspirin", new int[] {3645, 3530}, 0));
        }
        else if (startingString.equals("aspirin")) {
            records.add(new DrugRecord("aspirin", new int[] {3645, 3530}, 0));
        }
        return records;
    }
}

JUnit 5 features shown:

FeatureWhereDescription
@DisplayName (class)@DisplayName("DrugService should")Test class description
@DisplayName (method)@DisplayName("return drugs...")Readable description of each test
@BeforeEachsetup()Shared configuration before each test
@NestedThrowsExceptionTestsThematic grouping of tests
@Nested + @DisplayNameInternal classDisplay name prefixed in report
assertNotNulldrugsAreReturnedSortedChecking that the list is not null
assertEquals with lambda messageassertEquals(2, ..., () -> "...")Custom lambda failure message
assertThrowsthrowsExceptionOnBlankStartsWithChecking for a thrown exception
assertAllsetsDrugPropertiesCorrectlyVerification group without short circuit
assertArrayEqualsIn assertAllComparing arrays element by element
assertFalseIn assertAllChecking a Boolean value to false
Double test (self-shunt)implements DrugSourceThe test class provides the data

LifecycleTest.java

Tutorial demonstration of the complete JUnit lifecycle.

package rxwriter.drug;

import org.junit.jupiter.api.*;

public class LifecycleTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("BeforeAll executed");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("BeforeEach executed");
    }

    @Test
    void testOne() {
        System.out.println("Test one executed");
    }

    @Test
    void testTwo() {
        System.out.println("Test two executed");
    }

    @AfterEach
    void afterEach() {
        System.out.println("AfterEach executed");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("AfterAll executed");
    }
}

Console output when running:

BeforeAll executed
BeforeEach executed
Test one executed
AfterEach executed
BeforeEach executed
Test two executed
AfterEach executed
AfterAll executed

Suggested exercise: add this to the println of each method except @BeforeAll and @AfterAll. Observe that the object ID of the test class instance is different for each group BeforeEach-test-AfterEach — confirming the per-method behavior.


12. Appendix: Summary of JUnit 5 assertions

AssertionDescriptionExample
assertEquals(expected, actual)Equality of values ​​assertEquals(14, DurationParser.parseDays("2 weeks"))
assertNotEquals(expected, actual)Inequality of values ​​assertNotEquals(0, count)
assertSame(expected, actual)Same reference in memoryassertSame(DurationUnit.WEEK, unit)
assertNotSame(expected, actual)Different referencesassertNotSame(obj1, obj2)
assertNull(object)Value is nullassertNull(DurationUnit.getByTextValue("boop"))
assertNotNull(object)Value is not nullassertNotNull(foundDrugs)
assertTrue(condition)Condition is trueassertTrue(concept.isDrugInConcept(drug))
assertFalse(condition)Condition is falseassertFalse(drug.isControlled())
assertArrayEquals(expected, actual)Identical arrays element by elementassertArrayEquals(expectedClassifications, drug.drugClassifications())
assertThrows(exceptionType, lambda)An exception is thrown (JUnit 5)assertThrows(IllegalArgumentException.class, () -> service.findDrugs(""))
assertAll(heading, lambda...)Executes all assertions without short-circuiting (JUnit 5)assertAll("props", () -> assertEquals(...), () -> assertFalse(...))

13. Appendix: Summary of JUnit 5 annotations

AnnotationDescription
@TestMark a method as a test method
@ParameterizedTestParameterized test — run multiple times with different arguments
@ValueSource(...)Simple argument source for parameterized tests
@CsvSource({...})Inline CSV source for multi-parameter tests
@CsvFileSource(resources=...)CSV source from external file
@MethodSource("MethodName")Source of arguments via a method
@EnumSource(...)Source of arguments from an enum
@BeforeEachMethod executed before each test method
@AfterEachMethod executed after each test method
@BeforeAllStatic method executed once before all tests
@AfterAllStatic method executed once after all tests
@Disabled("reason")Disables a test or test class
@Tag("name")Applies a tag to a test for filtering
@Tags({...})Apply multiple tags
@DisplayName("description")Descriptive name for reports
@NestedIndicates an inner class containing nested tests
@EnabledOnJre(...)Enables testing only on certain JRE versions
@DisabledOnJre(...)Disables testing on certain JRE versions
@EnabledOnOs(...)Enables testing only on certain operating systems
@DisabledOnOs(...)Disables testing on some operating systems
@TestInstance(Lifecycle.PER_CLASS)Per-class lifecycle (single test instance)

Search Terms

java · se · unit · testing · junit · backend · architecture · full-stack · web · test · tests · assertions · classes · execution · methods · running · asserting · dependency · intellij · lifecycle · assertion · class · doubles · failure

Interested in this course?

Contact us to book it or get a custom training plan for your team.