Testing with JUnit and Your First Spring Boot App
You have reached the last lesson. You can already model domains with objects, pick data structures, handle errors, persist data, and query a database. What is left is what separates an exercise from a system other people use: proving it works and exposing it so others can consume it.
1. Why tests, in numbers
A bug costs differently depending on when you find it. While writing the code: minutes. In code review: an hour. In production: a 3 a.m. wake-up, an angry customer, and a rushed patch that probably introduces another bug.
Tests do not exist to “feel confident”. They exist to catch the bug on the first rung, and so you can change code without fear. Without tests, refactoring is gambling.
2. JUnit 5 and the AAA pattern
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calc;
@BeforeEach // runs before EACH test: always a clean state
void setUpCalculator() {
calc = new Calculator();
}
@Test
@DisplayName("adding two positives returns their sum")
void addTwoPositives() {
int result = calc.add(5, 10);
assertEquals(15, result);
}
@Test
@DisplayName("dividing by zero throws ArithmeticException")
void divideByZeroThrows() {
// Verify the exception is thrown AND that it says the right thing
ArithmeticException e = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0)
);
assertTrue(e.getMessage().contains("zero"));
}
@ParameterizedTest // the same test, with many different inputs
@CsvSource({ "1, 1, 2", "0, 0, 0", "-5, 5, 0", "2147483647, 0, 2147483647" })
void addSeveralCases(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
}
@BeforeEach matters more than it looks: each test gets a fresh object. If they shared state, a test could pass or fail depending on execution order, and that is worse than having no tests.
What makes a test good
- Fast. Milliseconds. No sleeping, no network, no real database.
- Independent. Runs alone and in any order. Never depends on another test.
- Repeatable. Same result every time. Watch out for
LocalDate.now()and random numbers. - Named so it explains.
addTwoPositiveshelps;test1says nothing when it fails at 3 a.m. - One reason to fail. If it checks five different things, it is five tests.
3. Mocks: why interfaces mattered
You want to test ProductService, but it depends on ProductRepository, which hits the database. If the test needs a database it stops being a unit test: it is slow, brittle, and will not run on just any machine.
import static org.mockito.Mockito.*;
@Test
void discountUsesThePriceFromTheRepository() {
// Arrange: a test double returning exactly what we need
ProductRepository repo = mock(ProductRepository.class);
when(repo.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Tea", 1000.0, 10)));
ProductService service = new ProductService(repo); // ← injection
// Act
double finalPrice = service.discountedPrice(1L, 20);
// Assert: the result, and that the repository was queried exactly once
assertEquals(800.0, finalPrice, 0.001);
verify(repo, times(1)).findById(1L);
}
If ProductService did new JdbcRepository() inside, this test would be impossible. That is why dependencies are received, not created.
4. Spring Boot: the three layers
Spring Boot takes that injection idea and automates it across the whole application.
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
// A single constructor → Spring injects automatically. No @Autowired needed.
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public ResponseEntity<Product> get(@PathVariable long id) {
return service.findById(id)
.map(ResponseEntity::ok) // 200 with the product
.orElse(ResponseEntity.notFound().build()); // 404 when absent
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody NewProduct data) {
Product created = service.create(data);
return ResponseEntity
.created(URI.create("/api/products/" + created.id())) // 201
.body(created);
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(@PathVariable long id, @Valid @RequestBody NewProduct data) {
return service.update(id, data)
.map(ResponseEntity::ok) // 200 with the replaced product
.orElse(ResponseEntity.notFound().build()); // 404 when absent
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
return service.delete(id) ? ResponseEntity.noContent().build() // 204
: ResponseEntity.notFound().build(); // 404
}
}
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository; // the interface, not the implementation
}
public double discountedPrice(long id, int percentage) {
if (percentage < 0 || percentage > 100) {
throw new IllegalArgumentException("Discount must be between 0 and 100");
}
Product p = repository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return p.price() * (1 - percentage / 100.0);
}
public Optional<Product> update(long id, NewProduct data) {
// @Valid already guaranteed in the controller that "data" is valid.
return repository.findById(id)
.map(current -> repository.save(
new Product(current.id(), data.name(), data.price(), data.stock())));
}
}
That Optional turning into a 200 or a 404 connects straight back to the Exception Handling and Robustness lesson: “not found” is not an exception, it is a possible result, and here it maps onto an HTTP status code. get and update share the exact same pattern because they share the exact same case: the id might not be there.
PUT: an idempotent replacement, not a patch
update takes the full NewProduct and asks the repository for a total replacement: the old values are discarded, not merged with the new ones. That is why sending the same PUT once, twice, or ten times leaves the resource in exactly the same final state: PUT is idempotent.
PATCH is the other side of the coin: it changes part of the resource (for example, “add 5 units to stock”), and repeating that same request twice normally does not give the same result — each repetition adds 5 again. If the client always sends the full resource, PUT is enough; if it needs partial changes, the right verb is PATCH.
The data layer keeps the same role: the repository is still the only thing that talks to the database. update uses it twice — findById to find the current product and save to persist the replacement — and the controller never touches it directly.
PUT’s responses follow the same logic you already use in get and delete, plus one new case:
200 OKwith the updated product, when the id exists.404 Not Found, when the id does not exist. SameOptional+orElsepattern asGET.400 Bad Request, when@Validrejects the body — the same validation that already protectscreate. Spring stops the request before the controller runs, so the service never gets called.
With update the CRUD is complete:
| Operation | HTTP verb | Service method | Responses |
|---|---|---|---|
| CREATE | POST | create | 201 Created |
| READ | GET | get | 200 OK / 404 Not Found |
| UPDATE | PUT | update | 200 OK / 404 Not Found / 400 Bad Request |
| DELETE | DELETE | delete | 204 No Content / 404 Not Found |
Four verbs, three layers, and no invented status code.
The status codes that actually matter
| Code | When |
|---|---|
200 OK | The query succeeded and there is content. |
201 Created | A resource was created. Also return its URL in the Location header. |
204 No Content | It worked and there is nothing to return (classic for DELETE). |
400 Bad Request | The data sent is invalid. |
404 Not Found | The resource does not exist. |
409 Conflict | It clashes with the current state (a duplicate email, for instance). |
500 Internal Server Error | Something of yours broke. Never return this on purpose. |
5. Testing the Spring application
// Web-layer test: starts ONLY the controller, with the service mocked out
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mockMvc;
@MockBean ProductService service; // not the real one: a mock
@Test
void returns404WhenTheProductDoesNotExist() throws Exception {
when(service.findById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/products/99"))
.andExpect(status().isNotFound());
}
@Test
void returnsTheProductAsJson() throws Exception {
when(service.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Tea", 3200.0, 45)));
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Tea"))
.andExpect(jsonPath("$.price").value(3200.0));
}
@Test
void updatesTheProductAndReturns200() throws Exception {
when(service.update(eq(1L), any(NewProduct.class)))
.thenReturn(Optional.of(new Product(1L, "Premium Tea", 3400.0, 40)));
mockMvc.perform(put("/api/products/1")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Premium Tea","price":3400.0,"stock":40}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Premium Tea"));
}
@Test
void returns404WhenUpdatingAMissingProduct() throws Exception {
when(service.update(eq(99L), any(NewProduct.class)))
.thenReturn(Optional.empty());
mockMvc.perform(put("/api/products/99")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Premium Tea","price":3400.0,"stock":40}
"""))
.andExpect(status().isNotFound());
}
@Test
void returns400WithAnInvalidBody() throws Exception {
mockMvc.perform(put("/api/products/1")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"","price":-100.0,"stock":40}
"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(service); // @Valid cuts in before the service is called
}
}
// Integration test: starts the whole application. Keep these few.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductIntegrationTest {
@Autowired TestRestTemplate rest;
@Test
void createAndRetrieveAProduct() {
ResponseEntity<Product> created = rest.postForEntity(
"/api/products", new NewProduct("Coffee", 5800.0, 12), Product.class);
assertEquals(HttpStatus.CREATED, created.getStatusCode());
Product read = rest.getForObject(
"/api/products/" + created.getBody().id(), Product.class);
assertEquals("Coffee", read.name());
}
}
@WebMvcTest starts in a few hundred milliseconds because it only brings up the web layer. @SpringBootTest boots everything and takes seconds. That difference is, once again, the pyramid.
6. Common mistakes
| Mistake | What happens | How to fix it |
|---|---|---|
| Tests that depend on execution order | They pass on your machine and fail in CI, with no explanation. | @BeforeEach with fresh state; nothing shared between tests. |
| One test checking five things | When it fails you cannot tell what broke. | One test, one reason to fail. |
| Using the real database in unit tests | Slow, brittle, and impossible to run in parallel. | Mocks for unit tests; H2 or Testcontainers for integration. |
Tests using LocalDate.now() or Math.random() | They fail one day a year, or once in a hundred runs. | Inject a Clock or a fixed seed. |
Creating dependencies with new inside the class | The class cannot be tested in isolation. | Take them via constructor, typed as the interface. |
Business logic in the @RestController | It cannot be tested without booting the whole web context. | All logic in the @Service. |
@Autowired on private fields | Impossible to construct the class by hand in a test. | Constructor injection. |
Always returning 200 OK | The client cannot distinguish success from failure. | Use the right codes: 201, 204, 404, 400. |
Too many @SpringBootTest | The suite goes from seconds to twenty minutes. | @WebMvcTest, @DataJpaTest, or plain JUnit where it suffices. |
7. Guided hands-on exercise
Challenge: ProductServiceTest
Test ProductService without a database, using a mocked repository:
discountedPricecomputes a normal case correctly.- It throws
IllegalArgumentExceptionfor an out-of-range percentage. - It throws
ProductNotFoundExceptionwhen the id does not exist. createrejects negative prices and never saves anything.- A parameterized test covering several discounts at once.
updatereplaces name, price, and stock from the receivedNewProductwithout touching the id, and returnsOptional.empty()when the id does not exist.
See suggested solution
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("ProductService")
class ProductServiceTest {
private ProductRepository repository; // the test double
private ProductService service; // the thing under test
@BeforeEach
void setUpScenario() {
// A fresh mock per test: no interaction leaks into the next one
repository = mock(ProductRepository.class);
service = new ProductService(repository);
}
@Nested
@DisplayName("discountedPrice")
class DiscountedPrice {
@Test
@DisplayName("applies the percentage to the repository's price")
void appliesTheDiscount() {
// ARRANGE
when(repository.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Tea", 1000.0, 10)));
// ACT — exactly one thing
double result = service.discountedPrice(1L, 20);
// ASSERT
assertEquals(800.0, result, 0.001);
verify(repository).findById(1L); // it was queried
verifyNoMoreInteractions(repository); // and nothing else
}
@ParameterizedTest(name = "{0}% off $1000 → ${1}")
@CsvSource({ "0, 1000.0", "10, 900.0", "50, 500.0", "100, 0.0" })
@DisplayName("covers the whole valid discount range")
void severalDiscounts(int percentage, double expected) {
when(repository.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Tea", 1000.0, 10)));
assertEquals(expected, service.discountedPrice(1L, percentage), 0.001);
}
@Test
@DisplayName("rejects a percentage above 100")
void rejectsInvalidPercentage() {
IllegalArgumentException e = assertThrows(
IllegalArgumentException.class,
() -> service.discountedPrice(1L, 150)
);
assertTrue(e.getMessage().contains("between 0 and 100"));
// Key point: it failed in validation, BEFORE touching the repository
verifyNoInteractions(repository);
}
@Test
@DisplayName("throws ProductNotFoundException when the id is absent")
void missingProduct() {
when(repository.findById(99L)).thenReturn(Optional.empty());
assertThrows(ProductNotFoundException.class,
() -> service.discountedPrice(99L, 10));
}
}
@Nested
@DisplayName("create")
class Create {
@Test
@DisplayName("rejects negative prices and saves nothing")
void rejectsNegativePrice() {
NewProduct invalid = new NewProduct("Coffee", -100.0, 5);
assertThrows(IllegalArgumentException.class, () -> service.create(invalid));
// The point of this test: validation cuts in BEFORE persisting
verify(repository, never()).save(any());
}
@Test
@DisplayName("saves the product and returns what the repository gives back")
void savesValidProduct() {
NewProduct data = new NewProduct("Coffee", 5800.0, 12);
when(repository.save(any()))
.thenReturn(new Product(7L, "Coffee", 5800.0, 12));
Product created = service.create(data);
assertEquals(7L, created.id());
assertEquals("Coffee", created.name());
verify(repository).save(argThat(p -> p.name().equals("Coffee")));
}
}
@Nested
@DisplayName("update")
class Update {
@Test
@DisplayName("replaces the product while keeping the id")
void replacesTheProduct() {
NewProduct data = new NewProduct("Premium Tea", 3400.0, 40);
when(repository.findById(1L))
.thenReturn(Optional.of(new Product(1L, "Tea", 3200.0, 45)));
when(repository.save(any()))
.thenReturn(new Product(1L, "Premium Tea", 3400.0, 40));
Optional<Product> result = service.update(1L, data);
assertTrue(result.isPresent());
assertEquals("Premium Tea", result.get().name());
verify(repository).save(argThat(p -> p.id() == 1L && p.name().equals("Premium Tea")));
}
@Test
@DisplayName("returns empty and saves nothing when the id does not exist")
void doesNotUpdateAMissingProduct() {
when(repository.findById(99L)).thenReturn(Optional.empty());
Optional<Product> result =
service.update(99L, new NewProduct("Tea", 3200.0, 45));
assertTrue(result.isEmpty());
verify(repository, never()).save(any());
}
}
}
The most valuable part of this suite is not the assertEquals calls, it is the verify calls.
verifyNoInteractions(repository) in the invalid-percentage test proves something no assertEquals can: that validation cuts in before the database is consulted. If tomorrow someone reorders the method and fetches the product first, that test fails and tells you. It is a design rule turned into a test.
Same with verify(repository, never()).save(any()): it is not enough that the exception is thrown, you have to prove nothing was persisted. A service that validates after saving leaves junk in the database even when it throws the right error.
And notice all of this runs in milliseconds, with no database, no server, and no internet connection. That is a unit test.
Key takeaways
- The pyramid: a great many fast unit tests at the bottom, very few end-to-end on top. Upside down, the suite becomes useless.
- Arrange, Act, Assert. One “Act” per test, and one reason to fail.
- A good test is fast, independent, repeatable, and named so it explains what broke.
- Mocks are only possible when a class receives its dependencies instead of creating them. That is the Abstract Classes, Interfaces, and Code Organization lesson’s interfaces paying off.
- Spring Boot splits into three layers: web (
@RestController), business (@Service), and data (@Repository). - Each layer is tested differently:
@WebMvcTest, plain JUnit with mocks,@DataJpaTest. Use@SpringBootTestas little as possible. - An empty
Optionalin the service becomes a404in the controller. The same idea, in two different languages. - The full CRUD is four verbs:
POST(201),GET(200/404),PUT(200/404/400), andDELETE(204/404).PUTreplaces the whole resource and is idempotent;PATCHchanges part of it and is not necessarily idempotent. verifyproves how something was done, not just the result. That is what turns a design decision into a guarantee.
What comes next
With testing and Spring Boot you close the loop of building software that works and proving that it works. One skill is still missing, and it is not taught all at once: what to do when something breaks, and how to leave code better than you found it without breaking anything along the way. That is what the course’s last lesson, Debugging, clean code, and refactoring, is about.