Saltar al contenido principal

Consulta de resultados paginados

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, en el/los commit(s) /films endpoint (spring_java), /films endpoint (spring_kotlin), /films endpoint (spring_groovy).

En el documento anterior conectamos la búsqueda de una sola película a través de la arquitectura hexagonal, desde el controlador hasta el repositorio JPA y de vuelta. Este documento agrega paginación al endpoint /films para que los clientes puedan pedir páginas de resultados en lugar de cargar todo de una vez.

Files Overview

Archivos a Crear/Modificar
File Tree
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ └── sakila
│ │ └── film
│ │ ├── adapter
│ │ │ ├── in
│ │ │ │ └── rest
│ │ │ │ ├── FilmRestController.java
│ │ │ │ └── FilmRestMapper.java
│ │ │ └── out
│ │ │ └── jpa
│ │ │ ├── FilmJpaMapper.java
│ │ │ ├── FilmJpaRepository.java
│ │ │ └── FilmRepositoryImpl.java
│ │ └── domain
│ │ ├── port
│ │ │ ├── in
│ │ │ │ └── FilmUseCases.java
│ │ │ └── out
│ │ │ └── FilmRepository.java
│ │ └── service
│ │ └── FilmUseCasesImpl.java
│ └── resources
│ ├── application.yaml
│ ├── application-dev.yaml
│ └── openapi.yaml
└── test
└── java
└── dev
└── pollito
└── spring_java
├── sakila
│ └── film
│ ├── adapter
│ │ ├── in
│ │ │ └── rest
│ │ │ └── FilmRestControllerMockMvcTest.java
│ │ └── out
│ │ └── jpa
│ │ └── FilmRepositoryImplDataJpaTest.java
│ └── domain
│ └── service
│ └── FilmUseCasesImplTest.java
└── test
└── util
└── MockMvcResultMatchers.java

Cambia cada capa de la arquitectura:

  • La especificación OpenAPI recibe x-spring-paginated: true en el endpoint /films.
  • FilmUseCases y FilmRepository ganan un método getFilms(Pageable).
  • El repositorio JPA sobrescribe findAll con un @EntityGraph.
  • El mapper REST gana un mapeo de Page a FilmListResponseAllOfData.
  • Los archivos application.yaml incorporan los valores por defecto de paginación de Spring Data.
  • Se agrega una aserción compartida hasPageFields() en MockMvcResultMatchers.

La especificación OpenAPI y x-spring-paginated

El endpoint /films ya existía en la especificación OpenAPI, pero antes de estos commits lanzaba RuntimeException("Not implemented"). La adición clave es x-spring-paginated: true:

openapi.yaml (snippet)
paths:
/films:
get:
x-spring-paginated: true
tags:
- Films
operationId: getFilms

Esta extensión personalizada le dice al generador de OpenAPI que resuelva los parámetros de consulta page, size y sort en un objeto Pageable de Spring, en lugar de tres parámetros de método separados. Sin esto, la interfaz del controlador generado se vería como getFilms(Integer page, Integer size, List<String> sort). Con esto, el generador produce getFilms(Pageable pageable), y PageableHandlerMethodArgumentResolver de Spring Data parsea automáticamente la query string en una instancia de Pageable.

springdoc-openapi-starter-webmvc-ui

Los archivos de build también agregan la dependencia springdoc-openapi-starter-webmvc-ui:3.0.2. Esto sirve la especificación OpenAPI como una Swagger UI en vivo en /swagger-ui.html, lo que viene bien para exploración ad-hoc. No afecta la lógica de paginación en sí.

build.gradle (snippet)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'

Configurar los valores por defecto de paginación

Spring Data te permite configurar valores globales por defecto para paginación, así cada request que omite page o size recibe valores predecibles. Los tres archivos YAML (application.yaml, application-dev.yaml, application-test.yaml) agregan el mismo bloque:

resources/application.yaml (snippet)
spring:
data:
web:
pageable:
default-page-size: 10
max-page-size: 100
  • default-page-size: 10 — cuando el cliente no incluye size, devolver 10 resultados por página.
  • max-page-size: 100 — limitar cualquier pedido a 100 resultados por página, así un cliente no puede pedir las 1000 películas de una sola vez.

Agregar paginación a través de las capas

Interfaces de puerto del dominio

Tanto el puerto de entrada (FilmUseCases) como el de salida (FilmRepository) ganan un método getFilms(Pageable) que devuelve Page<Film>:

java/dev/pollito/spring_java/sakila/film/domain/port/in/FilmUseCases.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FilmUseCases {
Film getFilm(Integer id);

Page<Film> getFilms(Pageable pageable);
}
java/dev/pollito/spring_java/sakila/film/domain/port/out/FilmRepository.java
package dev.pollito.spring_java.sakila.film.domain.port.out;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface FilmRepository {
Film getFilm(Integer id);

Page<Film> getFilms(Pageable pageable);
}

La capa de dominio solo conoce Page<Film> y Pageable. No sabe nada de JPA ni de HTTP.

Implementación del caso de uso

FilmUseCasesImpl delega directamente al repositorio — mismo patrón que getFilm:

java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImpl.java
package dev.pollito.spring_java.sakila.film.domain.service;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmUseCasesImpl implements FilmUseCases {
private final FilmRepository repository;

@Override
public Film getFilm(Integer id) {
return repository.getFilm(id);
}

@Override
public Page<Film> getFilms(Pageable pageable) {
return repository.getFilms(pageable);
}
}

Repositorio JPA con @EntityGraph

FilmJpaRepository ahora sobrescribe findAll(Pageable) con un @EntityGraph que trae con eager fetch las relaciones de language. Sin esto, acceder a film.languageByLanguageId dispararía una query N+1 por cada fila de la página.

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmJpaRepository.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import dev.pollito.spring_java.sakila.generated.entity.Film;
import java.util.Optional;
import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FilmJpaRepository extends JpaRepository<Film, Integer> {

@EntityGraph(attributePaths = {"languageByLanguageId", "languageByOriginalLanguageId"})
Page<Film> findAll(@NonNull Pageable pageable);

@EntityGraph(attributePaths = {"languageByLanguageId", "languageByOriginalLanguageId"})
Optional<Film> findById(Integer id);
}

La anotación @EntityGraph le dice a Hibernate que busque esas relaciones en la misma query usando un JOIN, en lugar de emitir un SELECT separado por el language de cada película.

Mapear Page<Entity> a Page<Film>

MapStruct (Java y Kotlin) y ModelMapper (Groovy) manejan el mapeo de Page de forma distinta.

En Java, un método default en la interfaz de MapStruct delega a source.map(this::map), que aplica el mapper de entidad individual existente a cada elemento y preserva los metadatos de paginación:

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmJpaMapper.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

import dev.pollito.spring_java.common.util.EnumUtils;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.model.FilmLanguage;
import dev.pollito.spring_java.sakila.film.domain.model.FilmRating;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.jspecify.annotations.NonNull;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.springframework.data.domain.Page;

@Mapper(
componentModel = SPRING,
imports = {
EnumUtils.class,
FilmLanguage.class,
FilmRating.class,
OffsetDateTime.class,
ZoneOffset.class
})
public interface FilmJpaMapper {

@Mapping(target = "id", source = "filmId")
@Mapping(
target = "language",
expression =
"java(source.getLanguageByLanguageId() != null && source.getLanguageByLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByLanguageId().getName()) : null)")
@Mapping(
target = "originalLanguage",
expression =
"java(source.getLanguageByOriginalLanguageId() != null && source.getLanguageByOriginalLanguageId().getName() != null ? EnumUtils.fromValue(FilmLanguage.class, source.getLanguageByOriginalLanguageId().getName()) : null)")
@Mapping(
target = "releaseYear",
expression =
"java(source.getReleaseYear() != null ? source.getReleaseYear().getYear() : null)")
@Mapping(
target = "rating",
expression =
"java(source.getRating() != null ? EnumUtils.fromValue(FilmRating.class, source.getRating()) : null)")
@Mapping(
target = "lastUpdate",
expression =
"java(source.getLastUpdate() != null ? source.getLastUpdate().atOffset(ZoneOffset.UTC) : null)")
Film map(dev.pollito.spring_java.sakila.generated.entity.Film source);

default Page<Film> map(
@NonNull Page<dev.pollito.spring_java.sakila.generated.entity.Film> source) {
return source.map(this::map);
}
}

Implementación del repositorio

FilmRepositoryImpl conecta el repositorio JPA y el mapper:

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FilmRepositoryImpl implements FilmRepository {
private final FilmJpaRepository repository;
private final FilmJpaMapper mapper;

@Override
public Film getFilm(Integer id) {
return mapper.map(repository.findById(id).orElseThrow());
}

@Override
public Page<Film> getFilms(Pageable pageable) {
return mapper.map(repository.findAll(pageable));
}
}

El método getFilms(Pageable) pasa el Pageable directo a repository.findAll(...), recibe un Page<FilmEntity>, y lo convierte a Page<Film> a través del mapper. Los metadatos de paginación (total de elementos, total de páginas, info de página actual) viajan solos porque Page.map() los preserva.

Mapear la respuesta REST

La especificación OpenAPI define FilmListResponse como un allOf que combina ResponseMetadata (instance, status, timestamp, trace) con una propiedad data. La propiedad data es a su vez un allOf del schema Page de Spring y un objeto con un array content de items Film.

El mapper del lado REST convierte Page<Film> (dominio) a FilmListResponseAllOfData (DTO generado):

MapStruct mapea Page<Film> directamente a FilmListResponseAllOfData, que refleja la estructura Page de Spring (content, pageable, totalElements, totalPages):

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.generated.model.FilmListResponseAllOfData;
import org.mapstruct.Mapper;
import org.springframework.data.domain.Page;

@Mapper(componentModel = SPRING)
public interface FilmRestMapper {
dev.pollito.spring_java.sakila.generated.model.Film map(Film source);

FilmListResponseAllOfData map(Page<Film> source);
}

El método del controlador

La interfaz generada FilmsApi ahora declara getFilms(Pageable pageable) gracias a x-spring-paginated. El controlador construye la respuesta usando el mismo patrón que el endpoint de una sola película:

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import static io.opentelemetry.api.trace.Span.current;
import static java.time.OffsetDateTime.now;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.ResponseEntity.ok;

import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import dev.pollito.spring_java.sakila.generated.api.FilmsApi;
import dev.pollito.spring_java.sakila.generated.model.FilmFields;
import dev.pollito.spring_java.sakila.generated.model.FilmListResponse;
import dev.pollito.spring_java.sakila.generated.model.FilmResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class FilmRestController implements FilmsApi {
private final FilmUseCases useCases;
private final FilmRestMapper mapper;
private final HttpServletRequest request;

@Override
public ResponseEntity<FilmResponse> createFilm(FilmFields filmFields) {
throw new RuntimeException("Not implemented");
}

@Override
public ResponseEntity<Void> deleteFilm(Integer id) {
throw new RuntimeException("Not implemented");
}

@Override
public ResponseEntity<FilmResponse> getFilm(Integer id) {
return ok(
new FilmResponse()
.data(mapper.map(useCases.getFilm(id)))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}

@Override
public ResponseEntity<FilmListResponse> getFilms(Pageable pageable) {
return ok(
new FilmListResponse()
.data(mapper.map(useCases.getFilms(pageable)))
.instance(request.getRequestURI())
.timestamp(now())
.trace(current().getSpanContext().getTraceId())
.status(OK.value()));
}

@Override
public ResponseEntity<FilmResponse> updateFilm(Integer id, FilmFields filmFields) {
throw new RuntimeException("Not implemented");
}
}

El parámetro Pageable se resuelve automáticamente por Spring MVC a partir de los parámetros de consulta page, size y sort, acotado por los valores por defecto y max-page-size configurados en application.yaml.

Probar la paginación

Test de integración — @DataJpaTest

El test del repositorio JPA verifica que getFilms(PageRequest.of(0, 10)) devuelva una página con 10 películas cuyos IDs empiezan en 1:

java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImplDataJpaTest.java
package dev.pollito.spring_java.sakila.film.adapter.out.jpa;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.data.domain.PageRequest.of;
import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_CLASS;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import java.util.List;
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.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

@DataJpaTest
@ActiveProfiles("test")
@Import({FilmRepositoryImpl.class, FilmJpaMapperImpl.class})
@Sql(
scripts = {"/sakila-schema.sql", "/sakila-data.sql"},
executionPhase = BEFORE_TEST_CLASS)
class FilmRepositoryImplDataJpaTest {

@SuppressWarnings("unused")
@Autowired
private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
assertEquals(1, repository.getFilm(1).getId());
}

@Test
void getFilmsReturnsAPage() {
var page = repository.getFilms(of(0, 10));
assertNotNull(page);
assertEquals(10, page.getSize());
assertEquals(
List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10),
page.getContent().stream().map(Film::getId).toList());
}
}

Test unitario — repositorio mockeado

El test del caso de uso verifica que getFilms delegue al repositorio y devuelva la página:

java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImplTest.java
package dev.pollito.spring_java.sakila.film.domain.service;

import static java.util.Collections.emptyList;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.data.domain.PageRequest.of;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.out.FilmRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

@ExtendWith(MockitoExtension.class)
class FilmUseCasesImplTest {
@InjectMocks private FilmUseCasesImpl useCases;
@Mock private FilmRepository repository;

@Test
void getFilmReturnsADomainModel() {
when(repository.getFilm(anyInt())).thenReturn(mock(Film.class));
assertNotNull(useCases.getFilm(1));
}

@Test
void getFilmsReturnsAPage() {
when(repository.getFilms(any(Pageable.class)))
.thenReturn(new PageImpl<>(emptyList(), of(0, 10), 0));
assertNotNull(useCases.getFilms(of(0, 10)));
}
}

Aserciones reutilizables de paginación

El test del controlador necesita verificar que la respuesta JSON contenga los campos de paginación (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages). En lugar de repetir esas aserciones jsonPath en cada test, se agrega un método compartido hasPageFields() al utilitario MockMvcResultMatchers de cada lenguaje:

java/dev/pollito/spring_java/test/util/MockMvcResultMatchers.java
package dev.pollito.spring_java.test.util;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.ResultMatcher;

public final class MockMvcResultMatchers {

private MockMvcResultMatchers() {}

public static ResultMatcher hasStandardApiResponseFields(
String expectedInstance, HttpStatus expectedStatus) {
return result -> {
jsonPath("$.instance").value(expectedInstance).match(result);
jsonPath("$.status").value(expectedStatus.value()).match(result);
jsonPath("$.timestamp").exists().match(result);
jsonPath("$.trace").exists().match(result);
};
}

public static ResultMatcher hasErrorFields(HttpStatus expectedStatus) {
return result -> jsonPath("$.title").value(expectedStatus.getReasonPhrase()).match(result);
}

public static ResultMatcher hasPageFields() {
return result -> {
jsonPath("$.data.content").isArray().match(result);
jsonPath("$.data.pageable.pageNumber").isNumber().match(result);
jsonPath("$.data.pageable.pageSize").isNumber().match(result);
jsonPath("$.data.totalElements").isNumber().match(result);
jsonPath("$.data.totalPages").isNumber().match(result);
};
}
}

hasPageFields() aserta que $.data.content es un array y que $.data.pageable.pageNumber, $.data.pageable.pageSize, $.data.totalElements y $.data.totalPages son todos números. Los tests del controlador lo llaman junto a hasStandardApiResponseFields().

Test del controlador — MockMvc

El test del controlador antes estaba asertando 500 INTERNAL_SERVER_ERROR (porque getFilms lanzaba RuntimeException("Not implemented")). Ahora mockea useCases.getFilms(any(Pageable.class)) para devolver una página vacía y aserta que la respuesta incluye los campos estándar de la API más los campos de paginación (content, pageable.pageNumber, pageable.pageSize, totalElements, totalPages):

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestControllerMockMvcTest.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.*;
import static java.util.Collections.emptyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.data.domain.PageRequest.of;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.web.ControllerAdvice;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
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.Import;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(FilmRestController.class)
@Import({ControllerAdvice.class, FilmRestMapperImpl.class})
class FilmRestControllerMockMvcTest {

public static final String PATH = "/api/films";
public static final String CONTENT_BODY =
"""
{
"title": "ACADEMY DINOSAUR",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99
}
""";

@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;

@SuppressWarnings("unused")
@MockitoBean
private FilmUseCases filmUseCases;

@SuppressWarnings("unused")
@MockitoSpyBean
private FilmRestMapper mapper;

@Test
void getFilmReturnsOK() throws Exception {
Integer filmId = 1;
Film film = mock(Film.class);
when(film.getId()).thenReturn(filmId);

when(filmUseCases.getFilm(anyInt())).thenReturn(film);

mockMvc
.perform(get(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, OK));
}

@Test
void getFilmsReturnsOK() throws Exception {
when(filmUseCases.getFilms(any(Pageable.class)))
.thenReturn(new PageImpl<>(emptyList(), of(0, 10), 0));

mockMvc
.perform(get(PATH).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH, OK))
.andExpect(hasPageFields());
}

@Test
void createFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
post(PATH).contentType(APPLICATION_JSON).content(CONTENT_BODY).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH, status))
.andExpect(hasErrorFields(status));
}

@Test
void deleteFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(delete(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}

@Test
void updateFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
put(PATH + "/{id}", filmId)
.contentType(APPLICATION_JSON)
.content(CONTENT_BODY)
.accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}
}

El flujo completo

Con la paginación conectada en cada capa, un request a GET /api/films?page=0&size=5 fluye así:

Scroll to zoom • Drag corner to resize

Buildeá y ejecutá la aplicación con el perfil dev, y después golpeá el endpoint:

Terminal
curl -s "http://localhost:8080/api/films?page=0&size=5" | jq
{
"instance": "/api/films",
"status": 200,
"timestamp": "2026-04-02T18:30:00.123456789+01:00",
"trace": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"data": {
"content": [
{ "id": 1, "title": "ACADEMY DINOSAUR", ... },
{ "id": 2, "title": "ACE GOLDFINGER", ... },
{ "id": 3, "title": "ADAPTATION HOLES", ... },
{ "id": 4, "title": "AFFAIR PREJUDICE", ... },
{ "id": 5, "title": "AFRICAN EGG", ... }
],
"pageable": { "pageNumber": 0, "pageSize": 5 },
"totalElements": 1000,
"totalPages": 200
}
}