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.

Syntax@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 @Container field is restarted for every test; declare it static so it starts once per class, and share one container configuration across classes to keep the suite fast.

Example

Example · java
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(@NotNull Long roomId, @NotNull Instant startsAt, @NotNull 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;
    }
}
@RestController
class BookingController {
    private final BookingService bookings;
    BookingController(BookingService bookings) { this.bookings = bookings; }
    @PostMapping("/api/bookings")
    ResponseEntity<BookingView> create(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody 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.
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler { }
@Configuration
class SecurityConfig {
    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
                .oauth2ResourceServer(o -> o.jwt(j -> { }))
                .csrf(c -> c.disable())
                .build();
    }
}

@WebMvcTest(BookingController.class)
@Import(SecurityConfig.class)
class BookingControllerTests {

    private static final String BODY = """
            {"roomId": 3, "startsAt": "2030-11-02T09:00:00Z", "endsAt": "2030-11-02T10:00:00Z"}
            """;

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    BookingService bookings;

    @MockitoBean
    JwtDecoder jwtDecoder;   // the resource server needs one; jwt() means it is never called

    @Test
    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");
    }

    @Test
    void rejectsRequestsWithoutAToken() {
        assertThat(mvc.post().uri("/api/bookings").contentType(MediaType.APPLICATION_JSON).content(BODY))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }

    @Test
    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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.