JUnit 6 Nullability: @Nullable, @NonNull and @NullMarked Explained

JUnit 6 adopted JSpecify nullability annotations across its entire API surface. Every method parameter, return type, and field in JUnit’s public API is now explicitly annotated to declare whether it can be null. The result is stronger IDE guidance, better static analysis, and safer Kotlin interop — all without changing how your tests run.

This guide explains the three annotations you will encounter — @Nullable, @NonNull, and @NullMarked — with complete, runnable examples for each.

Why JUnit 6 Adopted JSpecify

Before JUnit 6, the framework’s API gave no formal signal about which parameters or return values could be null. Developers relied on documentation, runtime exceptions, or convention. JUnit 6 fixed this by adopting JSpecify — a vendor-neutral, standardised set of null-safety annotations that IDEs (IntelliJ, Eclipse), static analysers (NullAway, Error Prone, SpotBugs), and Kotlin’s compiler all understand.

One important point to understand upfront: these annotations do not insert null checks at runtime. They are compile-time and tooling contracts. If you pass null to a @NonNull parameter at runtime, the JVM will not throw an exception at the call site — it will throw a NullPointerException only when that value is actually dereferenced. The annotations shift detection earlier, to your IDE and CI pipeline.

The Three Nullability Annotations

AnnotationApplies ToMeaning
@NullableParameter, return type, fieldThis value may be null. Callers and implementations must handle the null case.
@NonNullParameter, return type, fieldThis value must not be null. Tools will warn if a nullable value is passed or returned here.
@NullMarkedPackage, class, moduleWithin this scope, all unannotated types are treated as @NonNull by default. Only nullable ones need explicit @Nullable.

@Nullable Example

@Nullable marks a parameter or return type that legitimately may be null. It tells your IDE and static analyser: “you must handle the null case here.” In JUnit 6 tests this is most useful when the code under test can return null, or when a helper method accepts optional arguments.

import org.junit.jupiter.api.Test;
import org.jspecify.annotations.Nullable;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class NullableExampleTest {

    // Return type explicitly declared nullable
    @Nullable
    String findUserEmail(int userId) {
        if (userId == 0) {
            return null; // unknown user
        }
        return "user" + userId + "@example.com";
    }

    @Test
    void returnsEmailForKnownUser() {
        String email = findUserEmail(42);
        // IDE knows email could be null — use assertNotNull or null-check before use
        assertEquals("[email protected]", email);
    }

    @Test
    void returnsNullForUnknownUser() {
        String email = findUserEmail(0);
        assertNull(email); // @Nullable documents this is expected and valid
    }

    // Parameter declared nullable — caller may pass null intentionally
    void logMessage(@Nullable String message) {
        if (message == null) {
            System.out.println("[no message]");
        } else {
            System.out.println(message);
        }
    }

    @Test
    void logsNullMessageSafely() {
        logMessage(null);       // IDE: no warning, @Nullable allows this
        logMessage("hello");    // fine too
    }
}

@NonNull Example

@NonNull is the complementary annotation: it asserts that a value will never be null. When applied to a return type it means the method always returns a valid, non-null result. When applied to a parameter it signals that callers must not pass null. Your IDE will flag a potential null being passed to a @NonNull parameter before you even compile.

import org.junit.jupiter.api.Test;
import org.jspecify.annotations.NonNull;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

class NonNullExampleTest {

    // Guaranteed never to return null
    @NonNull
    String formatOrderId(int id) {
        return "ORD-" + String.format("%05d", id);
    }

    @Test
    void formatsOrderIdCorrectly() {
        String result = formatOrderId(42);
        // IDE knows result is @NonNull — no null-check needed
        assertNotNull(result);
        assertEquals("ORD-00042", result);
    }

    // Caller must never pass null — tool will warn if a @Nullable flows in here
    void processOrder(@NonNull String orderId) {
        System.out.println("Processing: " + orderId.toUpperCase());
    }

    @Test
    void processesValidOrder() {
        processOrder("ORD-00001"); // fine

        // processOrder(null); // IDE/NullAway: warning — @NonNull violated
    }
}

@NullMarked Example

@NullMarked is a scope-level annotation placed on a class, package, or module. It establishes a “non-null by default” rule: every unannotated type within that scope is treated as @NonNull. You only need to add @Nullable to the exceptions. This removes annotation noise from classes where the vast majority of types are non-null, which is the common case in well-written Java code.

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

@NullMarked  // all unannotated types in this class are implicitly @NonNull
class NullMarkedExampleTest {

    // Non-null by default — no annotation needed
    String buildGreeting(String name) {
        return "Hello, " + name + "!";
    }

    // Opt individual types out with @Nullable
    @Nullable
    String findConfig(String key) {
        if ("timeout".equals(key)) return "30s";
        return null; // unknown keys return null
    }

    @Test
    void buildsGreetingCorrectly() {
        String result = buildGreeting("Ankur");
        assertEquals("Hello, Ankur!", result);

        // buildGreeting(null); // IDE warns: parameter is @NonNull (from @NullMarked)
    }

    @Test
    void findsKnownConfig() {
        String value = findConfig("timeout");
        assertEquals("30s", value);
    }

    @Test
    void returnsNullForUnknownConfig() {
        String value = findConfig("unknown");
        assertNull(value); // fine — @Nullable explicitly allows this
    }
}

Using @NullMarked on a package-info.java

The most common production use of @NullMarked is at the package level via package-info.java. This way every class in the package inherits the non-null-by-default rule without adding @NullMarked to each class individually.

// src/main/java/com/example/orders/package-info.java
@NullMarked
package com.example.orders;

import org.jspecify.annotations.NullMarked;

With this in place, all classes under com.example.orders — and their corresponding test classes — benefit from non-null defaults. Any method that legitimately returns null adds @Nullable explicitly, making nullability visible at a glance rather than buried in Javadoc.

Integration with Static Analysis Tools

JSpecify annotations are understood by the main Java static analysis tools. The table below shows what each tool does with them and what you need to configure.

ToolJSpecify SupportWhat it catches
IntelliJ IDEABuilt-in (2023.1+)Passing nullable to @NonNull, not null-checking @Nullable return values, redundant null checks on @NonNull
NullAwayNative (recommended)Null flow violations at compile time via Error Prone integration
Error ProneVia NullAway pluginSame as NullAway — null contract violations flagged as compile errors
SpotBugsPartialDereference-of-null and return-of-null violations at bytecode level
Kotlin compilerNativePlatform types replaced with precise nullable/non-null types, eliminating T! ambiguity

Maven Dependency

JSpecify is a separate, lightweight library. Add it to your project alongside JUnit 6:

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.0</version>
</dependency>

<!-- JUnit 6 itself -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.1</version>
    <scope>test</scope>
</dependency>

Key Concepts at a Glance

ConceptDetail
Runtime enforcementNone. Annotations are compile-time / tooling contracts only.
Where to annotateParameters, return types, fields — anywhere a type appears.
@NullMarked scopeClass, package (via package-info.java), or module (via module-info.java).
Unannotated type under @NullMarkedTreated as @NonNull by default.
Overriding @NullMarked locallyAdd @Nullable to the specific type that can be null.
Kotlin interop benefitEliminates platform types (T!) — Kotlin sees precise nullable/non-null.
JSpecify version in JUnit 61.0.0 (stable, vendor-neutral spec)

See Also

Conclusion

JUnit 6’s adoption of JSpecify nullability annotations — @Nullable, @NonNull, and @NullMarked — is a meaningful improvement to the framework’s API clarity. They do not change runtime behaviour, but they shift null-related bugs from runtime stack traces to IDE warnings and CI build failures. Applying @NullMarked at the package level is the highest-value starting point: it makes non-null the default and ensures that any type that genuinely can be null is explicitly and visibly marked as such.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.