1. Manual startup inside a test
A full Boot context is already enough to check whether a bean exists or whether properties are bound. But as soon as you want to control the startup moment yourself — pass arguments, enable a profile, catch the expected failure — @SpringBootTest becomes a slightly less convenient partner. It creates the context before the test method runs, which means you do not always get proper controls in your hands: sometimes you want to start the application inside assertThrows, sometimes to start it without the web layer, and sometimes to pass a “command line” just like in a real java -jar.
In this lecture, we take a step toward “black-box” thinking. We stop looking at the context as some magical thing that “the annotation will bring up” and start treating startup as an ordinary operation: create SpringApplication, pass arguments, get ConfigurableApplicationContext, check what you need — and close it carefully. This is very close to starting catalog-service outside the IDE, except that instead of a terminal we have a test method.
To avoid confusion, it helps to keep a simple comparison in mind:
| Approach | How the application starts | When it is convenient | Where the pain is |
|---|---|---|---|
| JUnit without Spring | Only your Java code is invoked | Check an enum, a value object, parsing, or small logic | Does not catch broken wiring, broken config, or startup issues |
| @SpringBootTest | Automatically, before the test method | Check that “the context is assembled at all,” get beans through @Autowired | It is hard to “catch” an expected startup error inside assertThrows |
| SpringApplication | Inside @Test, when you decide | Check different startup parameters, profiles, and negative scenarios | You must remember to close the context, otherwise tests may hang |
The choice is simple: from the cheapest tool to the most controllable one. Plain JUnit is good for pure Java logic, @SpringBootTest is for when it matters that the application assembles, and manual SpringApplication is for when you want to conduct the startup yourself.
In catalog-service, this is especially relevant because the project is heavily tied to configuration (CatalogProperties) and to fail-fast behavior: if the config is broken, we want to learn about it at startup, not “sometime later.”
2. SpringApplication, WebApplicationType.NONE, and closing the context
Let us start with the simplest case: we start our CatalogServiceApplication manually in a test and check only that the context actually came up. There are three practical reasons to do it this way. First, we can start the application with parameters exactly as it starts in real life. Second, we can disable the web runtime so the test does not try to start Tomcat and does not occupy ports. Third, we can explicitly close the context and guarantee that nothing “alive” remains after the test, such as threads, timers, and other small joys.
The key trick is WebApplicationType.NONE. It tells Boot: “Build the context, but do not turn this into a web application.” For smoke tests of startup, this is often better than bringing up a servlet runtime.
Example test (conceptually the file src/test/java/com/example/catalogservice/startup/CatalogStartupSmokeTest.java):
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CatalogStartupSmokeTest {
@Test
void startsWithValidConfiguration() {
// Create SpringApplication manually so we can control startup from the test body
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
// In startup smoke tests, a web runtime and ports are usually not needed
app.setWebApplicationType(WebApplicationType.NONE);
// Important: the context must be closed, otherwise the test run may hang because of live threads
try (ConfigurableApplicationContext context = app.run()) {
assertNotNull(context);
}
}
}
Pay attention to try-with-resources: ConfigurableApplicationContext knows how to close itself, and that is your best friend. If you do not close the context, tests sometimes continue living their own life: not always noticeable right away, but one day you will see “why is Gradle stuck on test?” and start suspecting that Java got offended again.
If you want to understand a bit better what is happening, you can think of it as a short chain:
flowchart TD A["JUnit 6 @Test"] --> B[SpringApplication] B --> C["app.run(...)"] C --> D[ConfigurableApplicationContext is up] D --> E[assertNotNull / any checks] E --> F["context.close() via try-with-resources"]
And now we have a convenient “start application” button right in the test. Next we will add control: arguments, profiles, and negative scenarios.
3. Startup arguments: simulating java -jar
When you run a Spring Boot application in real life, you often control it through parameters: --spring.profiles.active=dev, --server.port=9090, --app.catalog.max-featured-count=3, and so on. Over the past days, we talked a lot about externalized configuration and precedence, and now we finally use that as a testing tool: pass parameters to app.run(...) and check that the application started with exactly those settings.
It is important to understand that arguments passed to app.run("--key=value") in a test are not some special test mode. It is the same mechanism as in a real startup. That means you are testing not only your beans, but also your configuration layer: binder, validation, defaults, and precedence.
Let us pass one property and make sure it really reached CatalogProperties. This is no longer just “the context came up,” but a startup with the expected configuration.
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CatalogArgsStartupTest {
@Test
void appliesCommandLineProperty() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // no web server: faster and more stable
// Pass the "command line" just like during a real startup: java -jar ... --key=value
try (ConfigurableApplicationContext context =
app.run("--app.catalog.max-featured-count=3")) {
// Check that the value really reached the typed configuration
CatalogProperties props = context.getBean(CatalogProperties.class);
// Record one specific fact: maxFeaturedCount = 3
assertEquals(3, props.maxFeaturedCount());
}
}
}
Pay attention to an important methodological detail: we are not checking “the whole application.” We are checking exactly one fact: the startup parameter really reached the typed configuration. For a “minimal test baseline,” this is the ideal balance of usefulness and simplicity.
Another nuance: these tests are very good at catching mistakes like “I changed the property name in YAML” or “I renamed the record component in the properties class, but forgot that the keys are different now.” That is not always easy to notice manually, but the test forces honesty.
4. Profile-based startup: checking the active profile
Profiles (local/dev/prod) are not decoration in configuration. They are your way of honestly describing “in which modes the application lives.” Therefore, a startup smoke test for a profile is very practical: it lets you confirm that the application can in principle start with a specific profile, and that the profile really became active.
You can check the profile very simply: after startup, we read Environment from the context and look at getActiveProfiles(). This is not some “test magic” — it is just standard Spring API that we already use in the application.
Example (again, without a web runtime):
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.ConfigurableApplicationContext;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CatalogProfileStartupTest {
@Test
void startsWithDevProfile() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // the web layer is not needed to check the profile
// Activate the profile exactly as during jar startup
try (ConfigurableApplicationContext context =
app.run("--spring.profiles.active=dev")) {
// Read the active profiles from the Environment of the already started context
List<String> profiles = List.of(context.getEnvironment().getActiveProfiles());
// Check the minimal fact: the profile really turned on
assertTrue(profiles.contains("dev"));
}
}
}
Here we check two things at once. First — that the application actually starts with the dev profile, meaning the profile config is not broken. Second — that the profile really activated, and not “should have been somewhere there.” Sometimes people think the profile is active because “the IDE showed it,” but in reality, when the jar starts, the profile is not enabled at all. A smoke test helps remove this class of surprises.
And again: we are not diving into checking specific logging values, Actuator exposure, or anything else. We are capturing the most basic fact: the profile is enabled, startup passes.
5. Broken config: fail-fast tests
The most “fun” part of today’s lecture is learning to write a test in which the application must not start. Yes, that sounds a little strange: “But we want green tests!” Not quite. We want green tests that confirm the system behaves correctly. If validation for CatalogProperties is enabled, then with a broken config the right behavior is to fail fast, loudly, and without half-tones.
And this is where manual startup shines especially brightly. With @SpringBootTest, you often cannot conveniently write assertThrows because the failure happens before the method runs. But with manual startup, you start the application inside a lambda, and assertThrows works like clockwork.
There is one important prerequisite here: validation for CatalogProperties must already be properly enabled in the project, and constraints must participate in binding. If you keep only @Min on the record component but do not connect the validation model, fail-fast will not emerge from that by itself.
Example: according to the project rules, maxFeaturedCount must be greater than 0. Let us pass 0 and check that startup really fails.
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CatalogBrokenConfigTest {
@Test
void failsFastWhenMaxFeaturedCountIsZero() {
SpringApplication app = new SpringApplication(CatalogServiceApplication.class);
app.setWebApplicationType(WebApplicationType.NONE); // the web layer only gets in the way here
// Key idea: start inside assertThrows so that startup failure is expected behavior
assertThrows(Exception.class, () ->
app.run("--app.catalog.max-featured-count=0")); // deliberately invalid value
}
}
This test is very “Boot-centric” in spirit. It does not check your business logic or controllers. It checks the basic philosophy of the application: configuration is part of the startup contract. If the contract is violated, we do not pretend that “well, we will figure it out later.” We do not start.
If you want to see the mechanics, you can picture the process like this:
flowchart TD
A["app.run('--app.catalog.max-featured-count=0')"] --> B[Binder binds properties]
B --> C[Validation checks constraints]
C --> D{"valid?"}
D -- no --> E[Exception, startup aborted]
D -- yes --> F[Context is up]
Why does the example use assertThrows(Exception.class, ...) instead of something more specific? Because at our level, the very fact of fail-fast is what matters. The exact exception type may change depending on where exactly the failure happens: binding, validation, or creation of a particular bean, and you do not want to make the test brittle. In a larger “testing course,” we would discuss cause unwrapping and precise classification. Here — no. Here we are training the right habit: “break the config → the application does not start → the test captures that.”
And one more subtle point: sometimes a student expects a “broken config” to show up as an error somewhere in the logs while the application still starts anyway. That is a very dangerous illusion. If configuration defines critical invariants, as in our case: limits, required fields, currency, rates, then partial startup is almost always worse than failure. Partial startup is like a car that “almost started,” but the brakes will appear later somewhere. Spoiler: they will not.
6. Nuances: speed and cleanliness of smoke tests
Manual application startup inside a test is a powerful tool, but like any tool, it likes moderation. Bringing up a Boot context is expensive in terms of time. In a teaching project, that is fine: there are not many tests, and they are needed as a baseline. But still, it is worth remembering a simple rule: do not turn one test into a “combine harvester” that checks the profile, three properties, bean presence, and also prints half of the Environment to the console. Such a test quickly starts living its own life and becomes hard to understand.
Practically, this means that it is better to keep small, narrowly focused tests: one startup — one idea. A separate test for “starts with valid configuration,” a separate test for “starts with the dev profile,” and a separate test for “fails on a broken property.” That makes the tests readable even for someone who opens the project a month later and no longer remembers what maxFeaturedCount is, and yes, that happens.
Another nuance: we deliberately set WebApplicationType.NONE. That is both about speed and stability. In this mode, you are not dependent on ports and do not start an embedded server. Yes, our project is a web service, but context startup and web runtime startup are different levels of verification. Today we are fixing the basic level: context and config. If that is missing, there will still be no web runtime “later.”
7. Typical mistakes in startup smoke tests
Mistake #1: not closing ConfigurableApplicationContext after a successful startup.
This is a classic that looks harmless: “Well, the test is short.” In practice, an unclosed context may leave live threads, timers, or resources behind, and at some point the tests will start hanging or slowing down strangely. try-with-resources is the simplest insurance: if the context came up, it must be closed.
Mistake #2: accidentally starting the web runtime where the test is about configuration.
If you do not set WebApplicationType.NONE, the application starts as a servlet web app because we have spring-boot-starter-webmvc. Then the test may unexpectedly start requiring a free port, and it will also take longer to run. When the goal is only wiring and config, the web server is an extra character on stage.
Mistake #3: trying to check the profile, properties, and “also let it fail on an incorrect value” all in one test.
Such a test looks economical, but it quickly becomes unreadable: it is unclear what exactly it checks and why it fails. It is much healthier to make three short tests, each with one startup scenario and one idea.
Mistake #4: expecting a too specific exception type in a broken-config test.
When you write assertThrows(BeanCreationException, ...), the test can become brittle: a small change in validation or bean creation order changes the exception type, even though the meaning stays the same — the config is broken, startup is aborted. At today’s level, it is better to capture the very fact of fail-fast and not turn the test into “guess the exact exception.”
Mistake #5: forgetting that startup inside a test uses the same configuration as a normal run.
Sometimes people expect the test to “replace everything by itself,” but in fact it honestly reads your application.yaml, profile files, and imports. Therefore, if the base project configuration is already broken, even a “broken config” test may fail at a place you do not expect. That is unpleasant, but fair: first the service must start normally, and only then do we add a check that it fails abnormally.
GO TO FULL VERSION