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.

The test pyramid with its three levels, quantities, and speeds E2E Integration Unit End-to-end — very few The whole app, with a browser. Minutes, and they break on their own. Integration — some Several layers together, with a real database. Seconds. Unit — a great many One class in isolation, no database, no network. Milliseconds. The shape matters: many fast tests at the bottom, very few slow ones on top. Invert the pyramid and you get a suite that takes twenty minutes, fails for reasons unrelated to the code, and the team ends up ignoring.
If a test takes more than a second, nobody runs it before every commit. And a test that is not run protects nothing.

2. JUnit 5 and the AAA pattern

The Arrange-Act-Assert structure of a test 1. ARRANGE — set up everything the test needs in order to exist Calculator calc = new Calculator(); 2. ACT — do the thing run EXACTLY one thing: the one under test int result = calc.add(5, 10); 3. ASSERT — verify check the expected outcome, and nothing else assertEquals(15, result, "5 + 10 should be 15");
If your test has two "Act" blocks, it is really two tests. Split them: when one fails, you will know which of the two broke.
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. addTwoPositives helps; test1 says 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.

The same service class receives the real implementation in production and a mock in tests ProductService takes a ProductRepository via its constructor «interface» ProductRepository JdbcRepository IN PRODUCTION — hits PostgreSQL slow, needs the database running mock(ProductRepository) IN THE TEST — returns whatever you say instant, no infrastructure This only works because the service depends on the INTERFACE and receives it via constructor.
Dependency inversion: the class does not create what it needs, it receives it. That is what makes a design testable — and it is the [Abstract Classes, Interfaces, and Code Organization](/en/courses/java/09-clases-abstractas-interfaces-y-modelado) lesson bearing its most concrete fruit.
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.

An HTTP request travelling through the three layers of a Spring Boot application Client GET /api/products/1 @RestController — web layer Translates HTTP into Java calls and back. Validates input and picks the status code. NO business logic here. Tested with @WebMvcTest. @Service — business layer The rules live here: discounts, domain validation, transactions. Knows nothing about HTTP or SQL. Tested with plain JUnit and mocks. @Repository — data layer Persistence only: the DAO from Database Access with JDBC and Safe SQL, or Spring Data JPA. Knows nothing about business rules. Tested with @DataJpaTest. Database PostgreSQL Each layer talks only to the one below, always through an interface. That is what makes them separately testable. JSON back ↑
The separation is not bureaucracy: it is what lets you test business logic without starting a server or a database.
@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 OK with the updated product, when the id exists.
  • 404 Not Found, when the id does not exist. Same Optional + orElse pattern as GET.
  • 400 Bad Request, when @Valid rejects the body — the same validation that already protects create. Spring stops the request before the controller runs, so the service never gets called.

With update the CRUD is complete:

OperationHTTP verbService methodResponses
CREATEPOSTcreate201 Created
READGETget200 OK / 404 Not Found
UPDATEPUTupdate200 OK / 404 Not Found / 400 Bad Request
DELETEDELETEdelete204 No Content / 404 Not Found

Four verbs, three layers, and no invented status code.

The status codes that actually matter

CodeWhen
200 OKThe query succeeded and there is content.
201 CreatedA resource was created. Also return its URL in the Location header.
204 No ContentIt worked and there is nothing to return (classic for DELETE).
400 Bad RequestThe data sent is invalid.
404 Not FoundThe resource does not exist.
409 ConflictIt clashes with the current state (a duplicate email, for instance).
500 Internal Server ErrorSomething 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

MistakeWhat happensHow to fix it
Tests that depend on execution orderThey pass on your machine and fail in CI, with no explanation.@BeforeEach with fresh state; nothing shared between tests.
One test checking five thingsWhen it fails you cannot tell what broke.One test, one reason to fail.
Using the real database in unit testsSlow, 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 classThe class cannot be tested in isolation.Take them via constructor, typed as the interface.
Business logic in the @RestControllerIt cannot be tested without booting the whole web context.All logic in the @Service.
@Autowired on private fieldsImpossible to construct the class by hand in a test.Constructor injection.
Always returning 200 OKThe client cannot distinguish success from failure.Use the right codes: 201, 204, 404, 400.
Too many @SpringBootTestThe 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:

  1. discountedPrice computes a normal case correctly.
  2. It throws IllegalArgumentException for an out-of-range percentage.
  3. It throws ProductNotFoundException when the id does not exist.
  4. create rejects negative prices and never saves anything.
  5. A parameterized test covering several discounts at once.
  6. update replaces name, price, and stock from the received NewProduct without touching the id, and returns Optional.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 @SpringBootTest as little as possible.
  • An empty Optional in the service becomes a 404 in the controller. The same idea, in two different languages.
  • The full CRUD is four verbs: POST (201), GET (200/404), PUT (200/404/400), and DELETE (204/404). PUT replaces the whole resource and is idempotent; PATCH changes part of it and is not necessarily idempotent.
  • verify proves 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.