Testing the API
Test the bookings API in layers: plain unit tests for rules, @WebMvcTest with JWTs for the HTTP contract, and Testcontainers for the real database.
@WebMvcTest(BookingController.class)
@Import(SecurityConfig.class)
class BookingControllerTests {
@Autowired MockMvcTester mvc;
@MockitoBean BookingService bookings;
}
mvc.post().uri("/api/bookings").with(jwt())Each kind of test answers a different question, and the bookings project uses each for what it does best. Most tests are fast; a few are slower and prove the parts that only a real database or the full application can.
Unit tests for rules
BookingPolicy decides whether a start time gives enough notice and whether a booking is too long. It takes a Clock, so a test fixes "now" with Clock.fixed and checks the boundaries exactly: 14 minutes rejected, 15 accepted. No Spring, no mocks, milliseconds per test. Boundary bugs are the most common bugs in time rules, and they are cheapest to catch here.
Web slice tests for the HTTP contract
@WebMvcTest(BookingController.class) starts only the MVC layer: the controller, the error handler, Jackson and Spring Security. The service is replaced with @MockitoBean. These tests check what clients rely on: status codes, the Location header, JSON field names, the Problem Details shape and who may call what.
jwt() from spring-security-test puts an authenticated JWT on the request without a real token or identity provider, and lets you set the subject and authorities. Two details make the slice start: import SecurityConfig so your rules apply instead of the defaults, and provide a mock JwtDecoder because the resource server configuration needs one and there is no issuer in the test.
Repository tests against PostgreSQL
The overlap guarantee is an exclusion constraint, and H2 has no equivalent. A @DataJpaTest with a PostgreSQL container and @ServiceConnection runs the real Flyway migrations and proves the constraint rejects an overlapping insert. If someone later drops the constraint in a migration, this test fails.
A few end-to-end tests
A @SpringBootTest with the container runs the whole stack, from HTTP through security, validation, the service and the database. Keep these few: one per critical flow, such as "book, then a second user gets 409". They are slower and, when they fail, they say less about where.
Mistakes worth avoiding
- Testing only the happy path. The 400, 404 and 409 responses are part of the contract, and the 404 for another user's booking is a security property.
- Using
Instant.now()in tests. Pick fixed instants far in the future, or inject a fixed clock, so tests do not start failing on a particular day. - A new container per test method. A non-static
@Containerfield is restarted for every test; declare itstaticso it starts once per class, and share one container configuration across classes to keep the suite fast.
Example
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import java.net.URI;
import java.time.Instant;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.util.UriComponentsBuilder;
// Trimmed copies of the production types, so this file compiles on its own.
record CreateBookingRequest( Long roomId, Instant startsAt, Instant endsAt) { }
record BookingView(long id, long roomId, String roomName, Instant startsAt, Instant endsAt, String status) { }
class BookingService {
BookingView book(String ownerId, CreateBookingRequest request) { throw new UnsupportedOperationException(); }
}
class BookingConflictException extends ErrorResponseException {
BookingConflictException() {
super(HttpStatus.CONFLICT, conflict(), null);
}
static ProblemDetail conflict() {
ProblemDetail p = ProblemDetail.forStatus(HttpStatus.CONFLICT);
p.setType(URI.create("https://bookings.example.com/problems/booking-conflict"));
return p;
}
}
class BookingController {
private final BookingService bookings;
BookingController(BookingService bookings) { this.bookings = bookings; }
("/api/bookings")
ResponseEntity<BookingView> create( Jwt jwt, CreateBookingRequest body,
UriComponentsBuilder uri) {
BookingView created = bookings.book(jwt.getSubject(), body);
return ResponseEntity.created(uri.path("/api/bookings/{id}").buildAndExpand(created.id()).toUri()).body(created);
}
}
// Without an advice like this, an ErrorResponseException is sent as a bare 409 with no body.
class ApiExceptionHandler extends ResponseEntityExceptionHandler { }
class SecurityConfig {
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(j -> { }))
.csrf(c -> c.disable())
.build();
}
}
(BookingController.class)
(SecurityConfig.class)
class BookingControllerTests {
private static final String BODY = """
{"roomId": 3, "startsAt": "2030-11-02T09:00:00Z", "endsAt": "2030-11-02T10:00:00Z"}
""";
MockMvcTester mvc;
BookingService bookings;
JwtDecoder jwtDecoder; // the resource server needs one; jwt() means it is never called
void createsABookingForTheCaller() {
given(bookings.book(eq("user-42"), any())).willReturn(new BookingView(118, 3, "Lovelace",
Instant.parse("2030-11-02T09:00:00Z"), Instant.parse("2030-11-02T10:00:00Z"), "CONFIRMED"));
MvcTestResult result = mvc.post().uri("/api/bookings")
.with(jwt().jwt(token -> token.subject("user-42")))
.contentType(MediaType.APPLICATION_JSON)
.content(BODY)
.exchange();
assertThat(result).hasStatus(HttpStatus.CREATED);
assertThat(result).headers().hasValue("Location", "http://localhost/api/bookings/118");
assertThat(result).bodyJson().extractingPath("$.roomName").isEqualTo("Lovelace");
}
void rejectsRequestsWithoutAToken() {
assertThat(mvc.post().uri("/api/bookings").contentType(MediaType.APPLICATION_JSON).content(BODY))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
void reportsAnOverlapAsAProblem() {
given(bookings.book(any(), any())).willThrow(new BookingConflictException());
assertThat(mvc.post().uri("/api/bookings").with(jwt())
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson().extractingPath("$.type")
.isEqualTo("https://bookings.example.com/problems/booking-conflict");
}
}
When to use it
- Catch an off-by-one in the minimum notice rule in a unit test that runs in milliseconds on every commit.
- Prove in CI that a refactored migration still contains the overlap constraint, using a disposable PostgreSQL container.
- Guarantee that a controller change cannot drop the 409 problem type the mobile app relies on.
More examples
Unit testing the time rules with a fixed clock
Each test sits on a boundary, because "at least 15 minutes" bugs are almost always an off-by-one between <code>isBefore</code> and <code>isAfter</code>. The fixed clock makes the result the same on every machine and every day. These tests run in milliseconds, so it is cheap to add a case whenever a rule changes.
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
// The production policy, copied here so the file compiles on its own.
class BookingRuleException extends RuntimeException {
BookingRuleException(String message) { super(message); }
}
class BookingPolicy {
private final Clock clock;
private final Duration minNotice;
private final Duration maxLength;
BookingPolicy(Clock clock, Duration minNotice, Duration maxLength) {
this.clock = clock;
this.minNotice = minNotice;
this.maxLength = maxLength;
}
void check(Instant startsAt, Instant endsAt) {
if (startsAt.isBefore(Instant.now(clock).plus(minNotice))) {
throw new BookingRuleException("A booking must start at least " + minNotice.toMinutes() + " minutes from now.");
}
if (Duration.between(startsAt, endsAt).compareTo(maxLength) > 0) {
throw new BookingRuleException("A booking can last at most " + maxLength.toHours() + " hours.");
}
}
}
class BookingPolicyTests {
private static final Instant NOW = Instant.parse("2030-03-04T08:00:00Z");
private final BookingPolicy policy = new BookingPolicy(
Clock.fixed(NOW, ZoneOffset.UTC), Duration.ofMinutes(15), Duration.ofHours(8));
@Test
void acceptsExactlyTheMinimumNotice() {
Instant start = NOW.plus(Duration.ofMinutes(15));
assertThatCode(() -> policy.check(start, start.plus(Duration.ofHours(1)))).doesNotThrowAnyException();
}
@Test
void rejectsOneMinuteLessNotice() {
Instant start = NOW.plus(Duration.ofMinutes(14));
assertThatThrownBy(() -> policy.check(start, start.plus(Duration.ofHours(1))))
.isInstanceOf(BookingRuleException.class)
.hasMessageContaining("15 minutes");
}
@Test
void acceptsEightHoursButNotOneMinuteMore() {
Instant start = NOW.plus(Duration.ofDays(1));
assertThatCode(() -> policy.check(start, start.plus(Duration.ofHours(8)))).doesNotThrowAnyException();
assertThatThrownBy(() -> policy.check(start, start.plus(Duration.ofHours(8)).plusSeconds(60)))
.isInstanceOf(BookingRuleException.class);
}
}
Proving the exclusion constraint with Testcontainers
This test bypasses the service's pre-check on purpose and talks to the repository, so the only thing that can stop the second insert is the constraint. Flyway runs the real migration against the container, which also proves the migration itself is valid PostgreSQL. The second test pins the half-open range behaviour, so back-to-back meetings keep working.
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.jpa.repository.JpaRepository;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
// Trimmed copies of the entities and repositories, so this file compiles on its own.
@Entity @Table(name = "rooms")
class Room {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String name; int capacity;
protected Room() { }
Room(String name, int capacity) { this.name = name; this.capacity = capacity; }
}
@Entity @Table(name = "bookings")
class Booking {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
@ManyToOne @JoinColumn(name = "room_id") Room room;
@Column(name = "owner_id") String ownerId;
@Column(name = "starts_at") Instant startsAt;
@Column(name = "ends_at") Instant endsAt;
String status = "CONFIRMED";
protected Booking() { }
Booking(Room room, String ownerId, Instant startsAt, Instant endsAt) {
this.room = room; this.ownerId = ownerId; this.startsAt = startsAt; this.endsAt = endsAt;
}
}
interface RoomRepository extends JpaRepository<Room, Long> { }
interface BookingRepository extends JpaRepository<Booking, Long> { }
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class BookingConstraintTests {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine");
@Autowired
RoomRepository rooms;
@Autowired
BookingRepository bookings;
@Test
void theDatabaseRejectsAnOverlappingBooking() {
Room room = rooms.save(new Room("Lovelace", 8));
bookings.saveAndFlush(new Booking(room, "user-1",
Instant.parse("2030-11-02T09:00:00Z"), Instant.parse("2030-11-02T10:00:00Z")));
assertThatThrownBy(() -> bookings.saveAndFlush(new Booking(room, "user-2",
Instant.parse("2030-11-02T09:30:00Z"), Instant.parse("2030-11-02T11:00:00Z"))))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void backToBackBookingsAreAllowed() {
Room room = rooms.save(new Room("Hopper", 4));
bookings.saveAndFlush(new Booking(room, "user-1",
Instant.parse("2030-11-02T09:00:00Z"), Instant.parse("2030-11-02T10:00:00Z")));
bookings.saveAndFlush(new Booking(room, "user-2",
Instant.parse("2030-11-02T10:00:00Z"), Instant.parse("2030-11-02T11:00:00Z")));
}
}
One end-to-end flow
This test lives in the application's own test folder, so <code>@SpringBootTest</code> finds <code>BookingsApplication</code> and starts everything. Note that <code>jwt()</code> does not run your <code>JwtAuthenticationConverter</code>, so a <code>roles</code> claim on the test token would not become <code>ROLE_ADMIN</code>; the test passes the authority directly with <code>.authorities(...)</code>. Assuming the new room gets ID 1 is fine in a fresh container, but read the <code>Location</code> header once the suite shares a database.
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class BookingFlowTests {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine");
@MockitoBean
JwtDecoder jwtDecoder;
@Autowired
MockMvcTester mvc;
@Test
void secondUserCannotBookAnOverlappingSlot() {
String room = """
{"name": "Turing", "capacity": 6}
""";
assertThat(mvc.post().uri("/api/rooms")
.with(jwt().jwt(t -> t.subject("admin-1"))
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
.contentType(MediaType.APPLICATION_JSON).content(room))
.hasStatus(HttpStatus.CREATED);
String booking = """
{"roomId": 1, "startsAt": "2030-11-02T09:00:00Z", "endsAt": "2030-11-02T10:00:00Z"}
""";
assertThat(mvc.post().uri("/api/bookings").with(jwt().jwt(t -> t.subject("user-1")))
.contentType(MediaType.APPLICATION_JSON).content(booking))
.hasStatus(HttpStatus.CREATED);
assertThat(mvc.post().uri("/api/bookings").with(jwt().jwt(t -> t.subject("user-2")))
.contentType(MediaType.APPLICATION_JSON).content(booking))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson().extractingPath("$.type")
.isEqualTo("https://bookings.example.com/problems/booking-conflict");
}
}
Discussion