1. Manual context — a dead end
Honestly, starting AnnotationConfigApplicationContext manually in a test is like starting a car by pushing it. Once — fine, just to feel the mechanics. But driving like that every day... well, you'll quickly become a person with very strong legs and a very sad face.
We already started AnnotationConfigApplicationContext manually once, and for a first introduction that was the right thing to do. It makes it clearer that the test really works with a real container, not with a "Spring-like" setup built on new.
The problem is not that the "manual context" does not work. It does. The problem is that as soon as you have more than one or two context tests, you run into repeated code, no normal integration with the JUnit lifecycle, and awkward access to beans directly from the test class. And then you are already writing a "test bootstrap" that suspiciously resembles mini-Spring. Great plan: write Spring so you can test Spring.
We need a mechanism that:
- starts the context automatically for the test class,
- lets you conveniently fetch beans through injection,
- makes the test configuration part of the contract (readable right from the annotations),
- does not force you to close the context manually and think about the lifecycle.
That mechanism is the JUnit Jupiter + Spring Test + @SpringJUnitConfig combination.
2. SpringExtension and JUnit Jupiter
In this section, we will peek a little under the hood, but carefully: just enough so that you get a mental model, not "I just put an annotation there and somehow it works". This matters because tests are the kind of thing that break always "at the worst possible moment", and understanding the mechanism is more useful than doing magic rituals.
JUnit Jupiter can plug in extensions via @ExtendWith(...). An extension is a piece of code that JUnit calls at the right moments of the test lifecycle: before creating the test class, before the test, after the test, and so on. Spring provides the SpringExtension extension, which does the magic of "start the context, inject the beans, give me the ApplicationContext, and let's go test".
So instead of doing new AnnotationConfigApplicationContext(...) in every test, we tell JUnit Jupiter: "please run this test with this extension". And the Spring extension itself:
- creates the ApplicationContext from the specified configuration,
- injects dependencies into the test class,
- gives you beans via @Autowired,
- and even tries to cache the context between tests (more on that later).
The process looks like this in schematic form:
flowchart TD
A["JUnit Jupiter starts the test class"] --> B["@ExtendWith(SpringExtension)"]
B --> C["Spring TestContext Framework"]
C --> D["@ContextConfiguration / @SpringJUnitConfig"]
D --> E["Creation of ApplicationContext"]
E --> F["Injection of beans into the test instance"]
F --> G["Running @Test methods"]
And here is the pleasant part: we do not need to write @ExtendWith(SpringExtension.class) manually if we use @SpringJUnitConfig, because @SpringJUnitConfig is a convenient bundle for tests. Let's move on to it.
3. The @SpringJUnitConfig annotation
Now comes the moment when the brain wants to say: "Oh, another annotation, let's just memorize it." But we will do better: we will understand what exactly it does. Then you will not be afraid of either errors or other people's tests in an old project.
@SpringJUnitConfig is an annotation from Spring Test (spring-test) that essentially combines two things:
- it adds the JUnit Jupiter Spring extension (SpringExtension),
- it defines the test context configuration (@ContextConfiguration) — that is, it answers the question "which context exactly should be started?".
You can think of it as the phrase:
"This test is context-based. Start the Spring context using this configuration."
It usually looks like this:
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(TestConfig.class) // Specify which config to use for the test context
class SomeSpringTest {
}
If you start googling (later, when you grow up; for now, don't), you will see that @SpringJUnitConfig is a composite (meta-)annotation. Roughly like this, conceptually:
// pseudocode, just to understand the idea
@ExtendWith(SpringExtension.class)
@ContextConfiguration
public @interface SpringJUnitConfig {
}
And from this follows the main conclusion: @SpringJUnitConfig is not "magic", but a compact, readable way to connect Spring Test to JUnit Jupiter and specify which context to load.
A small nuance about dependencies
Since we are working in the course without the Spring Boot runtime, nobody will pull in spring-test for us "by default". So in Gradle you need test dependencies for JUnit Jupiter and Spring Test. Usually it looks something like this:
dependencies {
// test dependencies
testImplementation("org.junit.jupiter:junit-jupiter")
// Spring Test: SpringExtension, TestContext Framework and @SpringJUnitConfig
testImplementation("org.springframework:spring-test")
}
tasks.test {
// Important: enable running through JUnit Platform
useJUnitPlatform()
}
If this is missing, then @SpringJUnitConfig simply will not be found, and the IDE will honestly say: "such an annotation does not exist." It is not being difficult — it is just not telepathic.
4. Minimal example with @Autowired
Now let's assemble the "skeleton" of a context-based test that will look almost like real tests in the ContextFlow project, but will remain small and understandable. Our goal here is to see the full cycle: configuration → context startup → injection → verification.
Let's start with a tiny configuration. Let it return one bean — the application name (just as an example).
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration // Tell Spring that this is a configuration class
class TestConfig {
@Bean // Register String as a bean in the context
String appName() {
return "contextflow-test";
}
}
Now the test. The simplest thing: inject String into the test class and check the value.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // Start the context using TestConfig
class SpringContextTest {
@Autowired
String appName; // Spring will fill this field (through SpringExtension)
@Test
void injectsBeanFromContext() {
// Check that the injection really worked and the bean is the one we expected
assertEquals("contextflow-test", appName);
}
}
It is important to understand what is happening here. The appName field is not a "magical test field". It is an ordinary field that Spring fills in the same way it fills dependencies in any other bean. It is just that the test class also gets "injection", because JUnit creates it, but SpringExtension fits into the process and performs dependency injection.
Yes, this is field injection. Yes, we do not like it in production code. But in a test class it is acceptable as a technical access to beans. Right now we are testing the container setup, and we want to get the needed objects quickly and clearly.
5. Configuration: Java and XML
In practice, you will be testing two similar but different things. Sometimes you want to start the context using Java configuration (which is typical for our project), and sometimes you want to deliberately start a small XML fragment (because we have legacy/*.xml, and we promised ourselves not to panic at the sight of angle brackets).
@SpringJUnitConfig supports both scenarios. It has two "main paths":
- specify configuration classes (Java config),
- specify locations (XML or other configuration resources).
Let's summarize this in a small table — it is useful specifically as a "choice map":
| What we are testing | How to usually specify the context | Example |
|---|---|---|
| Java config / modular config | @SpringJUnitConfig(SomeConfig.class) | @SpringJUnitConfig(ContextFlowAppConfig.class) |
| Legacy XML fragment | @SpringJUnitConfig(locations = "classpath:...") | @SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml") |
Option A: Java configuration
Let's imagine that we have a root config that assembles ContextFlow (in a real project it may be called differently; the main thing is the idea).
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration // Root config that "glues" the application modules together
@Import({CoreConfig.class, ReportingConfig.class}) // Import sub-configs
class ContextFlowAppConfig {
}
Then the test class looks like this:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(ContextFlowAppConfig.class) // Start the "assembled" application context
class ContextFlowWiringTest {
@Autowired
Object someBean; // In a real test this will be a concrete bean/service
@Test
void contextStarts() {
// Smoke check: the context started and dependencies resolve
assertNotNull(someBean);
}
}
This looks silly right now (checking Object), but we are intentionally keeping the example small. In the next lecture you will turn this into a real smoke test: you will check that key services really resolve. But the context startup tool is already the same one.
Option B: XML context
Now XML. Let's say we want to check that the legacy notification module is still alive and did not die of old age.
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(locations = "classpath:legacy/legacy-notification-context.xml") // Load XML from the classpath
class LegacyXmlContextTest {
@Autowired
ApplicationContext context; // A convenient "thermometer": if it was injected, the context definitely started
@Test
void loadsXmlFragment() {
// Minimal check: the context exists and is available in the test
assertNotNull(context);
}
}
Here we injected ApplicationContext simply as a "thermometer": it shows that the context has started. Later we will inject a specific NotificationSender or AuditWriter to check that the needed beans are really created.
6. Ways to inject beans in a test
At this stage, a common question arises: "If in production code we learned constructor injection, why are we suddenly putting @Autowired on a field in the test?" Good question. And here it is important not to slide into the dogma of "this is allowed / forbidden", but to understand the motivation.
The test class is not a business class; it is not read as an architectural unit. It is a tool. In a test, two things matter: clarity and speed of understanding. That is why the most common option is indeed @Autowired on a field. It makes the dependency visible and does not require extra boilerplate.
But if you want to keep the habit of constructor injection and find it more readable, Spring also lets you inject dependencies into the test class constructor. To make the example less magical, we will explicitly mark this constructor with @Autowired.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // The context will be started for this test; when the configuration matches, Spring Test usually caches it
class ConstructorInjectedTest {
private final String appName;
@Autowired
ConstructorInjectedTest(String appName) {
this.appName = appName;
}
@Test
void constructorInjectionWorks() {
// Check that the value reached the constructor
assertEquals("contextflow-test", appName);
}
}
There is also a third style, which is sometimes convenient for small checks: injection directly into the test method parameters. This is useful when you want to "avoid state" in the test class and make the dependency as local as possible.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringJUnitConfig(TestConfig.class) // Still the same mechanism: SpringExtension + test context
class ParameterInjectedTest {
@Test
void methodParameterInjectionWorks(@Autowired String appName) {
// The dependency lives right in the test signature — convenient for small checks
assertEquals("contextflow-test", appName);
}
}
If you are a beginner, do not try to choose the "only correct" style right away. Within this course, a normal strategy is this: in tests, use @Autowired on a field as the clearest path, and use the constructor/parameters when they really improve readability.
I will separately emphasize an important idea from the course methodology: even if you use field injection in tests, do not carry this habit into production service code. A test is a laboratory, not a combat aircraft. In a laboratory, you can stand in flip-flops, but you should not climb into the cockpit in flip-flops.
7. Test contract and context width
It is very easy to make a typical mistake here: you learn that @SpringJUnitConfig can start a context and want to use it everywhere. That is a normal emotion. A Spring context in a test feels like a "control panel for the whole system": press a button — and everything works.
But a context-based test always answers the question: which context did we start, and why? If a test starts "the whole ContextFlow" just to check one small setting, then that test is not strong, it is lazy. First, it is slower; second, it is harder to debug; and third, when it fails, it produces more noise.
Good discipline looks like this: choose the configuration as narrowly as the meaning of the test allows. If you are testing a legacy XML fragment, load only that. If you are testing one configuration module, load only that module. If you are testing the assembly of the whole application, then yes, load the root config, but do it consciously.
Here is a small example of a "narrow" test: we load only one config that declares MessageSource (or any infrastructure piece). We do not need services, scenarios, or aspects. We need one bean and its properties.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringJUnitConfig(MessagesConfig.class) // Load only the narrow config needed by the test
class MessageSourceWiringTest {
@Autowired
MessageSource messageSource; // Check for the presence of the infrastructure bean
@Test
void messageSourceIsPresent() {
// Quick signal: the bean exists, the context assembled as expected
assertNotNull(messageSource);
}
}
You will notice that the test becomes "narrow" not for philosophy's sake, but for practical reasons: if it fails, you immediately know where to look for the problem.
8. Caching the test context
There is one thing that at first seems like magic, and then becomes a source of strange bugs. So it is better to warn you in advance, before you manage to step on the rake face-first.
The Spring TestContext Framework usually caches the ApplicationContext between tests if the configuration matches. This is done for speed: starting the context every time is a fairly expensive operation. So you will often see behavior like this: the first test with a context starts noticeably slower, and the rest are faster.
Why does this matter? Because if you mutate the state of a singleton bean in one test, another test that uses the same context may receive "dirty" state. And then you get tests that "fail only if you run them all together" — a classic that all developers in the world love (no).
Within this course, the healthiest approach is simple: keep your services and infrastructure as stateless as possible, and make tests avoid mutating global singletons. If you need to "collect event data" or "count calls", do it through separate test beans (collectors/stubs), but in a way that their state does not affect other tests.
We will not go into a separate discussion right now about how to clear the cache or forcibly recreate the context — that is already too far from the main goal of the lecture. Our goal is for you to understand why tests sometimes "affect each other" and not create such situations in the training project.
9. Typical mistakes when using @SpringJUnitConfig
Mistake #1: "I added @SpringJUnitConfig, but the test still throws a NullPointerException."
This usually means you expect injection into an object that is not managed by Spring Test. For example, you created an object with new inside the test and think that @Autowired will work in it. Spring injects dependencies only into the test class (and into context beans), not into everything you create manually. If an object needs DI, make it a bean or assemble it manually, like in a unit test.
Mistake #2: "The annotation is not found / there is no @SpringJUnitConfig in the imports."
Almost always the problem is dependencies: spring-test is not included or JUnit Jupiter is not included. In a plain Spring project without Spring Boot, nobody will add this for you. Check testImplementation("org.springframework:spring-test") and testImplementation("org.junit.jupiter:junit-jupiter").
Mistake #3: "I specified XML, but Spring says the resource was not found."
Usually the path is to blame here. For locations, use classpath: (for example, classpath:legacy/legacy-notification-context.xml) and make sure the file really lives under src/test/resources or src/main/resources. The path must be the path inside the classpath, not a disk path. Absolute paths like C:\projects\... in tests are almost guaranteed to break reproducibility.
Mistake #4: "The test starts the whole context and fails for some distant reason; I do not understand what this has to do with my test."
This is a symptom of a context that is too broad. If a test checks one small thing but starts the entire ContextFlow, then when it fails you will see errors about profiles, resources, conversion, legacy XML, and everything else — even if you only wanted to check one line in Environment. The fix is simple: narrow the test configuration down to the needed module.
Mistake #5: "I use @Autowired in tests and now I want to do the same in production code because it is shorter."
I get it. We all love it when there is less code. But field injection in business services brings back hidden dependencies and hurts testability. In a test class, @Autowired is a convenient "control handle" for container diagnostics. In production code, it is a habit that later hits your hands — especially in a large project. Keep this difference in mind, and you will save yourself a lot of time in the future.
GO TO FULL VERSION