The Testing Pyramid

Kinds of Tests

spring-boot-starter-test (added by Initializr) brings JUnit 5, Mockito, AssertJ, Spring Test, MockMvc and JsonPath.

TestWhat runsSpeedUse for
Unit testOne class, dependencies mockedmillisecondsService business rules, mappers, validators
@WebMvcTestControllers + MVC, no services/DB~1 sURLs, status codes, JSON, validation errors
@DataJpaTestRepositories + DB~2 sQueries (Hibernate & JPA material)
@SpringBootTestThe whole applicationseveral sEnd-to-end flows
/\         few   @SpringBootTest + Testcontainers
         /  \
        /----\      some  @WebMvcTest, @DataJpaTest
       /      \
      /--------\    many  plain JUnit unit tests

A Unit Test without Any Framework

Example

A unit test is just code that calls your class with known input and checks the result. Here the service is tested with a hand-written fake repository; JUnit and Mockito make the same thing shorter.

import java.util.*;

public class Main {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) passed++; else failed++;
        System.out.println((ok ? "PASS " : "FAIL ") + name);
    }

    public static void main(String[] args) {
        FakeRepo repo = new FakeRepo();
        repo.save(new Product(1L, "Wireless Mouse", 5));
        StockService service = new StockService(repo);

        check("reserve reduces stock", service.reserve(1L, 2).stock() == 3);
        check("reserve saves the product", repo.saves == 2);
        try {
            service.reserve(1L, 10);
            check("too much stock throws", false);
        } catch (IllegalStateException e) {
            check("too much stock throws", e.getMessage().equals("Only 3 left"));
        }
        check("unknown product throws", throwsNoSuchElement(() -> service.reserve(99L, 1)));
        System.out.println(passed + " passed, " + failed + " failed");
    }

    static boolean throwsNoSuchElement(Runnable r) {
        try { r.run(); return false; } catch (NoSuchElementException e) { return true; }
    }
}

record Product(Long id, String name, int stock) {}

class FakeRepo {
    final Map<Long, Product> rows = new HashMap<>();
    int saves = 0;
    Optional<Product> findById(Long id) { return Optional.ofNullable(rows.get(id)); }
    Product save(Product p) { saves++; rows.put(p.id(), p); return p; }
}

class StockService {
    private final FakeRepo repo;
    StockService(FakeRepo repo) { this.repo = repo; }
    Product reserve(Long id, int qty) {
        Product p = repo.findById(id).orElseThrow();
        if (p.stock() < qty) throw new IllegalStateException("Only " + p.stock() + " left");
        return repo.save(new Product(p.id(), p.name(), p.stock() - qty));
    }
}

Output

PASS reserve reduces stock
PASS reserve saves the product
PASS too much stock throws
PASS unknown product throws
4 passed, 0 failed