Lessons available in both languages
Java Backend · Interview Prep

Spring Testing interview questions & answers

0+ real Spring Testing interview questions with model answers, plus free lessons to learn the concepts. Prepare in English & Hinglish, then practise with an AI mock interview.

18 topics · 0+ questions

How Hirenix teaches

One chapter. 90 minutes.
Interview-ready.

Every concept starts with a real-world problem — the kind that actually shows up in production code. Nothing to cram; it just clicks. Every question comes with a model answer: exactly what to say in the room, and why. Then an AI mock interview on the same chapter.

  • 📖Concept in 5 minutesNo jargon — straight to the point
  • 🛠️Real-world problemThe kind production code throws at you
  • 💬Model answerExactly what to say in the room
  • 🧠FlashcardsRevise in 10 minutes
  • 🤖AI mock interviewIt asks follow-ups too
  • 📊Weak topicsSee exactly where you're stuck
Start this chapter — free🌐 English🇮🇳 Hinglish
A student learning an interview concept on Hirenix at home
Video playlistbuilt around a syllabus18h+
Hirenix chapterbuilt around interviews90 min

The difference isn’t the content — it’s the filter. Only what’s actually used in production and actually asked in interviews. Textbook topics the industry never touches don’t make the cut.

Lessons available in both languages

What you’ll learn

  • Why test Spring apps: unit, slice and full-context tests
  • JUnit 5 essentials: lifecycle, assertions and parameterized tests
  • AssertJ and assertions that explain their own failureFree account
  • Mockito fundamentals: mock, stub, verify and strict stubsFree account
  • SpringBootTest and its web environments: MOCK vs RANDOM_PORTFree account
  • Test slices: what WebMvcTest loads and what it leaves outFree account
  • MockMvc: building requests and asserting responsesFree account
  • MockitoBean, the deprecated MockBean, and replacing beans in a contextFree account
  • DataJpaTest: the embedded database and automatic rollbackFree account
  • Testing Spring Security: WithMockUser, csrf and denial testsFree account
  • Context caching: why one test suite is fast and another is slowFree account
  • TestConfiguration, test properties and profilesFree account
  • Testcontainers and real databases: when H2 is not enoughFree account
  • Transactional test pitfalls: tests that pass for the wrong reasonFree account
  • RecapFree account
  • Project: Test the Secure Job Board API
  • Project: Repository Tests with DataJpaTestFree account
  • Project: Speed Up a Slow Test SuiteFree account

Why test Spring apps: unit, slice and full-context tests

Most Spring test suites that feel slow or fragile share one root cause: every test starts the whole application, because @SpringBootTest was the first annotation somebody found. This topic is about picking the smallest test that can actually catch the bug you are worried about - and proving the test can fail at all.

One application, three levels of test

The demo is a small piece of a job board. ApplicationScorer holds pure logic (experience is capped at five, knowing Java adds five), and ScoreController exposes it over HTTP. The same code is tested four ways:

Level What it builds Measured in this run
Unit - plain JUnit new ApplicationScorer(), nothing else passes; no Spring annotation anywhere in the class
Unit - Mockito the controller plus a @Mock scorer via MockitoExtension passes; still no Spring annotation, no context
Slice - @WebMvcTest the web layer only; the scorer replaced by @MockitoBean 165 bean definitions
Full - @SpringBootTest the whole application context 276 bean definitions

Those counts belong to this one application on Spring Boot 3.5.16 - do not quote them as universal numbers. Quote the shape instead: the slice registered roughly 60% of what the full context did, for an app with only two classes of its own. On a real service with repositories, HTTP clients and schedulers, that gap is where test time goes. (No timing was measured in this environment, so this topic makes no speed claim.)

What each level can and cannot catch

  • A plain unit test proves the rule: score(12, true) is 10 because experience is capped. It cannot tell you the controller is mapped to /score, or that Spring can build the bean at all.
  • A Mockito unit test proves the controller delegates correctly. @ExtendWith(MockitoExtension.class) gives you @Mock and @InjectMocks without any container - it is still a unit test.
  • A slice proves web wiring: request mapping, JSON, the security chain, @ControllerAdvice. It deliberately replaces everything below the controller with mocks.
  • A full-context test is an integration test: real beans wired together. It is the only level of the three that fails when a bean cannot be created.
  • An end-to-end test drives the running system from the outside - a browser or real HTTP against a real database. This environment has no browser, so here it stays a definition.

That is the test pyramid in practice: many unit tests at the base, fewer slices and integration tests in the middle, very few end-to-end tests at the top.

What spring-boot-starter-test puts on the classpath

Read directly from the spring-boot-starter-test 3.5.16 POM (an artifact read, not a run): spring-boot-test, spring-boot-test-autoconfigure, spring-test, JUnit Jupiter, mockito-core, mockito-junit-jupiter, AssertJ, Hamcrest, JsonPath, JSONassert, awaitility and xmlunit-core. One test dependency, and every tool in this chapter is available.

A test you have never seen fail proves very little

Run 2 keeps the same assertion and changes only the expected value, from 10 to 11. The runner answers expected: <11> but was: <10> and [ 1 tests failed ]. That is a negative control: before you trust a green test, break it on purpose once and watch it turn red. The rest of this chapter keeps finding tests that are green for the wrong reason, and this habit is the cheapest defence.

When to use which level: start at the lowest level that can see the bug. A business rule -> plain unit test. A class that calls collaborators -> Mockito unit test. Request mapping, JSON shape, validation or security rules -> @WebMvcTest. Bean wiring, configuration, several layers together -> @SpringBootTest.

When NOT to reach for @SpringBootTest: when the question is one class's logic. You pay for the whole context (276 definitions here, against none) and a failure points at application startup instead of the rule you broke.

Trade-off: it cuts both ways. A suite made only of mocks can be entirely green while the real application cannot start, because mocks never check wiring. Keep a small number of full-context integration tests for exactly that job.

// ---- t1/ApplicationScorer.java : pure logic, no database, no HTTP ----
@Service
public class ApplicationScorer {
  public int score(int yearsExperience, boolean knowsJava) {
    int s = Math.min(yearsExperience, 5);          // experience counts up to 5
    return knowsJava ? s + 5 : s;
  }
}

// ---- t1/ScoreController.java ----
@RestController
public class ScoreController {
  private final ApplicationScorer scorer;
  public ScoreController(ApplicationScorer scorer) { this.scorer = scorer; }
  @GetMapping("/score") public int score(@RequestParam int years, @RequestParam boolean java) { return scorer.score(years, java); }
}

// ---- Level 1: plain JUnit. No Spring, no context, just `new`. ----
class ScorerUnitTest {
  @Test void capsExperienceAtFive() {
    ApplicationScorer scorer = new ApplicationScorer();
    assertEquals(10, scorer.score(12, true));
    System.out.println("UNIT  score(12,true)=" + scorer.score(12, true) + "  (no ApplicationContext created)");
  }
}

// ---- Still level 1: a controller tested with Mockito alone. No Spring context is started. ----
@ExtendWith(MockitoExtension.class)
class ScoreControllerMockitoTest {
  @Mock ApplicationScorer scorer;
  @InjectMocks ScoreController controller;
  @Test void delegatesToScorer() {
    given(scorer.score(3, true)).willReturn(8);
    assertEquals(8, controller.score(3, true));
    verify(scorer).score(3, true);
    System.out.println("MOCKITO controller.score(3,true)=" + controller.score(3, true) + "  (no ApplicationContext created)");
  }
}

// ---- Level 2: a slice. Only the web layer is built; the scorer is replaced by a mock. ----
@WebMvcTest(ScoreController.class)
class ScorerSliceTest {
  @Autowired ApplicationContext ctx;
  @MockitoBean ApplicationScorer scorer;
  @Test void webLayerOnly() {
    System.out.println("SLICE beans=" + ctx.getBeanDefinitionCount()
        + "  controller=" + ctx.getBeanNamesForType(ScoreController.class).length);
  }
}

// ---- Level 3: the whole application context, real beans wired together. ----
@SpringBootTest
class ScorerFullTest {
  @Autowired ApplicationContext ctx;
  @Autowired ApplicationScorer scorer;
  @Test void wholeContext() {
    assertEquals(10, scorer.score(12, true));
    System.out.println("FULL  beans=" + ctx.getBeanDefinitionCount() + "  scorer=" + scorer.getClass().getSimpleName());
  }
}

// ---- t1neg: same assertion with a deliberately WRONG expectation - proves the runner really fails. ----
class ScorerNegativeControlTest {
  @Test void wrongExpectation() { assertEquals(11, new ApplicationScorer().score(12, true)); }
}

// Run with the JUnit Platform console launcher (Boot 3.5.16, JUnit 5.12.2, Mockito 5.17.0, JDK 17):
//   run 1: --select-class ScorerUnitTest ScoreControllerMockitoTest ScorerSliceTest ScorerFullTest
//   run 2: --select-class ScorerNegativeControlTest

JUnit 5 essentials: lifecycle, assertions and parameterized tests

JUnit 5 is not a single library. It is a platform plus engines, and almost every confusing thing people hit in their first week - tests running in an odd order, a field that was set but is suddenly empty, a setup method JUnit refuses to run - is explained by that structure and by the lifecycle. Everything below was run, not recited.

Platform, Jupiter, Vintage

Look at the tree the launcher printed. It has three roots: JUnit Platform Suite, JUnit Jupiter and JUnit Vintage.

  • The JUnit Platform discovers and launches tests and reports results. Build tools and IDEs run tests through it (per the JUnit documentation - no build tool was run here). This chapter's environment has no build tool at all - it drives the Platform's console launcher directly, which is why every output in the chapter ends with lines like [ 1 tests failed ].
  • Jupiter is the engine for the JUnit 5 programming model: @Test, @BeforeEach, @ParameterizedTest, @Nested, @ExtendWith.
  • Vintage is the engine that runs old JUnit 4 tests on the same Platform. It appears here because the standalone launcher bundles it; it found nothing to run.

The lifecycle, measured

Three facts are visible in the LifecycleTest output:

  1. @BeforeAll ran once, before anything else, and @AfterAll once at the very end. Both are static.
  2. A new instance of the test class was created for every test method - two methods, two different instance ids (210ab13f, 3b35a229). A field one test sets is simply not there in the next test. That default is what keeps tests from leaking state through instance fields.
  3. @BeforeEach and @AfterEach wrapped each test, on that test's own instance.

Why must @BeforeAll be static? Because it runs before any instance exists. Remove static and JUnit will not run the class at all:

@BeforeAll method 'void t2bad.NonStaticBeforeAllTest.setUp()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).

Note what the summary said for that class: 0 tests successful, 0 tests failed. The class failed as a container, so its test never ran - a report that shows no failures is not the same as a report that shows passes.

The order is not the order you wrote

first is declared before second, and second ran first. In the nested class, spaces are rejected ran before null is rejected, again the reverse of the source. JUnit 5 does not promise declaration order. A test that only passes after some other test has run is broken, and @TestMethodOrder is for the rare case where the order itself is the thing under test - not a way to paper over shared state.

Assertions that check the right failure

assertThrows returns the exception, so assert on the message as well as the type - rejectsMonthly checks the exact text expected LPA, got: 50000 per month. When the code throws a different type than expected, the failure is precise: Unexpected exception type thrown, expected: <java.lang.NullPointerException> but was: <java.lang.NumberFormatException>.

One rule, many inputs, readable reports

@ParameterizedTest with @CsvSource turned one method into three reported cases, each named by the name pattern ("12 LPA" -> 1200000). The quoted ' 7 LPA ' shows how to keep leading and trailing spaces in a CSV value. @Nested plus @DisplayName grouped the blank-input cases under a readable heading, so the tree reads like a specification.

JUnit 4 to JUnit 5 (documented - not run here)

This environment has no JUnit 4 jar, so this mapping is from the JUnit 5 documentation rather than a run: @Before/@After became @BeforeEach/@AfterEach; @BeforeClass/@AfterClass became @BeforeAll/@AfterAll; @RunWith(...) became @ExtendWith(...) (the previous topic ran @ExtendWith(MockitoExtension.class)); @Ignore became @Disabled; @Test(expected = ...) became assertThrows. The annotations moved from org.junit to org.junit.jupiter.api.

When to use @ParameterizedTest: when one rule must hold across a table of inputs - parsing, validation, boundary values. Each row is reported separately, so a failure names the exact input.

When to use @Nested: when a class under test has distinct situations (blank input, valid input, overflow) and you want the report grouped the same way.

When NOT to use @TestMethodOrder or a shared field: to make one test depend on another's leftovers. The measured order already differs from the source; relying on it produces a suite that breaks when someone adds a method.

Trade-off: the error message names @TestInstance(Lifecycle.PER_CLASS) as the way to allow a non-static @BeforeAll. Per the JUnit documentation (not run here) that uses one instance for the whole class - convenient for expensive setup, but it gives up the fresh-instance isolation measured above.

// ---- t2/SalaryParser.java : "12 LPA" -> 1200000 rupees per year ----
public class SalaryParser {
  public static long parse(String text) {
    if (text == null || text.isBlank()) throw new IllegalArgumentException("salary is blank");
    String t = text.trim();
    if (!t.endsWith("LPA")) throw new IllegalArgumentException("expected LPA, got: " + t);
    double lakhs = Double.parseDouble(t.substring(0, t.length() - 3).trim());
    return Math.round(lakhs * 100_000);
  }
}

// ---- t2/LifecycleTest.java ----
class LifecycleTest {
  LifecycleTest() { System.out.println("  new LifecycleTest instance@" + Integer.toHexString(System.identityHashCode(this))); }
  @BeforeAll static void beforeAll() { System.out.println("@BeforeAll (static, once)"); }
  @BeforeEach void beforeEach() { System.out.println("  @BeforeEach on instance@" + Integer.toHexString(System.identityHashCode(this))); }
  @Test void first()  { System.out.println("    test first"); }
  @Test void second() { System.out.println("    test second"); }
  @AfterEach void afterEach() { System.out.println("  @AfterEach"); }
  @AfterAll static void afterAll() { System.out.println("@AfterAll (static, once)"); }
}

// ---- t2/SalaryParserTest.java ----
@DisplayName("SalaryParser")
class SalaryParserTest {
  @ParameterizedTest(name = "\"{0}\" -> {1}")
  @CsvSource({ "12 LPA, 1200000", "4.5 LPA, 450000", "' 7 LPA ', 700000" })
  void parsesLakhs(String text, long expected) { assertEquals(expected, SalaryParser.parse(text)); }

  @Test @DisplayName("rejects a monthly figure, with a useful message")
  void rejectsMonthly() {
    IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> SalaryParser.parse("50000 per month"));
    assertEquals("expected LPA, got: 50000 per month", e.getMessage());
  }

  @Nested @DisplayName("when the input is blank")
  class WhenBlank {
    @Test @DisplayName("null is rejected")  void nullInput()  { assertThrows(IllegalArgumentException.class, () -> SalaryParser.parse(null)); }
    @Test @DisplayName("spaces are rejected") void spaces()   { assertThrows(IllegalArgumentException.class, () -> SalaryParser.parse("   ")); }
  }
}

// ---- t2bad/NonStaticBeforeAllTest.java ----
class NonStaticBeforeAllTest {
  @BeforeAll void setUp() { }          // not static
  @Test void anything() { }
}

// ---- t2bad/WrongExceptionTest.java ----
class WrongExceptionTest {
  @Test void expectsNpeButGetsNumberFormat() {
    assertThrows(NullPointerException.class, () -> Double.parseDouble("twelve"));
  }
}

// Each class run separately with the JUnit Platform 1.12.2 console launcher (Jupiter 5.12.2, JDK 17).

Ready to practise Spring Testing?

Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.