Skip to main content
JUnit · lesson 3 of 7
JUnit beginner 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

junit java lifecycle assumptions testing

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

  1. Write a PER_CLASS test whose non-static @BeforeAll opens a pretend connection into an instance field and whose @AfterAll closes 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.

  1. 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
  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.

Frequently Asked Questions

Why does JUnit create a new instance of the test class for every test?
JUnit's default lifecycle is per method: each test runs on a fresh instance, so fields changed by one test cannot leak into the next. That is also why @BeforeAll and @AfterAll methods must be static by default, since they run when no instance exists. Annotate the class with @TestInstance(Lifecycle.PER_CLASS) to share one instance instead.
When should I use @TestInstance(Lifecycle.PER_CLASS)?
Use it when @BeforeAll or @AfterAll need instance fields, for example to open a shared resource once and close it after all tests, or to use them in @Nested classes. The trade-off is shared state: every field a test changes is visible to the next test. Reset such fields in @BeforeEach or avoid mutable fields.
What is the difference between an assumption and an assertion in JUnit?
A failed assertion marks the test as failed and fails the build. A failed assumption, such as assumeTrue(condition), aborts the test and reports it as skipped, because the environment cannot run it rather than the code being wrong. Use assumptions for tests that need something like a database URL or a particular operating system.
How do I run a JUnit test only on CI or only on a certain OS?
Use condition annotations from org.junit.jupiter.api.condition, such as @EnabledIfEnvironmentVariable(named = "CI", matches = "true"), @EnabledOnOs(OS.LINUX) or @EnabledForJreRange(min = JRE.JAVA_21). Each has a @Disabled twin, and @EnabledIf or @DisabledIf take the name of a boolean method for custom logic. Tests switched off this way are reported as skipped with the reason.