JUnit 5 @BeforeEach — 3-Minute CI Pipeline from Setup
A @BeforeEach creating EmbeddedPostgres per test ballooned a 45-second suite to 3 minutes on CI.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- JUnit 5 annotations declare setup, teardown, and test methods
- @BeforeEach runs before every test; @BeforeAll runs once per class
- @ParameterizedTest drives a single test with multiple data sets
- @DisplayName replaces cryptic method names in test reports
- @Nested groups tests with shared state inside the same class
- Most production failures come from misusing @BeforeAll vs @BeforeEach
JUnit 5 is the de facto standard for unit testing in Java, replacing JUnit 4 with a modular architecture built on the JUnit Platform, Jupiter engine, and Vintage compatibility layer. It exists because modern Java testing demands more than simple assertions—you need lifecycle hooks, parameterized inputs, conditional execution, and extension points for mocking frameworks like Mockito or test containers.
JUnit 5 annotations like @BeforeEach, @ParameterizedTest, and @TestMethodOrder give you fine-grained control over test setup, data-driven testing, and execution ordering without boilerplate. If you're working with Spring Boot, Quarkus, or any JVM-based project, JUnit 5 is the default; alternatives like TestNG still exist but lack the same ecosystem adoption and native IDE support.
Use JUnit 5 when you need reliable, composable tests that integrate seamlessly with CI pipelines—avoid it only if you're stuck on a legacy Java 7 codebase or need parallel execution semantics that TestNG handles natively.
JUnit 5 annotations are the vocabulary you use to tell the test runner what to do and when. @Test marks a method as a test. @BeforeEach runs setup code before every test. @AfterEach runs cleanup after every test. Once you know what each annotation means, writing structured, readable test suites becomes natural.
JUnit 5 shipped in 2017 and most Java teams are still not using it to its full potential — they write JUnit 4 tests with JUnit 5 imports. The annotation model is genuinely better: display names, parameterized tests, nested test classes, and lifecycle extensions are all first-class. Understanding the lifecycle annotations specifically is what separates 'tests that happen to pass' from a test suite you can trust.
I've reviewed hundreds of test suites and the single most common issue is misuse of @BeforeAll vs @BeforeEach — specifically using @BeforeEach to set up shared expensive state (database connections, HTTP clients) that should be @BeforeAll. On a 200-test suite this is the difference between a 3-second run and a 45-second run.
Why @BeforeEach Is Your Setup Safety Net
JUnit 5's @BeforeEach annotation marks a method to run before every test method in the class. It's the declarative replacement for setUp() in JUnit 4 — but with a critical difference: each @BeforeEach method runs in the same test instance, so state leaks between tests unless you reset it. The lifecycle is: @BeforeEach methods (inherited and declared, in order) → test method → @AfterEach methods. This runs per test, not per class, giving you O(n) setup cost for n tests. In practice, @BeforeEach is where you initialize mocks, open database connections, or set up test data. It guarantees a fresh baseline for every test, but only if you keep it fast — a 100ms setup times 1000 tests is 100 seconds. Use it for state that must be clean per test, not for expensive resources that can be shared via @BeforeAll.
Core Lifecycle Annotations
JUnit 5's lifecycle annotations control the setup and teardown flow around your tests. Getting this right is the foundation of a fast, reliable test suite.
@BeforeEach runs before every single test method. Use it for state that must be fresh per test — creating a new instance of the class under test, resetting mocks, clearing an in-memory list. If two tests share state through a field set in @BeforeEach, they're still isolated.
@AfterEach runs after every test regardless of pass or fail. Use it to release resources acquired in @BeforeEach — close a file handle, clear a database row created during the test.
@BeforeAll runs once before all tests in the class. Must be static (unless @TestInstance(PER_CLASS) is used). Use it for expensive shared setup: starting an embedded database, loading a large test fixture, initialising a test HTTP client. Creating these in @BeforeEach means rebuilding them for every test — that's the bug I see most.
@AfterAll runs once after all tests. Tear down @BeforeAll resources here.
package io.thecodeforge.payment; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; class PaymentServiceTest { // Shared across ALL tests in this class — initialised once private static EmbeddedPostgres embeddedDb; private static DataSource dataSource; // Fresh instance per test — guarantees test isolation private PaymentService paymentService; @BeforeAll static void setUpDatabase() { // Expensive — do this ONCE, not before every test embeddedDb = EmbeddedPostgres.start(); dataSource = embeddedDb.getPostgresDatabase(); System.out.println("Embedded DB started — shared across all tests"); } @BeforeEach void setUpService() { // Cheap — fresh PaymentService instance per test paymentService = new PaymentService(dataSource); } @Test @DisplayName("processPayment should return SUCCESS for valid payment request") void processPayment_validRequest_returnsSuccess() { PaymentRequest request = new PaymentRequest("customer-42", 100_00, "GBP"); PaymentResult result = paymentService.processPayment(request); assertEquals(PaymentStatus.SUCCESS, result.getStatus()); } @Test @DisplayName("processPayment should throw for null payment reference") void processPayment_nullReference_throwsIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> paymentService.processPayment(null), "Expected exception for null payment request"); } @AfterEach void cleanUpTestData() { // Clean only data created during THIS test paymentService.deleteTestPayments("customer-42"); } @AfterAll static void tearDownDatabase() { embeddedDb.close(); System.out.println("Embedded DB stopped"); } }
Parameterized Tests with @ParameterizedTest
Parameterized tests are one of JUnit 5's biggest improvements over JUnit 4. Instead of writing five near-identical test methods for five input variations, you write one and drive it with data.
@ValueSource for primitive lists. @CsvSource for multiple parameters per test case. @MethodSource for complex objects. @EnumSource for testing against all values of an enum.
I use @CsvSource for the majority of business-logic edge cases — it keeps the test data inline and readable without a separate data factory.
package io.thecodeforge.payment; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.*; import static org.junit.jupiter.api.Assertions.*; class PaymentValidatorTest { private final PaymentValidator validator = new PaymentValidator(); @ParameterizedTest(name = "amount {0} should be valid") @ValueSource(ints = {1, 100, 999_99, 1_000_00}) void validate_validAmounts_pass(int amountInPence) { assertTrue(validator.isValidAmount(amountInPence)); } @ParameterizedTest(name = "amount={0} currency={1} should {2}") @CsvSource({ "100, GBP, true", "100, USD, true", "0, GBP, false", // zero amount invalid "-100, GBP, false", // negative amount invalid "100, XYZ, false" // unknown currency invalid }) void validate_amountAndCurrency(int amount, String currency, boolean expected) { assertEquals(expected, validator.isValid(amount, currency)); } @ParameterizedTest @EnumSource(value = PaymentMethod.class, names = {"CARD", "BANK_TRANSFER"}) void validate_supportedPaymentMethods_pass(PaymentMethod method) { assertTrue(validator.isSupportedMethod(method)); } // @MethodSource for complex objects @ParameterizedTest @MethodSource("invalidPaymentRequests") void validate_invalidRequests_fail(PaymentRequest request, String reason) { assertFalse(validator.isValid(request), "Expected invalid: " + reason); } static Stream<Arguments> invalidPaymentRequests() { return Stream.of( Arguments.of(new PaymentRequest(null, 100, "GBP"), "null customer"), Arguments.of(new PaymentRequest("c1", -1, "GBP"), "negative amount"), Arguments.of(new PaymentRequest("c1", 100, null), "null currency") ); } }
Other Key Annotations
The annotations that complete the toolkit: @Disabled skips a test with an explanatory reason. @DisplayName replaces cryptic method names in test reports. @Nested groups related tests with shared setup. @Timeout fails a test that runs too long — essential for catching accidental blocking calls in async code.
package io.thecodeforge.order; import org.junit.jupiter.api.*; import java.time.Duration; class OrderServiceTest { @Test @Disabled("Disabled until JIRA-1234 is resolved — payment gateway sandbox is down") @DisplayName("processOrder should trigger payment") void processOrder_triggersPayment() { // Will be skipped with the reason logged } @Test @Timeout(2) // Fails if this test takes more than 2 seconds @DisplayName("fetchOrder should return within SLA") void fetchOrder_returnsWithinSla() { OrderService service = new OrderService(); Order result = service.fetchOrder("order-123"); assertNotNull(result); } @Nested @DisplayName("When order is in PENDING state") class WhenPending { private Order pendingOrder; @BeforeEach void setUp() { pendingOrder = Order.builder().status(OrderStatus.PENDING).build(); } @Test @DisplayName("can be cancelled") void canBeCancelled() { assertTrue(pendingOrder.canBeCancelled()); } @Test @DisplayName("cannot be refunded") void cannotBeRefunded() { assertFalse(pendingOrder.canBeRefunded()); } } }
Controlling Test Execution Order with @TestMethodOrder
By default, JUnit 5 does not guarantee test execution order. If your tests depend on a specific order (they shouldn't, but sometimes legacy code requires it), use @TestMethodOrder. Available strategies: MethodName (alphabetical), OrderAnnotation (custom @Order), Random (to detect flaky dependencies), or a custom implementation.
I've seen teams rely on order to share state between tests — that's a design smell. But if you're migrating a JUnit 4 suite that used @FixMethodOrder, @TestMethodOrder(MethodName) is the direct replacement. For new code, stick with @Order if you must have ordering, and never share state between tests.
Using @Order(n) is the simplest way to make order explicit — just annotate each test with @Order(1), @Order(2), etc.
package io.thecodeforge.order; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; @TestMethodOrder(MethodName.class) class OrderedTestSuite { private static int counter = 0; @Test void testB() { assertEquals(1, ++counter); // Runs first: testA -> testB -> testC } @Test void testA() { assertEquals(0, ++counter); // Actually runs before testB alphabetically } @Test void testC() { // some test } } // Alternative with @Order annotation @TestMethodOrder(OrderAnnotation.class) class ExplicitOrderTest { private static int counter = 0; @Test @Order(1) void first() { assertEquals(1, ++counter); } @Test @Order(2) void second() { assertEquals(2, ++counter); } @Test @Order(3) void third() { assertEquals(3, ++counter); } }
Repeated Tests and Tagging with @RepeatedTest and @Tag
@RepeatedTest runs the same test method multiple times — useful for stress testing or verifying idempotent behavior. You can customize the display name to include the current repetition: @RepeatedTest(value = 10, name = "Run {currentRepetition} of {totalRepetitions}").
@Tag marks tests for filtering. Use it to separate unit from integration tests: @Tag("fast"), @Tag("slow"). Then run mvn test -Dgroups="fast" to skip slow tests during development.
A common pattern: tag all tests that call external services as @Tag("integration") and exclude them from local builds.
package io.thecodeforge.retry; import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; class RetryServiceTest { @RepeatedTest(value = 5, name = "Attempt {currentRepetition} of {totalRepetitions}") @Tag("fast") @DisplayName("Idempotent retry should always succeed") void testIdempotentRetry(RepetitionInfo info) { int attempt = info.getCurrentRepetition(); assertTrue(attempt >= 1 && attempt <= 5); } @Test @Tag("slow") @DisplayName("Payment gateway call times out after 3 retries") void testGatewayRetry() { // Simulate external call assertTrue(true); } } // In build.gradle or pom.xml you can filter: // mvn test -Dgroups="fast" (excludes @Tag("slow") tests)
Maven Dependencies: Stop Copy-Pasting the Wrong Version
You’d think adding JUnit 5 to a project would be brainless. Yet every week some junior tags me on a PR with a NoClassDefFoundError because they grabbed junit-vintage-engine when they needed jupiter, or worse, they pinned an ancient 5.0.0 release from a 2016 blog post. Here’s the only dependency you need for modern JUnit 5 tests.
The JUnit team split the monolith into three sub-projects: Platform, Jupiter, and Vintage. For new tests, you want the junit-jupiter aggregator artifact. It pulls in the engine, the API, and the parameterized-test extension in one shot. No, you don’t need the vintage engine unless you’re running JUnit 4 tests alongside — and if you’re starting fresh, you’re not.
Don’t put this in your main source tree. This is test scope, period. And pin a specific version — 5.10.2 as of this writing — because the team releases quarterly and breaking changes in parameterized resolvers have bitten me twice.
<!-- io.thecodeforge — java tutorial -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>junit-jupiter aggregator artifact for all new JUnit 5 projects; pin the version, set scope to test, and never look back.Architecture: Why JUnit 5 Isn’t Just a Library Anymore
JUnit 5 isn’t a monolithic JAR like its predecessor. It’s three distinct sub-projects that work together like a well-oiled CI pipeline. Understanding this architecture saves you from the dreaded 'What the hell is a TestEngine?' moment when your IDE refuses to run a test.
JUnit Platform is the launcher. It sits between your build tool (Maven, Gradle, your own CLI) and the actual test engines. It discovers tests via the ServiceLoader mechanism and delegates execution. No platform, no test run.
JUnit Jupiter is the programming model you actually write code against. New annotations (@Test, @ParameterizedTest, @Tag), extension APIs, and assertion methods. This is the module you import.
JUnit Vintage exists for a single reason: backward compatibility. If you have existing JUnit 4 tests you’re too scared to rewrite, vintage bridges them to the platform. But don’t use it as a crutch. New code in 2024 should be Jupiter-only.
The practical takeaway: When you add junit-jupiter to your POM, you implicitly pull in both the platform and jupiter engines. If you ever need to debug a test runner failure, look at the TestEngine classes on your classpath first.
// io.thecodeforge — java tutorial import org.junit.platform.engine.TestEngine; import java.util.ServiceLoader; public class TestEngineInspection { public static void main(String[] args) { ServiceLoader<TestEngine> engines = ServiceLoader.load(TestEngine.class); System.out.println("Available TestEngines:"); for (TestEngine engine : engines) { System.out.println(" - " + engine.getId()); } } }
Assumptions: Fail Fast or Skip Cleanly
Assumptions are the gatekeepers you didn't know you needed. They let you abort a test gracefully when conditions aren't met, rather than throwing a hard failure. Think of them as runtime preflight checks: if the database isn't accessible, skip the test. If the JVM version is too old, don't bother. This keeps your pipeline green when the environment is off, without lying about test results.
Use assumeTrue() or assumeFalse() at the top of a test. If the condition fails, JUnit marks the test as skipped, not failed. This is crucial for conditional logic in CI, where not every node has every service running. Stop using @Disabled everywhere. That's manual. Assumptions are automatic, maintainable, and honest. Your team will thank you when the build doesn't randomly burn down.
// io.thecodeforge — java tutorial import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assumptions.assumeTrue; class AssumptionsTest { @Test void testDatabaseConnection() { assumeTrue("true".equals(System.getenv("DB_AVAILABLE")), "Skipping: database not available"); // actual test logic here System.out.println("Database test ran."); } }
assumeTrue() — never hide a real bug.Overview: JUnit 5 Is Built for Chaos
You didn't ask for another testing framework. You asked for a tool that doesn't get in the way. JUnit 5 is that tool — modular, extensible, and finally separated from the bloated monolith that was JUnit 4. The core insight: JUnit 5 is three independent modules (JUnit Platform, Jupiter, Vintage) that each own one job. This architecture means you get lambda support, better assertions, and extension points that don't require black magic.
Stop thinking about JUnit 5 as a library. It's a runtime for writing tests that survive refactors, changing environments, and your junior dev's overuse of @Test. The payoff: tests that fail for the right reasons, not because you wrote them wrong. If you're still clinging to JUnit 4 in a greenfield project, you're wasting time. Upgrade. Now.
// io.thecodeforge — java tutorial import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class SimpleJUnit5Test { @Test void verifySum() { int result = 2 + 2; assertEquals(4, result, "Addition failed — check your math"); } }
@ExtendWith: Glue Your Test to Production Infrastructure
Integration tests often need Spring context, database connections, or mocks. Instead of wiring everything manually, @ExtendWith connects JUnit 5 to third-party extensions that set up and tear down infrastructure automatically. This annotation registers one or more Extension classes that JUnit calls during test lifecycle hooks. For example, SpringExtension loads the application context, MockitoExtension initializes mocks before each test, and TempDirectory creates temporary folders for file I/O tests. Why use @ExtendWith? Because it decouples test logic from setup boilerplate. Your test stays focused on assertions while the extension handles environment provisioning. No more static @BeforeAll methods that leak state between tests. Extensions are composable: you can stack multiple @ExtendWith annotations or combine them with custom extensions for logging, timeouts, or database cleanup. The pattern replaces inheritance-based test setup with pluggable, reusable components.
// io.thecodeforge — java tutorial import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private PaymentGateway gateway; @Test void shouldProcessPayment() { // MockitoExtension automatically injects the mock } }
@TestTemplate: Run One Test Against Multiple Injection Points
Standard parameterized tests give you different arguments. @TestTemplate goes further: it runs the same test method logic against multiple contexts injected by a custom TestTemplateInvocationContextProvider. Think of it as a factory that provides not just values, but entire execution environments — including display names, extensions, and parameter resolvers. This is critical when testing the same algorithm against different data sources (file, database, stream) or different transaction managers. Why not @ParameterizedTest? Because parameterized tests assume consistent infrastructure across runs. @TestTemplate lets each invocation bring its own extension set, enabling scenarios like testing error handling with a broken database connection side by side with a happy path. The provider exposes how many invocations exist and customizes what each invocation sees, including unique display names for reporting.
// io.thecodeforge — java tutorial import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.*; class RepositoryTest { @TestTemplate @ExtendWith(RepositoryProvider.class) void shouldSaveEntity(Repository repo) { repo.save(new Entity()); // Runs once for InMemoryRepoTest and once for PostgresRepoTest } }
45-Second Test Suite from a Misplaced @BeforeEach
- Use @BeforeAll for any resource that can be shared across all tests without shared mutable state
- Use @BeforeEach only for per-test state that must be fresh (e.g., instance of class under test)
- When in doubt, measure the cost — add
System.currentTimeMillis()in @BeforeEach to see how long setup takes
grep -rn '@BeforeEach' src/test/java/grep -rn '@Test' src/test/java/ | wc -ljava -jar junit-platform-console-standalone-1.10.0.jar --scan-class-path --details verbosegrep -rn '@BeforeAll' src/test/java/javap -p -c TargetTestClass.class 2>/dev/null | grep 'ParameterizedTest'mvn test -Dtest=TargetTestClass -pl .| Annotation | Runs | Static? | JUnit 4 Equivalent |
|---|---|---|---|
| @Test | Marks a test method | No | @Test |
| @BeforeEach | Before every test | No | @Before |
| @AfterEach | After every test | No | @After |
| @BeforeAll | Once before all tests | Yes (by default) | @BeforeClass |
| @AfterAll | Once after all tests | Yes (by default) | @AfterClass |
| @Disabled | Skips the test | No | @Ignore |
| @DisplayName | Sets the test report label | No | No equivalent |
| @ParameterizedTest | Runs test multiple times with data | No | No direct equivalent |
| @Nested | Groups tests in a class | No | No direct equivalent |
| @Timeout | Fails if test exceeds duration | No | @Test(timeout=) |
| @TestMethodOrder | Controls test execution order | No | @FixMethodOrder |
| @RepeatedTest | Repeats test N times | No | No direct equivalent |
| @Tag | Labels for filtering | No | @Category |
| File | Command / Code | Purpose |
|---|---|---|
| PaymentServiceTest.java | class PaymentServiceTest { | Core Lifecycle Annotations |
| PaymentValidatorTest.java | class PaymentValidatorTest { | Parameterized Tests with @ParameterizedTest |
| OrderServiceTest.java | class OrderServiceTest { | Other Key Annotations |
| OrderedTestSuite.java | @TestMethodOrder(MethodName.class) | Controlling Test Execution Order with @TestMethodOrder |
| RetryServiceTest.java | class RetryServiceTest { | Repeated Tests and Tagging with @RepeatedTest and @Tag |
| pom.xml | Maven Dependencies | |
| TestEngineInspection.java | public class TestEngineInspection { | Architecture |
| AssumptionsTest.java | class AssumptionsTest { | Assumptions |
| SimpleJUnit5Test.java | class SimpleJUnit5Test { | Overview |
| ExtensionTest.java | @ExtendWith(MockitoExtension.class) | @ExtendWith |
| TemplateTest.java | class RepositoryTest { | @TestTemplate |
Key takeaways
Common mistakes to avoid
5 patternsUsing @BeforeEach for expensive shared setup
@BeforeAll method not static
Multiple @Test methods that depend on each other's side effects
Not using @DisplayName
Using @ParameterizedTest without a custom name template
Interview Questions on This Topic
What is the difference between @BeforeEach and @BeforeAll, and when would you use each?
How do you write a parameterized test in JUnit 5?
A test suite takes 3 minutes but only has 50 tests. What JUnit lifecycle annotation misuse would you investigate first?
How do you group related tests together with shared setup in JUnit 5?
What is the purpose of @TestMethodOrder and when would you use it?
Frequently Asked Questions
@BeforeEach runs before every single test method in the class — it's for per-test setup. @BeforeAll runs once before any tests in the class run — it's for expensive shared setup like starting an embedded database or creating an HTTP client. @BeforeAll methods must be static unless you use @TestInstance(PER_CLASS).
Use @Disabled on the test method or class. Always include a reason: @Disabled('Reason why this is skipped'). The reason appears in the test report. Avoid using @Disabled without a reason — it becomes mystery disabled tests that nobody removes.
Use @ParameterizedTest with a source annotation. @ValueSource works for single primitive parameters. @CsvSource works for multiple parameters per test case. @MethodSource works for complex objects. Add a name attribute to @ParameterizedTest to make each run identifiable in reports.
Use @Nested inner classes. Each nested class can have its own @BeforeEach, @AfterEach, and @Test methods. The display name of the nested class appears in the test report: Outer > When condition > test().
Use @TestMethodOrder on the test class with a strategy: MethodName (alphabetical), OrderAnnotation (custom @Order), Random (to detect dependencies). For explicit order, annotate each test with @Order(n).
@RepeatedTest runs the same test method N times with the same configuration — useful for stress testing. @ParameterizedTest runs the test once per input data set — each run can use different parameters.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Advanced Java. Mark it forged?
6 min read · try the examples if you haven't