[Spring/Spring Boot] 파일 다운로드와 multipart/form-data 업로드 컨트롤러 메소드 테스트 코드를 작성해보자
목차
들어가기 전에
최근에 파일을 다운로드 받는 컨트롤러 메소드와 파일을 업로드해서 처리하는 컨트롤러 메소드를 작성하면서 해당 컨트롤러 메소드가 제대로 돌아가는지 확인하려고 테스트 코드를 작성할 일이 있었습니다.
평소에 작성하는 테스트 코드는 평범한 요청과 응답을 가지는 로직에 대한 테스트 코드 뿐이었다 보니 파일 업로드/다운로드와 관련된 테스트 코드를 작성한다는게 상당히 생소했는데 나중에 까먹지 않으려고 기록해두려고 합니다.
본문상의 코드는 모두 코틀린으로 작성되어 있긴 하지만 가장 중요한 파일 업로드나 다운로드 검증 부분은 자바도 별반 다르지 않습니다.
파일 업로드 테스트
일반적으로 파일을 업로드하는 로직은 MultipartFile을 사용하는 경우가 많습니다. 이를 테스트하기 위해 스프링에서는 MultipartFile을 구현한 MockMultipartFile이라는 클래스가 있습니다.
파일 업로드 테스트를 하려면 이 MockMultipartFile 객체를 사용하면 됩니다.
예를 들어 아래와 같이 한줄마다 JSON 형태로 사용자 정보가 기록된 파일이 업로드되면 파일을 읽어서 사용자 정보를 저장하는 API와 서비스 메소드가 있다고 가정해보겠습니다.
@RestController
class PersonController(
private val personService: PersonService
) {
@PostMapping(value = ["/person-upload"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun personUpload(@RequestParam(required = false) dataFile: MultipartFile?): ResponseEntity<*> {
if (dataFile == null) {
return ResponseEntity.badRequest().body(mapOf("message" to "파일이 누락되었습니다."))
}
val count = personService.savePersonFromFile(dataFile)
return ResponseEntity.ok(mapOf("message" to "success", "count" to count))
}
}
@Service
class PersonService(
private val personRepository: PersonRepository,
private val objectMapper: ObjectMapper = jacksonObjectMapper()
) {
fun savePersonFromFile(dataFile: MultipartFile): Int {
val personList = mutableListOf<Person>()
val lines = dataFile.inputStream.bufferedReader().readLines()
for (line in lines) {
personList.add(objectMapper.readValue(line, Person::class.java))
}
val saveAll = personRepository.saveAll(personList)
return saveAll.size
}
}
이 로직을 테스트하기 위한 테스트 코드는 MockMultipartFile 객체를 사용하여 작성할 수 있습니다.
@Test
fun `업로드된 파일에서 데이터를 읽어 저장(컨트롤러)`() {
// given
val fileContent = """
{"name": "colabear754", "phoneNumber": "010-1234-5678"}
{"name": "ciderbear754", "phoneNumber": "010-1234-5678"}
{"name": "fantabear754", "phoneNumber": "010-1234-5678"}
""".trimIndent()
val multipartFile = MockMultipartFile("dataFile", "test.txt", MediaType.TEXT_PLAIN_VALUE, fileContent.toByteArray())
// when
val actionsDsl = mockMvc.multipart("/person-upload") { file(multipartFile) }
// then
actionsDsl.andExpect {
status { isOk() }
jsonPath("$.message") { value("success") }
jsonPath("$.count") { value(3) }
}
personRepository.findAll().let {
it.size shouldBe 3
it[0].name shouldBe "colabear754"
it[0].phoneNumber shouldBe "010-1234-5678"
it[1].name shouldBe "ciderbear754"
it[1].phoneNumber shouldBe "010-1234-5678"
it[2].name shouldBe "fantabear754"
it[2].phoneNumber shouldBe "010-1234-5678"
}
}
여기서 주목할 부분은 MockMultipartFile 객체를 생성하는 생성자와 MockMvc에서 API를 호출하는 부분입니다.
MockMultipartFile의 생성자는 4개의 파라미터를 받는데 각 파라미터는 아래와 같습니다.
name
: API에서 파일을 구분하기 위한 이름입니다. 컨트롤러 메소드의 MultipartFile 파라미터 이름이나@RequestParam
의name
으로 지정한 이름과 동일하게 설정하면 됩니다.originalFileName
: 실제로 클라이언트에서 업로드하는 파일의 이름에 해당하는 파라미터입니다. 테스트하려는 메소드의 내부 로직에서 해당 값을 사용하지 않는다면 아무 값이나 넣어도 됩니다.contentType
: 업로드하는 파일의 타입입니다. 일반 텍스트 파일인지, XML 파일인지, 이미지 파일인지 등을 지정할 수 있으며, 파일의 타입을 따지지 않는 경우에는null
으로 지정하면 됩니다.content
: 파일의 내용을Byte
배열이나InputStream
으로 전달합니다.
그리고 MockMvc에서 API를 호출할 때는 post
가 아닌 multipart
를 사용해야 합니다.
컨트롤러 테스트를 할 때는 위와 같이 MockMvc로 API만 호출하면 되고, 서비스 메소드의 단위 테스트를 할 때는 아래와 같이 그냥 서비스 메소드를 호출할 때 인자로 MockMultipartFile 객체를 전달해주기만 하면 됩니다.
@Test
fun `업로드된 파일에서 데이터를 읽어 저장(서비스)`() {
// given
val fileContent = """
{"name": "colabear754", "phoneNumber": "010-1234-5678"}
{"name": "ciderbear754", "phoneNumber": "010-1234-5678"}
{"name": "fantabear754", "phoneNumber": "010-1234-5678"}
""".trimIndent()
val multipartFile = MockMultipartFile("dataFile", "test.txt", MediaType.TEXT_PLAIN_VALUE, fileContent.toByteArray())
// when
personService.savePersonFromFile(multipartFile)
// then
personRepository.findAll().let {
it.size shouldBe 3
it[0].name shouldBe "colabear754"
it[0].phoneNumber shouldBe "010-1234-5678"
it[1].name shouldBe "ciderbear754"
it[1].phoneNumber shouldBe "010-1234-5678"
it[2].name shouldBe "fantabear754"
it[2].phoneNumber shouldBe "010-1234-5678"
}
}
두 경우 모두 테스트를 잘 통과하네요.
파일 다운로드 테스트
파일 다운로드 테스트는 업로드보다 훨씬 간단합니다.
이번에는 각 사용자들을 JSON으로 직렬화하여 한줄에 한명의 사용자 JSON 데이터가 담긴 파일을 다운로드 받는 API를 예시로 들어보겠습니다.
@RestController
class PersonController(
private val personService: PersonService
) {
@GetMapping("/person-download")
fun personDownload() = personService.makePeopleStream()?.let {
ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=people.txt")
.body(it)
} ?: ResponseEntity.noContent().build()
}
@Service
class PersonService(
private val personRepository: PersonRepository,
private val objectMapper: ObjectMapper = jacksonObjectMapper()
) {
fun makePeopleStream(): InputStreamResource? {
val people = personRepository.findAll()
if (people.isEmpty()) return null
val peopleString = people.joinToString("\n") { objectMapper.writeValueAsString(it) }
return InputStreamResource(peopleString.byteInputStream())
}
}
이번에는 서비스 메소드에서 사용자들의 목록을 InputStreamResource로 만들고, 컨트롤러에서는 이 InputStreamResource 길이가 0보다 크면(파일 내용이 있으면) 파일을 다운로드할 수 있도록 합니다.
이러한 파일 다운로드에 대한 테스트 코드는 MockMvc를 사용하는 다른 테스트 코드와 다르지 않습니다.
@Test
fun `DB에 저장된 데이터를 파일로 다운로드`() {
// given
personRepository.saveAll(listOf(
Person("colabear754", "010-1234-5678"),
Person("ciderbear754", "010-1234-5678"),
Person("fantabear754", "010-1234-5678")
))
// when
val actionsDsl = mockMvc.get("/person-download")
// then
actionsDsl.andExpect {
status { isOk() }
content { contentType(MediaType.APPLICATION_OCTET_STREAM) }
content { string("""
{"name":"colabear754","phoneNumber":"010-1234-5678"}
{"name":"ciderbear754","phoneNumber":"010-1234-5678"}
{"name":"fantabear754","phoneNumber":"010-1234-5678"}
""".trimIndent()) }
}
}
MockMvc로 API를 호출할 때는 일반적인 REST API를 호출할 때와 동일하게 호출을 하면 되고 응답 검증 방법도 동일합니다. 다만 응답 내용이 JSON이 아니라 텍스트 혹은 바이너리 형태이기 때문에 이에 맞게만 입력해주면 됩니다.
이번에도 테스트를 잘 통과하고 있습니다.