CodeGym /Cursos /Spring Test /Descarga de archivos adjuntos

Descarga de archivos adjuntos

Spring Test
Nivel 13, Lección 2
Disponible

1. Endpoint de descarga: no es solo un archivo

Cuando probamos un endpoint JSON, el cerebro se acostumbra rápidamente a un patrón simple: estado, Content-Type: application/json, algunas comprobaciones JsonPath, y la vida es hermosa. Con la descarga de archivos todo es un poco más engañoso: la respuesta es binaria, el significado está «oculto» en las cabeceras, y el error debe seguir llegando en su estable ApiProblem, y no en forma de una página HTML aleatoria. Y aquí es donde los principiantes suelen caer en la trampa clásica: parece que «funciona en el navegador», pero el contrato en realidad es inestable.

Si lo simplificamos, el endpoint de descarga en ContentHub es el momento en que HTTP comienza a comportarse como un mensajero: no «cuenta» una historia, sino que lleva una caja. Y en la caja hay pegatinas (headers) sin las cuales el cliente o bien abrirá lo que no debe, o llamará al archivo «download», o incluso pensará que es texto y mostrará caracteres extraños. Por lo tanto, la prueba del controlador aquí debe ser un poco más «aterrizada»: menos filosofía sobre el dominio, más comprobación de bytes y cabeceras concretos.

En nuestro proyecto ContentHub, esto se refiere al endpoint: GET /api/editor/articles/{id}/attachments/{attachmentId}

Y vamos a probar precisamente la representación HTTP: cómo, a partir de la URI y las variables de ruta, se obtiene la respuesta correcta con el estado, Content-Disposition, Content-Type y el cuerpo.

2. Contrato de descarga: cabeceras y bytes

Para que la prueba no se convierta en un conjunto aleatorio de expectativas, es útil primero definir qué es realmente el contrato de descarga. Aquí el contrato no solo vive en la URI y el estado, sino también en cómo se le explica al cliente «qué son estos datos» y «cómo llamarlos». Y sí, las cabeceras en este punto son más importantes que su fe en la bondad y la justicia.

A continuación se muestra un «mapa» compacto de lo que normalmente tiene sentido fijar en una prueba MVC para un endpoint de descarga:

Parte de la respuesta Qué significa para el cliente Cómo solemos comprobarlo en MockMvc Por qué es importante
200 OK la descarga está permitida y el recurso se ha encontrado status()(.isOk() sin esto, ni siquiera estamos en la rama correcta del escenario
Content-Disposition «descargar como archivo» + «aquí está el nombre» header(.string(...) sin ella, el navegador/cliente podría abrirlo inline o nombrar el archivo de forma extraña
Content-Type qué tipo de datos contiene content()(.contentType(...) es importante que el cliente sepa si es png, pdf o zip
(opcional) Content-Length tamaño del cuerpo header(.longValue(...) útil si realmente se establece la longitud (no siempre es obligatorio)
cuerpo de la respuesta (bytes) el archivo en sí content()(.bytes(...) de lo contrario, «hemos comprobado las pegatinas de la caja, pero no la caja»

Es importante señalar un matiz: Content-Disposition y Content-Type son partes diferentes del contrato. La primera dice «cómo entregar» (attachment/inline, nombre), la segunda dice «qué hay dentro». Un error muy común es pensar que Content-Type «influye de alguna manera en el nombre del archivo». No influye. El nombre lo define Content-Disposition.

En modo educativo, podemos comprobar el cuerpo binario con exactitud porque utilizamos un array de bytes pequeño y completamente determinista. En un producto real, si los archivos son grandes, la comparación exacta de cada byte puede resultar costosa y sin sentido a este nivel, pero para el curso es un ejercicio correcto: verá que la prueba es capaz de fijar el contenido real de la respuesta, y no una «sensación de que todo está bien».

3. Código de producción: construcción de la respuesta de archivo

Antes de escribir la prueba, es útil «aterrizar» un momento y observar cómo se ve normalmente este endpoint dentro del controlador. No se trata de estudiar el sistema de archivos u optimizar flujos, sino de entender de dónde vienen las cabeceras y por qué la prueba debe comprobarlas. Y además: es el momento en que muchos se familiarizan con que la respuesta HTTP no es solo @ResponseBody.

Un modelo (simplificado) que es útil tener en mente para las pruebas MVC podría verse así: el controlador llama a un servicio, el servicio devuelve un record (o clase) de la forma:

// DTO para el resultado de la descarga: metadatos + bytes.
// Importante: el nombre del archivo se toma de aquí, no se «adivina» por la URL.
public record DownloadedAttachment(
        String originalFilename,
        String contentType,
        byte[] bytes
) {
}

Y el propio controlador (un fragmento de la lógica de construcción de cabeceras) suele tener este aspecto:

import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;

// Construimos las cabeceras explícitamente: así controlamos cómo el cliente guardará el archivo.
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
        // Importante: este es el nombre «original» que verá el cliente al descargar.
        .filename(originalFilename)
        .build());

Y junto a esto, el establecimiento del tipo:

import org.springframework.http.MediaType;

// Content-Type es una parte separada del contrato: describe el tipo de contenido, no el nombre del archivo.
headers.setContentType(MediaType.parseMediaType(contentType));

El significado aquí es simple: el nombre del archivo no proviene de la URL ni de storedFilename, sino de los metadatos (normalmente originalFilename), y es el controlador quien decide si es attachment o inline. En la prueba, comprobaremos el resultado de esta construcción, sin meternos en los detalles de dónde obtuvo el servicio los bytes (disco, nube, portal extraterrestre).

4. Happy path en @WebMvcTest

En las pruebas de controladores con JSON, estamos acostumbrados a que la «comprobación principal» es jsonPath. Para la descarga, la comprobación principal suele comenzar con las cabeceras: estas determinan cómo el cliente tratará el cuerpo. Y este es precisamente el caso en que la prueba puede ser pequeña pero muy útil: tres o cuatro expectativas proporcionan una protección real contra regresiones en el contrato.

Imaginemos que tenemos un EditorArticleController, y su dependencia (por ejemplo, EditorAttachmentService) la sustituimos por un @MockitoBean. En el happy path, queremos fijar: estado 200, Content-Disposition correcto con el nombre cover.png, y Content-Type correcto, por ejemplo image/png.

Primero, definamos el comportamiento del mock. Observe: devolvemos un array de bytes diminuto. Esto es importante: la prueba debe ser rápida y legible.

import static org.mockito.BDDMockito.given;

// Simulamos el servicio: en la entrada (articleId, attachmentId), en la salida — metadatos + bytes.
given(attachmentService.download(10L, 3L))
        .willReturn(new DownloadedAttachment(
                "cover.png",     // nombre que irá en Content-Disposition
                "image/png",     // valor para Content-Type
                new byte[] {1, 2, 3} // payload pequeño y determinista para la prueba
        ));

Ahora hacemos la petición y comprobamos las partes clave de la respuesta. Aquí uso intencionadamente HttpHeaders.CONTENT_DISPOSITION para no proliferar literales de cadena que luego «difieren un poco y por alguna razón no funcionan».

import org.springframework.http.HttpHeaders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// Comprobamos precisamente el contrato HTTP: estado + cabeceras clave.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
        .andExpect(status().isOk())
        // Content-Disposition define: «descargar como archivo» y «cómo llamar al archivo».
        .andExpect(header().string(
                HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"cover.png\""
        ))
        // Content-Type define el tipo de contenido (png/pdf/zip, etc.).
        .andExpect(content().contentType("image/png"));

Un pequeño comentario de la vida real: la cadena attachment; filename="cover.png" parece trivial, pero es precisamente esa pequeñez la que se rompe con más frecuencia. Alguien cambia la construcción de la cabecera, alguien olvida las comillas, alguien decide que el nombre debe ser storedFilename (y de repente se convierte en algo como b6f2c9a1-...dat), y ahí el cliente ya tiene un problema. La prueba lo detecta al instante.

Si desea hacer la expectativa un poco menos frágil (por ejemplo, para sobrevivir a la futura aparición de filename*=), puede comprobar no la igualdad completa de la cadena, sino un fragmento importante. Para ello es adecuado el matcher containsString, pero entonces necesitará Hamcrest. Esto es aceptable, simplemente hágalo de forma consciente: no volvemos a «Hamcrest como estilo principal», solo lo usamos allí donde Spring espera un matcher.

import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;

// Comprobamos solo el fragmento importante: el nombre del archivo en Content-Disposition.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
        .andExpect(header().string(
                HttpHeaders.CONTENT_DISPOSITION,
                containsString("filename=\"cover.png\"")
        ));

5. Cuerpo binario: bytes, no cadenas

Después de las cabeceras, llega el momento en que a los principiantes les encanta pisar el rastrillo: «vamos a comparar el body como una cadena». Esto casi siempre es una mala idea, porque un archivo no es texto. Incluso si tiene un PDF, PNG o ZIP, Java intentará honradamente interpretar los bytes como caracteres, y obtendrá una prueba que o bien falla, o está verde pero no demuestra nada útil.

Tenemos dos enfoques normales. El primero es usar content().bytes(...). Es conciso y se lee bien cuando el array es pequeño.

// Esperamos los bytes exactos del archivo (en el ejemplo educativo son pequeños y deterministas).
byte[] expected = new byte[] {1, 2, 3};

mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
        .andExpect(content().bytes(expected));

El segundo es obtener los bytes de la respuesta y comprobarlos con AssertJ. Esta opción a veces proporciona mensajes de error más claros y permite hacer comprobaciones un poco más «humanas».

import static org.assertj.core.api.Assertions.assertThat;

// Extraemos el cuerpo de la respuesta como byte[] (¡no como cadena!) para no «estropear» los datos binarios.
byte[] body = mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
        .andReturn()
        .getResponse()
        .getContentAsByteArray();

// AssertJ da un buen mensaje si los bytes difieren.
assertThat(body).containsExactly(1, 2, 3);

Aquí hay una disciplina importante: no convierta la prueba en un «generador de grandes binarios». Si intenta meter 5 megabytes «como en el mundo real» en la prueba, obtendrá una prueba lenta y ruidosa, difícil de leer. Un slice MVC se trata de retroalimentación rápida. Por lo tanto, usamos un payload pequeño y demostramos que el mecanismo funciona.

Si su código de producción establece Content-Length, se puede fijar por separado. Pero hágalo solo si realmente considera que es parte del contrato. A veces Content-Length no se establece explícitamente (o depende del tipo de Resource), y entonces es mejor no vincular la prueba a algo que no ha prometido.

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import org.springframework.http.HttpHeaders;

// Content-Length es una parte opcional del contrato: fíjelo solo si realmente se compromete a establecerlo.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
        .andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, 3L));

6. Escenarios negativos: 404 y 400

Los endpoints de archivos no solo fallan en el happy path. Es más, son los escenarios negativos los que más a menudo dan al usuario errores «extraños»: en lugar de su ApiProblem, de repente se devuelve algo incorrecto, y el cliente no sabe cómo manejarlo. Por lo tanto, es obligatorio fijar al menos una o dos ramas de fallo: «archivo adjunto no encontrado» y «parámetro incorrecto». Esto no es un intento de cubrir toda la matriz del mundo, es un seguro para el contrato.

Empecemos con «no encontrado». Supongamos que el servicio lanza una AttachmentNotFoundException o una más general ArticleNotFoundException (da igual, lo importante es la idea), y nuestro @ControllerAdvice lo traduce a un ApiProblem con estado 404. En la prueba, simulamos la excepción y comprobamos que la respuesta es efectivamente 404 y contiene los campos esperados del contrato.

import org.springframework.http.MediaType;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// El servicio informa: archivo adjunto no encontrado (el controlador/advice debe convertirlo en ApiProblem 404).
given(attachmentService.download(10L, 99L))
        .willThrow(new AttachmentNotFoundException(99L));

mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 99L)
                // Solicitamos explícitamente JSON en el error, para no obtener un «HTML aleatorio».
                .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isNotFound())
        // Comprobamos los campos de ApiProblem, no solo el estado.
        .andExpect(jsonPath("$.status").value(404))
        .andExpect(jsonPath("$.title").exists());

Observe el accept(MediaType.APPLICATION_JSON). Es un detalle pequeño pero importante: decimos explícitamente «en caso de error, quiero JSON». Y estamos probando precisamente su contrato API, no un HTML aleatorio del manejador de errores por defecto (que a veces aparece si en algún lugar se cuela text/html).

Ahora, «parámetro incorrecto». La variable de ruta es de tipo long, y si el cliente envía oops, Spring no podrá convertirlo en un número, normalmente se obtendrá un 400 Bad Request (type mismatch). Esto no es un error de negocio, es un error de formato de la solicitud, y también debe convertirse a su ApiProblem.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// Enviamos una cadena en lugar de un número: esperamos 400 y un contrato JSON de error estable.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, "oops")
                .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.status").value(400))
        .andExpect(jsonPath("$.errorCode").exists());

Sí, parece un poco extraño: probamos «oops» como attachmentId. Pero estos son precisamente los casos que llegan a una API real, cuando el cliente se equivoca, cuando el desarrollador frontend accidentalmente envía una cadena en lugar de un número, o cuando alguien llama al endpoint manualmente con curl. La prueba MVC debe demostrar que el contrato de error es estable.

Límite de la prueba del controlador en la descarga

Después de varios ejemplos, es fácil dejarse llevar y empezar a «demostrarlo todo» dentro de la prueba del controlador: cómo el servicio encontró el archivo, cómo está organizada la ruta en el disco, cómo el storage lee los bytes, cómo funciona en Windows, Linux y en Mercurio retrógrado. Pero un slice MVC no se trata de eso. Y si intenta hacer que la prueba del controlador verifique el sistema de archivos, obtendrá un conjunto de pruebas costoso, frágil y desagradable.

El límite aquí es exactamente el mismo que hemos comentado antes: la prueba del controlador comienza con la entrada HTTP y termina con la salida HTTP. Comprueba el mapeo de peticiones, la conversión de variables de ruta, cómo el resultado del servicio se transforma en estado/cabeceras/cuerpo, y cómo la excepción se traduce a ApiProblem. Todo lo que esté por debajo (lectura real del archivo, permisos de directorio, carreras por el archivo en peticiones paralelas, limitaciones del sistema de archivos) debe comprobarse en otros niveles y en otros días del curso.

Incluso las comprobaciones de interacción con los mocks deben usarse aquí con mucho cuidado. Una comprobación del tipo «el controlador realmente llamó al servicio con los ids correctos» puede ser útil como «seguro contra argumentos intercambiados». Pero en cuanto empiece a comprobar el orden de las llamadas, métodos adicionales y «aquí debería haber hecho exactamente tres invocaciones», convierte la prueba en una actuación. Una actuación verde que no necesariamente está relacionada con la realidad.

Si desea comprobar mínimamente el límite de delegación, podría verse así:

import static org.mockito.Mockito.verify;

// No nos importan los detalles: fijamos solo que el controlador no intercambió los argumentos.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L));

verify(attachmentService).download(10L, 3L);

El sentido es precisamente «mínimamente»: no comprobamos los internos, solo fijamos que el controlador no «perdió» los parámetros y realmente fue a la dependencia.

7. Errores típicos al descargar archivos adjuntos

Error n.º 1: comprobar solo el 200 OK y no mirar el Content-Disposition.
Una prueba así a menudo parece «bueno, se descargó», hasta que llega un cliente real (navegador, aplicación móvil, frontend) y dice: «¿por qué el archivo se llama attachments y se abre en la ventana en lugar de descargarse?». Content-Disposition no es cosmético, es parte del contrato, y es mejor detectar una regresión con una prueba que con un informe de error.

Error n.º 2: confundir el nombre del archivo y el Content-Type.
Los principiantes a veces esperan que si se establece Content-Type: image/png, el navegador «entienda por sí mismo» que el nombre debe ser cover.png. No lo entenderá. Sin Content-Disposition, el nombre puede ser cualquiera (o incluso no existir), y el comportamiento del cliente dependerá de su lógica. En la prueba, vale la pena comprobar estas dos cosas por separado, como dos partes independientes de la respuesta.

Error n.º 3: comparar la respuesta binaria como una cadena.
Si hace content().string(...) o extrae getContentAsString() y lo compara con algo, no está probando el archivo, sino una interpretación aleatoria de los bytes como texto. Para una respuesta binaria, use bytes y trabaje con el array byte[]. Es trivial, pero es una de las fuentes más comunes de pruebas sin sentido.

Error n.º 4: arrastrar el sistema de archivos real a un slice MVC.
Surge la tentación: «vamos a poner el archivo en src/test/resources, y que el controlador lo lea realmente a través del storage». En ese momento, el slice MVC deja de ser rápido y aislado: empieza a probar el storage, la configuración de rutas, los permisos y otros detalles de E/S. A nivel del controlador, nos basta con que la dependencia devuelva los bytes y el controlador construya la respuesta correctamente.

Error n.º 5: no probar los escenarios negativos y obtener un formato de error «aleatorio».
Los endpoints de archivos a menudo devuelven errores de forma diferente a los endpoints JSON, simplemente porque en algún sitio no se conectó @ControllerAdvice en el slice o porque el cliente envió un Accept diferente. Una prueba para 404 y una prueba para 400 suelen aumentar drásticamente la confianza: usted fija de antemano que incluso en el endpoint de descarga, los errores tienen el aspecto de su ApiProblem, y no de «algo del servidor».

1
Tarea
Spring Test, nivel 13, lección 2
Bloqueada
Descarga de portada por contrato HTTP
Descarga de portada por contrato HTTP
1
Tarea
Spring Test, nivel 13, lección 2
Bloqueada
Anexo faltante
Anexo faltante
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION