JUnit · lesson 3 of 7
JUnit Lifecycle, Assumptions and Conditional Tests
How JUnit 6 creates test instances, PER_METHOD vs PER_CLASS, assumptions that skip instead of fail, and tests enabled by OS, JRE or environment variable.
By SkillByExample · Published
Two questions decide how a test class behaves before any assertion runs: how many instances of the class JUnit creates, and whether a test should run at all in this environment. This lesson answers both with runs you can repeat. It uses the Maven project from Your First JUnit 6 Test with Maven and the same alias:
alias mvn='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn'
A new instance for every test
The class counts how many times its constructor runs and keeps a per-instance counter.
// File: src/test/java/PerMethodTest.java
import org.junit.jupiter.api.*;
class PerMethodTest {
static int instances = 0;
int calls = 0;
PerMethodTest() { instances++; }
@BeforeAll static void start() { System.out.println("@BeforeAll"); }
@BeforeEach void before() { calls++; }
@Test void first() { System.out.println("first: instance " + instances + ", calls " + calls); }
@Test void second() { System.out.println("second: instance " + instances + ", calls " + calls); }
@Test void third() { System.out.println("third: instance " + instances + ", calls " + calls); }
}
$ mvn -B -ntp test -Dtest=PerMethodTest | grep -E "^(@|first|second|third)"
@BeforeAll
second: instance 1, calls 1
first: instance 2, calls 1
third: instance 3, calls 1
Three tests, three constructor calls, and calls is 1 every time. JUnit’s default lifecycle is
per method: it builds a fresh PerMethodTest for each test, runs @BeforeEach on it, runs the
test, and throws it away. A field changed by one test can never leak into another, which is why
tests can share field initialisers safely. It is also why @BeforeAll must be static: it runs
once, before any instance exists.
The tests ran as second, first, third. JUnit’s default method order is deterministic (the
same every run) but deliberately not obvious, so tests don’t come to depend on it.
Tags, Filtering, Ordering and Suites shows how to set it.
One shared instance: PER_CLASS
The same class with one annotation added.
// File: src/test/java/PerClassTest.java
import org.junit.jupiter.api.*;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
static int instances = 0;
int calls = 0;
PerClassTest() { instances++; }
@BeforeAll void start() { System.out.println("@BeforeAll (not static)"); }
@BeforeEach void before() { calls++; }
@Test void first() { System.out.println("first: instance " + instances + ", calls " + calls); }
@Test void second() { System.out.println("second: instance " + instances + ", calls " + calls); }
@Test void third() { System.out.println("third: instance " + instances + ", calls " + calls); }
}
$ mvn -B -ntp test -Dtest=PerClassTest | grep -E "^(@|first|second|third)"
@BeforeAll (not static)
second: instance 1, calls 1
first: instance 1, calls 2
third: instance 1, calls 3
With Lifecycle.PER_CLASS there is one instance for the whole class: instance 1 every time,
and calls keeps climbing because @BeforeEach runs on the same object. @BeforeAll no longer
needs static, since an instance exists before it runs; that is the main reason to use
PER_CLASS, for example to open a connection in a @BeforeAll that reads instance fields, or to
use @BeforeAll in a @Nested class. The price is shared mutable state: any field a test changes,
the next test sees. Reset it in @BeforeEach or don’t keep state in fields at all.
Assumptions: skip, don’t fail
A test that needs a database URL from the environment. Without it, failing would be wrong: the code isn’t broken, the machine just can’t run this test.
// File: src/test/java/DatabaseTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.*;
class DatabaseTest {
@Test
void connectsToDatabase() {
String url = System.getenv("DB_URL");
assumeTrue(url != null, "DB_URL is not set");
System.out.println("connecting to " + url);
assertTrue(url.startsWith("jdbc:"));
}
@Test
void parsesConfigEverywhere() {
assumingThat(System.getenv("DB_URL") != null,
() -> System.out.println("extra check against the database"));
System.out.println("config parsed");
}
}
$ mvn -B -ntp test -Dtest=DatabaseTest | grep -E "connecting|extra|config|Tests run:"
config parsed
[WARNING] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.281 s -- in DatabaseTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1
$ grep -o 'Assumption failed: .*' target/surefire-reports/TEST-DatabaseTest.xml
Assumption failed: DB_URL is not set
$ docker run --rm -e DB_URL=jdbc:h2:mem:test -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn -B -ntp test -Dtest=DatabaseTest | grep -E "connecting|extra|config|Tests run:"
extra check against the database
config parsed
connecting to jdbc:h2:mem:test
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.276 s -- in DatabaseTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Without DB_URL, assumeTrue threw a TestAbortedException. Surefire counted the test as
Skipped, not failed, and the build passed; the reason (Assumption failed: DB_URL is not set)
is recorded in the XML report that CI servers read. The second docker run passes
-e DB_URL=... to the container, and the test JVM inherits it: now nothing is skipped and the
connecting to line appears.
assumingThat is different: it runs its lambda only when the condition holds, but the test as a
whole always runs. parsesConfigEverywhere passed both times and printed its extra line only in
the second run. Use assumeTrue/assumeFalse when the whole test is meaningless without the
condition, and assumingThat for an optional extra check.
Conditions: decide before the test starts
An assumption is checked inside the test. A condition annotation is checked by JUnit before
the test (and its @BeforeEach) starts.
// File: src/test/java/ConditionsTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.*;
class ConditionsTest {
@Test @EnabledOnOs(OS.LINUX)
void linuxOnly() { System.out.println("ran linuxOnly"); }
@Test @EnabledOnJre(JRE.JAVA_17)
void java17Only() { System.out.println("ran java17Only"); }
@Test @EnabledForJreRange(min = JRE.JAVA_21)
void java21AndLater() { System.out.println("ran java21AndLater"); }
@Test @EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void onCiOnly() { System.out.println("ran onCiOnly"); }
@Test @DisabledIf("isWeekendBuild")
void notOnWeekendBuilds() { System.out.println("ran notOnWeekendBuilds"); }
static boolean isWeekendBuild() { return "weekend".equals(System.getenv("BUILD_KIND")); }
}
$ mvn -B -ntp test -Dtest=ConditionsTest | grep -E "^ran|Tests run:"
ran notOnWeekendBuilds
ran linuxOnly
ran java21AndLater
[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.286 s -- in ConditionsTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2
$ grep -o 'skipped message="[^"]*"' target/surefire-reports/TEST-ConditionsTest.xml
skipped message="Disabled on JRE version: 21.0.12.1"
skipped message="Environment variable [CI] does not exist"
$ docker run --rm -e CI=true -e BUILD_KIND=weekend -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn -B -ntp test -Dtest=ConditionsTest | grep -E "^ran|Tests run:"
ran linuxOnly
ran java21AndLater
ran onCiOnly
[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2, Time elapsed: 0.216 s -- in ConditionsTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 2
Inside the Linux container on Java 21, linuxOnly and java21AndLater ran, java17Only and
onCiOnly were skipped, and the XML report records why: Disabled on JRE version: 21.0.12.1 and
Environment variable [CI] does not exist. Conditional tests count as Skipped exactly like failed
assumptions, which is why the class summary is a [WARNING] line rather than [INFO].
The second run sets two environment variables. CI=true enabled onCiOnly, and
BUILD_KIND=weekend made the static method isWeekendBuild return true, so @DisabledIf
switched notOnWeekendBuilds off. Two tests are still skipped, but they are a different two.
Every condition has a @Disabled... twin (@DisabledOnOs, @DisabledIfEnvironmentVariable,
…), and @EnabledIfSystemProperty reads -D properties instead of the environment. The
unconditional @Disabled("reason") is shown in Unit Testing with JUnit 5 and
Mockito.
Practice
- Write a
PER_CLASStest whose non-static@BeforeAllopens a pretend connection into an instance field and whose@AfterAllcloses it. Two tests should both use the same connection.
Solution
// File: src/test/java/SharedConnectionTest.java
import org.junit.jupiter.api.*;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SharedConnectionTest {
StringBuilder connection;
@BeforeAll void open() { connection = new StringBuilder("conn"); System.out.println("open"); }
@AfterAll void close() { System.out.println("close after " + connection); }
@Test void a() { connection.append("+a"); }
@Test void b() { connection.append("+b"); }
}
$ mvn -B -ntp test -Dtest=SharedConnectionTest | grep -E "^(open|close)|Tests run:"
open
close after conn+a+b
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.250 s -- in SharedConnectionTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Both tests appended to the one StringBuilder, so @AfterAll saw both changes. Under the default
per-method lifecycle this class wouldn’t compile as intended: @BeforeAll would have to be
static, and so would connection.
- Skip a test on CI with
assumeFalse, and prove it with two runs.
Solution
// File: src/test/java/LocalOnlyTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeFalse;
class LocalOnlyTest {
@Test
void opensBrowser() {
assumeFalse("true".equals(System.getenv("CI")), "no browser on CI");
System.out.println("opening browser");
}
}
$ mvn -B -ntp test -Dtest=LocalOnlyTest | grep -E "opening|Tests run: 1,"
opening browser
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.219 s -- in LocalOnlyTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
$ docker run --rm -e CI=true -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn -B -ntp test -Dtest=LocalOnlyTest | grep -E "opening|Tests run: 1,"
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.237 s -- in LocalOnlyTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
- Enable a slow test only when Maven is run with
-Dslow=true, using@EnabledIfSystemProperty.
Solution
// File: src/test/java/SlowTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
class SlowTest {
@Test
@EnabledIfSystemProperty(named = "slow", matches = "true")
void fullImport() { System.out.println("running the full import"); }
}
$ mvn -B -ntp test -Dtest=SlowTest | grep -E "running|Tests run: 1,"
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.126 s -- in SlowTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
$ mvn -B -ntp test -Dtest=SlowTest -Dslow=true | grep -E "running|Tests run: 1,"
running the full import
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.193 s -- in SlowTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Surefire copies Maven’s -D user properties into the test JVM’s system properties, so
-Dslow=true on the mvn command line reaches @EnabledIfSystemProperty.