CodeGym /Cursos /Spring REST & MVC /Multipart y auditoría de pruebas web-layer

Multipart y auditoría de pruebas web-layer

Spring REST & MVC
Nivel 30, Lección 4
Disponible

1. Multipart como contrato independiente

Los endpoints multipart a menudo se rompen no porque «Spring sea malo», sino porque su contrato consta literalmente de varias partes independientes. Es como enviar un paquete, donde en un sobre va un archivo y en otro una nota con la descripción, y el mensajero (Spring MVC) espera estrictamente ambos paquetes con las etiquetas correctas. Si confundes los nombres de las partes o los tipos, todo se desmorona antes de llegar a la lógica de negocio.

Con un endpoint JSON normal todo es relativamente lineal: un Content-Type: application/json, un cuerpo, un @RequestBody. Multipart es multipart/form-data y un conjunto de partes. Cada parte tiene un nombre (por ejemplo, "file" o "metadata") y también tiene su propio Content-Type. Y si piensas «bueno, es solo una solicitud», Spring piensa diferente: «son dos (o más) fragmentos de datos independientes que deben analizarse de forma diferente y vincularse a los parámetros del método».

Otra razón por la que multipart debe probarse por separado: es muy fácil «arreglar» accidentalmente un endpoint en Postman y luego darse cuenta de que las pruebas no pueden reproducir la misma solicitud. O al revés: la prueba «de alguna manera armó multipart», pero el cliente (Postman/Frontend) lo envía de otra manera, y obtienes una sorpresa en forma de 400 Bad Request debido a una parte faltante.

Por lo tanto, la lógica aquí es simple y bastante madura: dado que multipart es una forma separada de contrato, debemos tener pruebas web-layer separadas que verifiquen precisamente el contrato multipart, no «algo sobre archivos adjuntos en general».

Hasta ahora hemos estado usando el mismo MVC slice para endpoints JSON normales. Los escenarios de archivos no requieren un modo de prueba diferente: solo cambia la forma del contrato. En lugar de un solo cuerpo aparecen partes, en lugar de JSON normal a veces hay bytes y cabeceras, pero seguimos verificando la misma respuesta HTTP externa que ve el cliente.

2. Contrato del endpoint de subida: partes y respuesta

Antes de escribir la prueba, es útil detenerse un minuto y expresar honestamente el contrato del endpoint de subida en palabras. En la API de Task Tracker, un archivo adjunto es un subrecurso de una tarea, y la subida se realiza mediante POST /api/v1/tasks/{taskId}/attachments. La solicitud multipart contiene al menos dos partes: el archivo binario (file) y los metadatos JSON (metadata). Y ese «al menos» no es una expresión artística, sino parte del acuerdo con el cliente.

Imagina la solicitud como un «pseudo‑contrato» (no RFC, solo una imagen clara):


POST /api/v1/tasks/{taskId}/attachments
Content-Type: multipart/form-data

Part "metadata" (Content-Type: application/json):
{"description":"Sprint note"}

Part "file" (Content-Type: text/plain):
<bytes of note.txt>

A nivel de Spring MVC esto suele expresarse así: @RequestPart("metadata") para la parte JSON y @RequestPart("file") para el archivo. Observa que usamos precisamente @RequestPart, no @RequestParam, porque «parte multipart» no es un parámetro de consulta ni una simple «cadena en un formulario», sino un trozo separado del cuerpo.

Mini‑ejemplo de la firma del método del controlador (la lógica interna es intencionalmente muy simple):

import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/v1/tasks/{taskId}/attachments")
class AttachmentController {

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    AttachmentResponse upload(@PathVariable String taskId,
                              // Parte JSON del multipart: Spring leerá los bytes y los convertirá a DTO mediante Jackson
                              @RequestPart("metadata") @Valid AttachmentUploadMetadataRequest metadata,
                              // Parte del archivo multipart: aquí irá el contenido binario + nombre del archivo + content type
                              @RequestPart("file") MultipartFile file) {
        // En la prueba web-layer verificamos que el controlador acepta correctamente las partes y devuelve una respuesta HTTP.
        return attachmentService.upload(taskId, metadata, file);
    }
}

En la respuesta esperamos 201 Created y un JSON con los metadatos del archivo adjunto (AttachmentResponse). Al cliente normalmente le interesa ver al menos el identificador del archivo adjunto y el nombre original del archivo (y sí, este es el caso en que «nombre del archivo» realmente es parte del contrato, no estética).

Si ahora piensas: «¿En serio? ¿Vamos a probar el nombre de la parte "metadata" y "file"?» — sí, en serio. Para el cliente es aproximadamente lo mismo que el nombre de un campo en JSON. Renombrar una parte es casi como renombrar title a taskTitle: al servidor no le cuesta, pero al cliente le duele.

3. Construcción de una solicitud multipart en MockMvc

Ahora a la parte práctica: cómo reproducir una solicitud multipart en MockMvc. Aquí hay una pequeña alegría de ingeniería: no necesitas un archivo real en el disco ni un navegador real. Spring proporciona MockMultipartFile, que se puede crear a partir de un array de bytes, estableciendo el nombre de la parte, el nombre original del archivo y el Content-Type. Es como un «sándwich de prueba»: no es una comida real, pero en composición es muy parecido y el estómago del controlador lo digerirá.

Una idea importante: la parte metadata también es un MockMultipartFile. Sí, suena un poco extraño («¿JSON como archivo?»), pero técnicamente una parte multipart es un conjunto de bytes. Si quieres que Spring lo lea mediante Jackson y construya tu AttachmentUploadMetadataRequest, debes darle a la parte el Content-Type: application/json correcto y un JSON válido en su interior.

Prácticamente es conveniente tener un ObjectMapper en la prueba para no tener que armar el JSON manualmente y evitar un «calvario de comillas». Mini‑ejemplo de creación de partes:

import static java.nio.charset.StandardCharsets.UTF_8;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.mock.web.MockMultipartFile;

// Parte del archivo: nombre part = "file" (debe coincidir con @RequestPart("file"))
MockMultipartFile file = new MockMultipartFile(
        "file",
        "note.txt",     // nombre original del archivo (parte del contrato)
        "text/plain",   // content type del archivo
        "hello".getBytes(UTF_8)
);

// Parte JSON: técnicamente también es un conjunto de bytes, solo que con content type application/json
MockMultipartFile metadata = new MockMultipartFile(
        "metadata",
        "", // el nombre original del archivo para la parte JSON normalmente no es necesario
        "application/json",
        // Generamos JSON mediante ObjectMapper para no equivocarnos con comillas/escapados
        objectMapper.writeValueAsBytes(new AttachmentUploadMetadataRequest("Sprint note"))
);

Observa un par de detalles. El nombre de la parte "file" y "metadata" debe coincidir con lo que espera el controlador. El segundo argumento (nombre original del archivo) para metadata se puede dejar vacío: no es un archivo en el sentido del usuario. Y otro punto: multipart(...) en MockMvc establecerá automáticamente el Content-Type: multipart/form-data con un boundary, no necesitas inventar los límites manualmente como en los laboratorios de redes.

Si quieres mostrar de forma muy breve lo que sucede, puedes pensar en ello así:

flowchart LR
    T[Test] --> M[MockMvc multipart request]
    M --> D[DispatcherServlet]
    D --> C[AttachmentController]
    C --> S["AttachmentService (mock)"]
    S --> C --> M --> A[Assertions]

Por eso en una prueba multipart nos centramos en lo que llega al controlador y lo que sale del controlador hacia afuera, no en cómo se «guarda realmente el archivo en el disco».

4. Prueba happy‑path: subida y 201 Created

La prueba happy‑path para un endpoint de subida es tu «ancla» del contrato. Debe mostrar al futuro lector (y a tu futuro «yo del pasado») qué consideramos una solicitud multipart correcta y cómo debe ser la respuesta exitosa. Y sí, aquí es útil ser un poco quisquilloso: si no verificas al menos los campos clave de la respuesta, la prueba se convierte en una «verificación del estado por el estado mismo».

Empecemos con el esqueleto de la prueba. En un proyecto real se ve aproximadamente así: @WebMvcTest(AttachmentController.class), MockMvc, ObjectMapper y el servicio mockeado. Ejemplo de estructura (muy mínima):

@WebMvcTest(AttachmentController.class)
class AttachmentControllerWebMvcTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;

    // El servicio se mockea: la prueba web‑layer verifica el contrato HTTP, no la lógica de negocio
    @MockitoBean AttachmentService attachmentService;
}

Ahora el happy‑path. Configuramos el servicio de antemano (stubbing) para que devuelva un AttachmentResponse conocido. Luego armamos multipart y verificamos el contrato de la respuesta.

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;

@Test
void uploadAttachment_returns201AndMetadata() throws Exception {
    // Configuramos el mock del servicio: el controlador solo debe pasar los datos de entrada y devolver la respuesta
    when(attachmentService.upload(eq("task-1"), any(), any()))
            .thenReturn(new AttachmentResponse("att-1", "note.txt")); // campos abreviados

    // Parte del archivo multipart
    MockMultipartFile file = new MockMultipartFile(
            "file",
            "note.txt",
            "text/plain",
            "hello".getBytes(UTF_8)
    );

    // Parte JSON multipart: el content type es fundamental, de lo contrario Spring/Jackson pueden no analizar el DTO
    String metadataJson = """
            {"description":"Sprint note"}
            """;
    MockMultipartFile metadata = new MockMultipartFile(
            "metadata",
            "",
            "application/json",
            metadataJson.getBytes(UTF_8)
    );

    mockMvc.perform(
                    multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
                            // Importante: el nombre de la parte debe coincidir con @RequestPart("file") / @RequestPart("metadata")
                            .file(file)
                            .file(metadata)
            )
            // Verificamos el contrato básico: estado + JSON
            .andExpect(status().isCreated())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            // Verificamos el campo clave de la respuesta en el que se apoya el cliente
            .andExpect(jsonPath("$.originalFileName").value("note.txt"));
}

Aquí es importante que la prueba no verifica «todo de una vez». Fija la promesa básica del endpoint: una subida exitosa devuelve 201, la respuesta es JSON, y en el JSON hay al menos originalFileName que coincide con lo que enviamos. En un proyecto real, por supuesto, agregarás verificaciones de id, contentType, size, uploadedAt, pero el enfoque sigue siendo el mismo: verificamos el contrato público, no la implementación interna.

Si tu endpoint de subida agrega Location, es un excelente candidato para verificar: andExpect(header(...)). Pero no conviertas la prueba en una novela de 40 páginas: mejor varias pruebas cortas que una sola «épica sobre todo».

5. Pruebas de camino negativo: parte faltante, 415, 409

En el mundo multipart, los escenarios de camino negativo son especialmente importantes, porque algunos errores ocurren antes del servicio y otros ya dentro de la lógica de negocio. Y si juntamos todo en un montón, obtendrás «a veces 400, a veces 415, y nadie sabe por qué». Una API madura debe ser predecible: una misma clase de error debe dar el mismo estado y el mismo formato ProblemDetail.

El caso negativo más obligatorio de multipart es la ausencia de una parte obligatoria. Por ejemplo, el cliente envió file pero olvidó metadata. Para nosotros no es «bueno, cargamos sin descripción», porque según el contrato la parte debe estar. Y la prueba debe fijarlo.

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;

@Test
void uploadAttachment_withoutMetadata_returns400Problem() throws Exception {
    // Enviamos solo la parte file: violamos intencionadamente el contrato (falta metadata obligatorio)
    MockMultipartFile file = new MockMultipartFile(
            "file",
            "note.txt",
            "text/plain",
            "hi".getBytes(UTF_8)
    );

    mockMvc.perform(
                    multipart("/api/v1/tasks/{taskId}/attachments", "task-1")
                            .file(file)
            )
            // Esperamos un error predecible a nivel web‑layer
            .andExpect(status().isBadRequest())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
            // Verificamos el código legible por máquina (no el texto detail)
            .andExpect(jsonPath("$.code").value("INVALID_INPUT"));
}

Esta solicitud falla a nivel web‑layer: falta una parte obligatoria, por lo que no se llega a la lógica de negocio. Y este es el mismo estilo de errores que en los endpoints JSON normales: application/problem+json, code legible por máquina y ningún formato de fallo «especial para archivos».

Por lo tanto, aquí es lógico verificar code, no todo el texto de detail. code es la parte legible por máquina del contrato. El texto puede cambiar, localizarse o refinarse, y eso es normal.

Para la API de Task Tracker es conveniente mantener este canon:

si falta la parte metadata obligatoria o los metadatos JSON no se pueden convertir a DTO, esperamos 400 INVALID_INPUT;

si el tipo de archivo no es compatible con la política de la API, esperamos 415 UNSUPPORTED_FILE_TYPE;

si la subida está prohibida por una regla de dominio, por ejemplo para una tarea archivada, esperamos 409 FILE_UPLOAD_NOT_ALLOWED;

si no se encuentra la tarea padre o el propio adjunto, esperamos 404 TASK_NOT_FOUND / ATTACHMENT_NOT_FOUND.

Así, los caminos negativos para adjuntos siguen siendo predecibles: algunos errores nacen aún en el pipeline MVC, otros ya en el servicio, pero desde fuera el cliente sigue viendo el mismo formato ProblemDetail.

El significado clave de este bloque: un endpoint multipart debe ser igual de «maduro» en los errores que un endpoint JSON normal. Simplemente tiene más razones para fallar, pero el contrato de errores debe seguir siendo único y legible por máquina.

6. Endpoint de descarga: cabeceras y bytes

El endpoint de descarga es casi lo opuesto al de subida. Si la subida es una «entrada compleja», la descarga es una «salida estricta». El cliente viene por datos binarios, y si olvidas las cabeceras, el cliente puede comenzar a mostrar basura en el navegador, descargar un archivo con el nombre download sin extensión o simplemente no entender lo que le enviaste. Por lo tanto, aquí no solo hay que probar el estado.

Para GET /api/v1/tasks/{taskId}/attachments/{attachmentId}/download, el contrato básico suele incluir 200 OK, un Content-Type correcto (o application/octet-stream por defecto) y Content-Disposition para que el navegador/cliente descargue el archivo con el nombre correcto. Y, por supuesto, el cuerpo de la respuesta debe contener los bytes del archivo.

En @WebMvcTest no leemos el archivo real. Lo hacemos más simple y honesto: el servicio (mock) devuelve un conjunto de bytes y metadatos conocidos, y el controlador forma la respuesta HTTP correcta. Mini‑ejemplo:

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test; 
import org.springframework.http.HttpHeaders;

@Test
void downloadAttachment_setsHeadersAndBody() throws Exception {
    // Mockeamos el servicio: devuelve nombre de archivo, content type y bytes
    when(attachmentService.download("task-1", "att-1"))
            .thenReturn(new AttachmentContent(
                    "note.txt",
                    "text/plain",
                    "hello".getBytes(UTF_8)
            )); // DTO condicional

    mockMvc.perform(get("/api/v1/tasks/{t}/attachments/{a}/download", "task-1", "att-1"))
            // Estado: entrega exitosa del archivo
            .andExpect(status().isOk())
            // Cabecera de la que depende el comportamiento del navegador/cliente al descargar
            .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"note.txt\""))
            // Contenido: deben coincidir exactamente los bytes
            .andExpect(content().bytes("hello".getBytes(UTF_8)));
}

Sí, aquí hay un «DTO condicional» AttachmentContent — es solo una ilustración de que el servicio debe devolver al controlador tres cosas: nombre del archivo, content type y bytes. En tu proyecto real podría ser un record, un modelo de dominio o un objeto de respuesta separado, pero el controlador igualmente debe construir la respuesta HTTP a partir de eso.

Observa Content-Disposition. Es fácil equivocarse con las comillas y los espacios. Y este es justo el caso en que la prueba salva de «bueno, funciona». Porque «bueno, funciona» normalmente termina en el primer cliente que descarga un archivo con nombre note.txt"; rm -rf /… Vale, es broma. (Casi.) Pero las cabeceras realmente deben formarse con cuidado.

7. Auditoría final del conjunto web‑layer: mapa de cobertura

La auditoría final de las pruebas no es contar la cantidad de métodos con @Test. Si simplemente has acumulado 50 pruebas, la API no ha mejorado. La calidad del conjunto web‑layer se determina por si protege el comportamiento HTTP público: estados, cabeceras, forma del JSON y forma de los errores. En otras palabras, si mañana alguien «cambia ligeramente» el controlador, tus pruebas deben dar la alarma precisamente donde el contrato realmente se rompe.

Es conveniente hacer la auditoría mediante un «mapa de cobertura». Por ejemplo, en forma de tabla, donde cada zona de la API tiene al menos un happy‑path y al menos un negative‑path representativo. No es necesario probar cada rama microscópica de la lógica — eso ya es nivel de servicio. Pero el contrato web debe estar protegido.

A continuación se muestra un ejemplo de este mapa para la API de Task Tracker canónica (como orientación, no como documento burocrático):

Zona API Qué consideramos happy‑path básico Qué consideramos negative‑path básico Qué observamos en la respuesta
tasks (list/detail/create/update/delete) GET /tasks devuelve el envelope PagedResponse, POST /tasks devuelve 201 validación 400, JSON malformado 400, TASK_NOT_FOUND 404, conflicto 409 estado, Content-Type, campos JSON clave, Location para create (si existe), ProblemDetail.code
comments como subrecurso POST /tasks/{id}/comments devuelve 201 TASK_NOT_FOUND 404, validación 400 estado, forma del JSON, ProblemDetail en caso de error
attachments (upload/list/detail/download/delete) subida 201, descarga 200 con cabeceras parte faltante 400, prohibición de subida 409, tipo no soportado 415, not found 404 estado, Content-Type, Content-Disposition para descarga, ProblemDetail.code

Y ahora el significado principal de la auditoría: observa que multipart y descarga están al mismo nivel que las demás zonas. No son una «característica opcional». Son parte de tu contrato público. Si no las pruebas, dejas un gran trozo de API sin seguro.

Hay otra regla importante para un buen conjunto final: las pruebas deben ser legibles. Si tus pruebas multipart se han convertido en 60 líneas con cadenas de builders y JSON copiado y pegado, empezarás a temerles. Y cuando se teme a las pruebas, no se arreglan, se desactivan. Por lo tanto, mantén las pruebas pequeñas, basadas en escenarios y lo más «cliente‑céntricas» posible: leo la URI, veo el método, veo el estado esperado y veo los campos clave de la respuesta.

8. Errores típicos al probar endpoints multipart y de archivos

En el final, fijemos los rastrillos en los que casi todos pisan, incluyendo personas que «ya lo han visto todo». Las pruebas de multipart y descarga parecen simples, pero precisamente en ellas habitan los bugs más molestos de nivel «¿por qué da 400 si lo envié todo?».

Error nº1: confundir los nombres de las partes y probar «el contrato equivocado».
La solicitud multipart se sostiene en los nombres de las partes. Si el controlador espera @RequestPart("metadata") y tú en la prueba enviaste "meta", Spring no «adivinará» lo que quisiste decir. Obtendrás 400. No es un capricho, es el contrato. Por eso, en las pruebas los nombres de las partes deben ser tan visibles como la URI y el método.

Error nº2: enviar metadatos JSON sin Content-Type: application/json.
Si la parte metadata tiene text/plain, Jackson no está obligado a intentar leerlo como JSON. Como resultado, en lugar de un bonito AttachmentUploadMetadataRequest obtienes un error de conversión o un escenario de parte faltante. En la prueba siempre asigna explícitamente application/json para la parte JSON, de lo contrario no estás probando tu API, sino el azar.

Error nº3: intentar verificar el sistema de archivos real mediante @WebMvcTest.
@WebMvcTest es una prueba del contrato web, no una prueba de integración del almacenamiento. En cuanto empiezas a crear archivos reales, introduces inestabilidad del entorno y efectos secundarios. El sistema de archivos (si realmente lo necesitas) se prueba en otro nivel. Aquí el servicio está mockeado — y eso es normal, incluso si el alma pide «autenticidad».

Error nº4: probar la descarga solo por el estado e ignorar las cabeceras.
Un endpoint de descarga sin Content-Disposition es como «una pizza sin caja»: formalmente la comida está, pero luego es incómodo y sucio. El comportamiento del cliente depende de las cabeceras, por lo que la prueba debe verificar Content-Type y Content-Disposition. De lo contrario, puedes «romper» la descarga fácilmente sin darte cuenta.

Error nº5: en las pruebas de camino negativo, fijarse en el texto de detail en lugar de en code y el estado.
El texto de los errores se puede mejorar y cambiar, eso es normal. Si tus pruebas verifican «exactamente esta frase», te estás cerrando la posibilidad de mejorar los mensajes. Es mucho más estable verificar status, contentTypeCompatibleWith(application/problem+json) y $.code, y para validación también la forma de fieldErrors.

1
Tarea
Spring REST & MVC, nivel 30, lección 4
Bloqueada
Multipart upload mediante `MockMultipartFile`
Multipart upload mediante `MockMultipartFile`
1
Tarea
Spring REST & MVC, nivel 30, lección 4
Bloqueada
Download endpoint con cuerpo binario y cabeceras
Download endpoint con cuerpo binario y cabeceras
1
Cuestionario/control
Tests de controllers, nivel 30, lección 4
No disponible
Tests de controllers
Web-layer y MockMvc
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION