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

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.
Certificates
Learn all 18 topics and earn your Spring Testing certificate
Finish every Spring Testing topic and a certificate with your name on it is issued instantly. No exam, no waiting. Download it, share it on LinkedIn, and put it on your resume.
Chapter certificate
Your name, the Spring Testing course and all 18 topics on it.
Stack Mastery certificate
Finish every Java Backend chapter for the Java Backend Stack Mastery certificate.
Anyone can verify it
Each certificate has a QR code and a public link at hirenix.in/verify.

Verified credential
hirenix.in/verify/…
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 failurePro
- Mockito fundamentals: mock, stub, verify and strict stubsPro
- SpringBootTest and its web environments: MOCK vs RANDOM_PORTPro
- Test slices: what WebMvcTest loads and what it leaves outPro
- MockMvc: building requests and asserting responsesPro
- MockitoBean, the deprecated MockBean, and replacing beans in a contextPro
- DataJpaTest: the embedded database and automatic rollbackPro
- Testing Spring Security: WithMockUser, csrf and denial testsPro
- Context caching: why one test suite is fast and another is slowPro
- TestConfiguration, test properties and profilesPro
- Testcontainers and real databases: when H2 is not enoughPro
- Transactional test pitfalls: tests that pass for the wrong reasonPro
- RecapPro
- ●Project: Test the Secure Job Board API
- Project: Repository Tests with DataJpaTestPro
- Project: Speed Up a Slow Test SuitePro
Free lessons
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 |
168 bean definitions |
Full - @SpringBootTest |
the whole application context | 279 bean definitions |
Those counts belong to this one application on Spring Boot 3.5.16, on this exact classpath - do not quote them as universal numbers. They are not even stable for this app: before Bean Validation (hibernate-validator) was added to the environment the same two tests counted 165 and 276. One library, three more definitions in each. 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@Mockand@InjectMockswithout 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 (279 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 ScorerNegativeControlTestJUnit 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:
@BeforeAllran once, before anything else, and@AfterAllonce at the very end. Both arestatic.- 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. @BeforeEachand@AfterEachwrapped 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).Project: Test the Secure Job Board API
The Spring Security chapter secured a job board API and proved each rule by calling a running server by hand: anonymous POST 401, a plain user 403, a recruiter 200, an admin delete 200. That proof expired the moment it was printed. This project writes the same proof as a test suite that runs on every build - and then breaks the configuration twice to show exactly which tests notice.
The rules under test
| Endpoint | Rule |
|---|---|
GET /api/jobs |
public |
POST /api/jobs |
RECRUITER only |
DELETE /api/jobs/** |
ADMIN only |
GET /api/jobs/mine |
no rule of its own - falls through to anyRequest().authenticated() |
SecurityConfig is its own @Configuration class, not a set of @Bean methods on the application class. The transactional-pitfalls topic measured why: beans on the main class are loaded into every test slice, and a web security bean breaks the JPA slice.
One allowed test and one denial test per rule
JobBoardRulesTest is a @WebMvcTest of the controller with @Import(SecurityConfig.class), and it pairs every "who may" with a "who may not":
- the feed: anonymous
GETis 200; - posting: anonymous 401, a
USER403, aRECRUITER200 - andjsonPath("$.postedBy")isrec, proving the job is owned by the caller's identity, not a request parameter; - deleting: a
RECRUITER403, anADMIN200; - "mine": anonymous 401; and the recruiter's job appears in the recruiter's list but not in asha's.
Run 1: all 8 green. So far this is only what a hand check already showed.
Break 1 - loosen a rule
-Ddemo.widen=true changes the delete rule from hasRole("ADMIN") to hasAnyRole("ADMIN", "RECRUITER") - the kind of one-line change that slips through review. Run 2: 7 still green, including adminCanDelete. Exactly one test failed:
recruiterCannotDelete() [X] Status expected:<403> but was:<200>
That is the whole case for denial tests. The "admin can delete" test cannot notice a rule that lets more people in. Only a test that asks the rule to refuse someone can.
Break 2 - forget the import
NoImportRulesTest is the same feed test without @Import(SecurityConfig.class). Run 3:
feedIsPublic() [X] Status expected:<200> but was:<401>
Without the import the slice secures the controller with Spring Boot's default chain (measured in the test-slices topic), where nothing is public. Here the mistake happened to be loud. The quiet version is worse: a suite that only tests authenticated calls would pass against the default chain and test none of your rules.
What @WithMockUser skipped
Every MockMvc test above put a finished user into the security context. None of them checked that rec's BCrypt password actually works. RealCredentialsTest starts a real server on a random port and sends real HTTP Basic credentials: rec:pw2 200, rec:nope 401, asha:pw1 403. That is the authentication path - UserDetailsService, PasswordEncoder, the Basic filter - exercised end to end, once. The rule tests stay fast in the slice; one real-HTTP test covers what they cannot.
The shape to take away
For every access rule, keep at least one test that expects refusal (401 or 403), import the real security configuration into the slice, and add a small number of real-credential tests over HTTP. Together they fail when a rule is widened, when the configuration is missing, and when authentication itself breaks - the three ways a secured API silently stops being secured.
// ---- p16/JobController.java : the job board API from the Spring Security chapter, same behaviour ----
record Job(Long id, String title, String city, String postedBy, String status) {}
@RestController public class JobController {
private final Map<Long, Job> jobs = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
@GetMapping("/api/jobs") public Collection<Job> feed() { return jobs.values(); }
@PostMapping("/api/jobs") public Job post(@RequestParam String title, @RequestParam String city, Authentication a) {
Job j = new Job(seq.incrementAndGet(), title, city, a.getName(), "OPEN"); jobs.put(j.id(), j); return j;
}
@GetMapping("/api/jobs/mine") public List<Job> mine(Authentication a) {
return jobs.values().stream().filter(j -> j.postedBy().equals(a.getName())).toList();
}
@DeleteMapping("/api/jobs/{id}") public String delete(@PathVariable Long id) { return jobs.remove(id) == null ? "not-found" : "deleted"; }
}
// ---- p16/SecurityConfig.java : its own @Configuration, NOT on the main class ----
@Configuration public class SecurityConfig {
@Bean SecurityFilterChain chain(HttpSecurity h) throws Exception {
boolean widened = Boolean.getBoolean("demo.widen"); // simulates a careless edit to one rule
return h.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> {
a.requestMatchers(HttpMethod.GET, "/api/jobs").permitAll();
a.requestMatchers(HttpMethod.POST, "/api/jobs").hasRole("RECRUITER");
if (widened) a.requestMatchers(HttpMethod.DELETE, "/api/jobs/**").hasAnyRole("ADMIN", "RECRUITER");
else a.requestMatchers(HttpMethod.DELETE, "/api/jobs/**").hasRole("ADMIN");
a.anyRequest().authenticated();
})
.httpBasic(b -> {}).build();
}
@Bean PasswordEncoder enc() { return new BCryptPasswordEncoder(); }
@Bean UserDetailsService users(PasswordEncoder e) {
return new InMemoryUserDetailsManager(
User.withUsername("asha").password(e.encode("pw1")).roles("USER").build(),
User.withUsername("rec").password(e.encode("pw2")).roles("RECRUITER").build(),
User.withUsername("boss").password(e.encode("pw3")).roles("ADMIN").build());
}
}
// ---- p16/JobBoardRulesTest.java : one allowed test and one denial test per rule ----
@WebMvcTest(JobController.class)
@Import(SecurityConfig.class)
class JobBoardRulesTest {
@Autowired MockMvc mvc;
// GET /api/jobs : public
@Test void feedIsPublic() throws Exception { mvc.perform(get("/api/jobs")).andExpect(status().isOk()); }
// POST /api/jobs : RECRUITER only
@Test void anonymousCannotPost() throws Exception {
mvc.perform(post("/api/jobs").param("title", "Java Dev").param("city", "Pune")).andExpect(status().isUnauthorized());
}
@Test @WithMockUser(username = "asha", roles = "USER") void userCannotPost() throws Exception {
mvc.perform(post("/api/jobs").param("title", "Java Dev").param("city", "Pune")).andExpect(status().isForbidden());
}
@Test @WithMockUser(username = "rec", roles = "RECRUITER") void recruiterCanPostAndOwnsTheJob() throws Exception {
mvc.perform(post("/api/jobs").param("title", "Java Dev").param("city", "Pune"))
.andExpect(status().isOk()).andExpect(jsonPath("$.postedBy").value("rec"));
}
// DELETE /api/jobs/** : ADMIN only
@Test @WithMockUser(username = "rec", roles = "RECRUITER") void recruiterCannotDelete() throws Exception {
mvc.perform(delete("/api/jobs/1")).andExpect(status().isForbidden());
}
@Test @WithMockUser(username = "boss", roles = "ADMIN") void adminCanDelete() throws Exception {
mvc.perform(delete("/api/jobs/999")).andExpect(status().isOk()).andExpect(content().string("not-found"));
}
// GET /api/jobs/mine : no rule of its own, falls to anyRequest().authenticated()
@Test void anonymousCannotSeeMine() throws Exception { mvc.perform(get("/api/jobs/mine")).andExpect(status().isUnauthorized()); }
@Test void mineShowsOnlyTheCallersJobs() throws Exception {
mvc.perform(post("/api/jobs").param("title", "Rec Only Role").param("city", "Delhi").with(user("rec").roles("RECRUITER")))
.andExpect(status().isOk());
mvc.perform(get("/api/jobs/mine").with(user("rec").roles("RECRUITER")))
.andExpect(jsonPath("$[*].title", hasItem("Rec Only Role")));
mvc.perform(get("/api/jobs/mine").with(user("asha").roles("USER")))
.andExpect(jsonPath("$[*].title", not(hasItem("Rec Only Role"))));
}
}
// ---- p16/NoImportRulesTest.java ----
@WebMvcTest(JobController.class) // @Import(SecurityConfig.class) forgotten
class NoImportRulesTest {
@Autowired MockMvc mvc;
@Test void feedIsPublic() throws Exception { mvc.perform(get("/api/jobs")).andExpect(status().isOk()); }
}
// ---- p16/RealCredentialsTest.java : the part @WithMockUser skips ----
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RealCredentialsTest {
@Autowired TestRestTemplate http;
@Test void bcryptUsersOverRealHttp() {
int good = http.withBasicAuth("rec", "pw2").postForEntity("/api/jobs?title=JavaDev&city=Pune", null, String.class).getStatusCode().value();
int wrong = http.withBasicAuth("rec", "nope").postForEntity("/api/jobs?title=JavaDev&city=Pune", null, String.class).getStatusCode().value();
int user = http.withBasicAuth("asha", "pw1").postForEntity("/api/jobs?title=JavaDev&city=Pune", null, String.class).getStatusCode().value();
System.out.println("P16 real HTTP POST: rec:pw2 -> " + good + " | rec:nope -> " + wrong + " | asha:pw1 -> " + user);
assertEquals(200, good); assertEquals(401, wrong); assertEquals(403, user);
}
}
// run 1: JobBoardRulesTest run 2: same with -Ddemo.widen=true run 3: NoImportRulesTest run 4: RealCredentialsTest
// Boot 3.5.16, Spring Security 6.5.11 + spring-security-test, JUnit 5.12.2, JDK 17.Spring Testing interview questions & answers
10 sample questions below — 203+ in the full bank inside.
What is the difference between @Mock, @Spy and @InjectMocks in Mockito?
@Mock creates a fake with no behaviour: every method returns a default until you stub it. @Spy wraps a real object - calls run the real code but are recorded, so they can be verified or selectively overridden. @InjectMocks creates the class under test and passes the mocks and spies into it (constructor injection in the course demo). With @ExtendWith(MockitoExtension.class) these fields are created before each test, with no Spring involved.
In simple terms: The core Mockito vocabulary question.
Write the basic MockMvc chain for a JSON POST that should return 201 with a title field.
mvc.perform(post("/api/jobs").contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"Java Developer\",\"openings\":2}")).andExpect(status().isCreated()).andExpect(jsonPath("$.title").value("Java Developer")). perform builds the mock request and sends it to the DispatcherServlet; each andExpect checks one aspect of the response. This exact shape passed in the course demo.
In simple terms: The everyday MockMvc question; fluency with the chain is expected.
What does @DataJpaTest load, and what does it leave out?
It loads the persistence layer: JPA and Hibernate, your Spring Data repositories, entity scanning, a TestEntityManager, and a database - by default an embedded one. It leaves out the web layer and your @Service beans. Measured: a @Service Mailer counted 0 beans in the slice. So a @DataJpaTest tests queries and mappings, not the service logic that calls them.
In simple terms: Baseline slice question; the service exclusion is the practical consequence.
What are the JUnit Platform, Jupiter and Vintage, and how can tests run without Maven or Gradle?
The Platform discovers and launches tests and reports results; Jupiter is the engine for the JUnit 5 annotations; Vintage is the engine that runs JUnit 4 tests on the same Platform. Build tools and IDEs run tests through the Platform. In this course tests were run with the Platform's own console launcher, junit-platform-console-standalone 1.12.2, using execute --select-class ... with no build tool at all - the report tree showed JUnit Platform Suite, JUnit Jupiter and JUnit Vintage as roots.
In simple terms: Understanding the engine model explains why JUnit 4 and 5 tests can coexist in one run.
How do you stub a mock and verify an interaction in Mockito?
Stub with when(gateway.charge("u1", 99)).thenReturn("rcpt-7"), or the BDD spelling given(gateway.charge("u1", 99)).willReturn("rcpt-7"). After calling the code under test, verify with verify(gateway, times(1)).charge("u1", 99); verify(mailer, never()).send(anyString(), anyString()) asserts a call did not happen. In the demo the upgrade returned rcpt-7 and both verifications passed.
In simple terms: Basic API question; mentioning never() shows the candidate also tests absence of interactions.
What is the difference between Mockito's @Mock and Spring's @MockitoBean (or @MockBean)?
@Mock creates a Mockito object that only the test knows about - useful in plain unit tests with MockitoExtension. @MockitoBean (and the older @MockBean) creates a mock and puts it into the Spring application context, replacing the bean of that type, so every bean that depends on it receives the mock. In a Spring test, only the second changes what the application's beans see.
In simple terms: The foundational distinction behind a very common Spring testing mistake.
Why do many Spring teams prefer AssertJ's assertThat over JUnit's assertEquals?
AssertJ starts from the value under test - assertThat(actual).isEqualTo(expected) - so there is only one slot for the expected value and it cannot be swapped. It has type-aware assertions for collections, exceptions and objects (containsExactly, extracting), reads in the order of the check, and produces richer failure messages: on a wrongly ordered list it printed the full actual list, the full expected list and the exact indexes that differed. It ships in spring-boot-starter-test, so there is no extra dependency.
In simple terms: A common preference question; the best answer is about failure messages and the impossible swap, not taste.
What is the Spring TestContext context cache?
A cache of application contexts kept by the test framework within one JVM. When a test class asks for a configuration identical to one already built, it reuses that context instead of starting the application again. Measured: two test classes with the same configuration printed the same context identity, a component's constructor ran once, and the cache log showed missCount = 1. Reuse is what makes a suite of many integration tests affordable.
In simple terms: The foundation for every test-speed discussion in Spring.
In what order do @BeforeAll, @BeforeEach, @Test, @AfterEach and @AfterAll run in a JUnit 5 test class with two test methods?
@BeforeAll once, before anything else. Then for each test method: a new instance of the test class is created, @BeforeEach runs on it, the test runs, @AfterEach runs. After the last test, @AfterAll once. In the measured run two test methods produced two different instance ids, each wrapped by its own @BeforeEach/@AfterEach, with the static @BeforeAll and @AfterAll printed exactly once at the edges.
In simple terms: The basic lifecycle question, but the per-method instance is the detail that separates a memorised answer from an understood one.
How does the repository test suite set up its data, and why does every test start with zero jobs?
@BeforeEach persists one company and five jobs through TestEntityManager, then calls em.flush() and em.clear(). The seed runs inside each test's transaction, which @DataJpaTest rolls back at the end of the test, so every test printed jobs at start of test = 0 before seeding. Five tests seeded five times without seeing each other's rows, with no cleanup code.
In simple terms: Explains the setup pattern the whole project depends on.
193+ more Spring Testing questions inside
Start free to read the first 10 with model answers and take a voice mock interview. The full question bank comes with Pro.
Start free