Why JUnit matters, lifecycle annotations (JUnit 4 vs 5), testing exceptions, assertion types, parameterized tests, suites, timeouts, test structure, @Test, static methods without libraries, runners/rules vs extensions, private methods, database-free tests, parallel execution, and best practices.
Published September 25, 2026
Many interview questions are still phrased in JUnit 4 terms (@Before, @RunWith, @Rule). Answer with the JUnit 5 (Jupiter) equivalent, and mention the JUnit 4 name. That shows you know both, and that you use the current one.
Short answer: JUnit is the standard testing framework for Java. It discovers test methods, runs them with a lifecycle, provides assertions, and reports the results to IDEs and build tools. JUnit 5 has three parts: the Platform (the launcher), Jupiter (the programming model) and Vintage (runs JUnit 4 tests). Tests give fast feedback, catch regressions before production, document behaviour, and make refactoring safe.
Learn it in depth → Spring Boot Testing & Auto-Configuration
@Before and @BeforeClass? How are they used?Short answer: They're JUnit 4 annotations:
@Before runs before each test method: fresh fixtures per test.@BeforeClass runs once before all tests in the class. It must be static, and is used for expensive shared setup.JUnit 5 renames them @BeforeEach and @BeforeAll (with @AfterEach and @AfterAll). @BeforeAll can be non-static with @TestInstance(Lifecycle.PER_CLASS).
class OrderServiceTest {
static PostgreSQLContainer<?> db;
OrderService service;
@BeforeAll static void startDb() { db = new PostgreSQLContainer<>("postgres:16"); db.start(); } // once
@BeforeEach void setUp() { service = new OrderService(new InMemoryOrders(), Clock.systemUTC()); } // per test
@AfterAll static void stopDb() { db.stop(); }
}
Key points to cover:
@BeforeEach, so tests stay independent. Shared mutable state in @BeforeAll causes order-dependent failures.Short answer: In JUnit 5, use assertThrows. It returns the exception, so you can also assert on its message or fields. (JUnit 4 used @Test(expected = …), or the ExpectedException rule.)
@Test
void rejectsNegativeAmount() {
var ex = assertThrows(IllegalArgumentException.class, () -> account.withdraw(new BigDecimal("-5")));
assertEquals("amount must be positive", ex.getMessage());
}
Key points to cover:
@Test(expected = …) passes if any line throws that exception, even the setup, which can hide bugs. assertThrows targets the exact call.assertThatThrownBy(() -> …).isInstanceOf(…).hasMessageContaining(…).assertEquals, assertTrue and assertSame?Short answer:
assertEquals(expected, actual) compares values with equals(), with a helpful failure message showing both values.assertTrue(condition) checks a boolean, but its failure message only says "expected true".assertSame(expected, actual) checks reference identity (==).Key points to cover:
assertEquals(3, list.size()) rather than assertTrue(list.size() == 3)).assertAll to report several failures at once.assertEquals(expected, actual, delta). For BigDecimal, compare with compareTo, or AssertJ's isEqualByComparingTo.Short answer: One test method runs once per set of arguments. JUnit 5 uses @ParameterizedTest with sources:
@ValueSource, @CsvSource and @CsvFileSource;@MethodSource;@EnumSource;@ArgumentsSource, for custom sources.(JUnit 4 needed a whole class with @RunWith(Parameterized.class).)
@ParameterizedTest(name = "{0} → {1}")
@CsvSource({ "0, 0", "999, 0", "1000, 50", "5000, 400" })
void discountTiers(int orderTotal, int expectedDiscount) {
assertEquals(expectedDiscount, pricing.discountFor(orderTotal));
}
@ParameterizedTest
@MethodSource("invalidEmails")
void rejectsInvalid(String email) { assertFalse(validator.isValid(email)); }
static Stream<String> invalidEmails() { return Stream.of("", "a@", "@b.com", "no-at-sign"); }
Short answer: A suite groups tests to run together, for example "all fast tests" or "the payment module". In JUnit 5, use the Platform Suite engine: @Suite with @SelectPackages, @SelectClasses, @IncludeTags or @ExcludeTags. (JUnit 4: @RunWith(Suite.class) + @Suite.SuiteClasses.)
@Suite
@SelectPackages("com.acme.payments")
@IncludeTags("fast")
class PaymentsFastSuite { }
Key points to cover:
./gradlew test -PincludeTags=fast, Maven <groups>) are more common than suite classes.Short answer:
@Timeout(value = 2, unit = SECONDS) fails a test (or all tests in a class) that runs too long.assertTimeout runs the code in the same thread and fails after it finishes. assertTimeoutPreemptively aborts the code when time runs out, but beware: that code runs in a different thread, which breaks ThreadLocals and transactions.junit.jupiter.execution.timeout.default.(JUnit 4: @Test(timeout = 1000).)
Key points to cover:
await().atMost(5, SECONDS).until(() -> repo.count() == 1).Short answer: Arrange–Act–Assert (or Given–When–Then), with one behaviour per test, and a descriptive name that states the scenario and expected outcome.
@Test
@DisplayName("placing an order with insufficient stock is rejected and nothing is saved")
void rejectsOrderWhenStockInsufficient() {
// Arrange
inventory.setStock("SKU-1", 1);
// Act
var result = assertThrows(OutOfStockException.class, () -> orders.place(cartWith("SKU-1", 2)));
// Assert
assertEquals("SKU-1", result.sku());
assertEquals(0, orderRepository.count());
}
Key points to cover:
@Nested classes to group scenarios ("when the cart is empty…"). Use test data builders or Object Mothers for readable arrangement.@Test?Short answer: It marks a method as a test that the engine should discover and run. JUnit 5 test methods can be package-private, must not be private or static, and return void. Related annotations: @ParameterizedTest, @RepeatedTest, @TestFactory (dynamic tests), @Disabled, @Tag, @DisplayName.
Common trap: mixing org.junit.Test (JUnit 4) with JUnit 5 imports. The test silently doesn't run unless the Vintage engine is present.
Short answer: JUnit itself can't mock anything. It's a test runner, not a mocking library. Without extra libraries, the answer is design: wrap the static call behind an interface you can substitute (Clock, an IdGenerator, a CurrentTime provider), and inject it. With libraries, Mockito's mockStatic (built in since Mockito 5, with the inline mock maker) works, scoped with try-with-resources.
// Refactor instead of mocking: inject a Clock instead of calling LocalDate.now()
class InvoiceService {
private final Clock clock;
InvoiceService(Clock clock) { this.clock = clock; }
LocalDate dueDate() { return LocalDate.now(clock).plusDays(30); }
}
// test: new InvoiceService(Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC))
@RunWith and @Rule work? What replaced them?Short answer: In JUnit 4:
@RunWith replaces the whole runner. For example, SpringRunner, MockitoJUnitRunner or Parameterized. Only one runner is allowed per class.@Rule / @ClassRule wrap each test (or the class) with reusable behaviour: TemporaryFolder, ExpectedException, Timeout.JUnit 5 replaced both with the extension model: @ExtendWith(...). It's composable, so several extensions can be combined (SpringExtension, MockitoExtension, Testcontainers), using lifecycle callbacks, parameter resolvers and conditions.
@ExtendWith({ MockitoExtension.class })
class CheckoutServiceTest { @TempDir Path tmp; /* built-in replacement for TemporaryFolder */ }
Short answer: Test them through the public behaviour that uses them. Private methods are implementation details, and tests coupled to them break on every refactor. If a private method is complex enough to want its own tests, that's a design signal: extract it into its own class (or a package-private helper), with a public contract, and test that.
Key points to cover:
setAccessible(true)) is possible, but brittle, and blocked by modules for non-open packages. Avoid it.Short answer: Separate the unit and integration concerns:
@DataJpaTest). Queries, mappings and constraints can only be proven against the real engine.Common trap: relying on H2 to "test the database". Its SQL dialect, locking and constraint behaviour differ from PostgreSQL or MySQL, so the tests pass while production fails.
Short answer: JUnit 5 supports opt-in parallel execution through configuration (junit-platform.properties):
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
Key points to cover:
@ResourceLock for shared resources, and @Execution(SAME_THREAD) for tests that can't run concurrently.forkCount, Gradle's maxParallelForks), which isolates the static state.Short answer:
Clock and seeds.Q: What is @Nested for?
A: Grouping related tests in inner classes, each with its own setup, to express scenarios ("given an empty cart", "given a premium customer"), with readable reports.
Q: What does @TestInstance(Lifecycle.PER_CLASS) change?
A: JUnit creates one test instance per class instead of per method, so @BeforeAll can be non-static and instance state is shared. Use it carefully.
Q: How do you disable a test conditionally?
A: @Disabled("reason"), or conditions such as @EnabledOnOs, @EnabledIfEnvironmentVariable, @EnabledIfSystemProperty, or a custom ExecutionCondition extension.
Q: What are dynamic tests?
A: Tests generated at runtime by a @TestFactory method, which returns a Stream<DynamicTest>. They're useful when the test cases come from data files.