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
| Annotation | Applies To | Meaning |
|---|---|---|
@Nullable | Parameter, return type, field | This value may be null. Callers and implementations must handle the null case. |
@NonNull | Parameter, return type, field | This value must not be null. Tools will warn if a nullable value is passed or returned here. |
@NullMarked | Package, class, module | Within 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.
| Tool | JSpecify Support | What it catches |
|---|---|---|
| IntelliJ IDEA | Built-in (2023.1+) | Passing nullable to @NonNull, not null-checking @Nullable return values, redundant null checks on @NonNull |
| NullAway | Native (recommended) | Null flow violations at compile time via Error Prone integration |
| Error Prone | Via NullAway plugin | Same as NullAway — null contract violations flagged as compile errors |
| SpotBugs | Partial | Dereference-of-null and return-of-null violations at bytecode level |
| Kotlin compiler | Native | Platform 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
| Concept | Detail |
|---|---|
| Runtime enforcement | None. Annotations are compile-time / tooling contracts only. |
| Where to annotate | Parameters, return types, fields — anywhere a type appears. |
| @NullMarked scope | Class, package (via package-info.java), or module (via module-info.java). |
| Unannotated type under @NullMarked | Treated as @NonNull by default. |
| Overriding @NullMarked locally | Add @Nullable to the specific type that can be null. |
| Kotlin interop benefit | Eliminates platform types (T!) — Kotlin sees precise nullable/non-null. |
| JSpecify version in JUnit 6 | 1.0.0 (stable, vendor-neutral spec) |
See Also
- JUnit 6 vs TestNG: Feature Comparison and When to Use What
- JUnit 6 vs Mockito: Roles, Differences, and Integration
- Performance Testing Using JUnit 6 (Benchmarks & Techniques)
- Property-Based Testing in JUnit 6 with jqwik (Complete Guide)
- JUnit 6 Tutorial: Complete Series Index
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.