JUnit 5 Annotations: @Test, @BeforeEach, @AfterEach and More
- @BeforeEach runs before every test β use it for cheap per-test setup. @BeforeAll runs once β use it for expensive shared setup like database or network clients.
- @ParameterizedTest with @CsvSource or @MethodSource eliminates repetitive test methods for multiple input variations.
- @Nested classes group related tests with their own @BeforeEach setup, making complex test classes readable.
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.
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"); } }
# 1. @BeforeAll (once)
# 2. @BeforeEach β test 1 β @AfterEach
# 3. @BeforeEach β test 2 β @AfterEach
# 4. @AfterAll (once)
Embedded DB started β shared across all tests
Test run: 2 tests passed
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") ); } }
PaymentValidatorTest > validate_validAmounts_pass(int) > amount 100 should be valid PASSED
PaymentValidatorTest > validate_amountAndCurrency > amount=100 currency=GBP should true PASSED
PaymentValidatorTest > validate_amountAndCurrency > amount=0 currency=GBP should false PASSED
# ... all 14 parameterized cases ...
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()); } } }
OrderServiceTest > processOrder should trigger payment SKIPPED
Reason: Disabled until JIRA-1234 is resolved
@Nested test output:
OrderServiceTest > When order is in PENDING state > can be cancelled PASSED
OrderServiceTest > When order is in PENDING state > cannot be refunded PASSED
| 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=) |
π― Key Takeaways
- @BeforeEach runs before every test β use it for cheap per-test setup. @BeforeAll runs once β use it for expensive shared setup like database or network clients.
- @ParameterizedTest with @CsvSource or @MethodSource eliminates repetitive test methods for multiple input variations.
- @Nested classes group related tests with their own @BeforeEach setup, making complex test classes readable.
- @DisplayName on every test and class transforms opaque method names into human-readable test documentation in CI reports.
- @Timeout is essential for tests that involve async code or network calls β catches accidental blocking that would otherwise stall your CI pipeline indefinitely.
β Common Mistakes to Avoid
- βUsing @BeforeEach for expensive shared setup like database connections β this runs before every single test, not once. Use @BeforeAll for expensive resources.
- β@BeforeAll method not being static β JUnit 5 requires @BeforeAll methods to be static unless you annotate the class with @TestInstance(Lifecycle.PER_CLASS).
- βWriting multiple @Test methods that depend on each other's side effects β each @Test must be independent. JUnit does not guarantee execution order without @TestMethodOrder.
- βNot using @DisplayName β test reports become unreadable with method names like 'test3'. DisplayName is the difference between a test suite that communicates intent and one that doesn't.
Interview Questions on This Topic
- QWhat is the difference between @BeforeEach and @BeforeAll, and when would you use each?
- QHow do you write a parameterized test in JUnit 5?
- QA test suite takes 3 minutes but only has 50 tests. What JUnit lifecycle annotation misuse would you investigate first?
- QHow do you group related tests together with shared setup in JUnit 5?
Frequently Asked Questions
What is the difference between @BeforeEach and @BeforeAll in JUnit 5?
@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).
How do I skip a test in JUnit 5?
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.
How do I run the same test with multiple inputs in JUnit 5?
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.