feat: ThemeControllerTest 코틀린 전환

- 전체적으로는 MockMvc를 이용하였으나 mocking이 애매한 API는 MostReservedThemeAPITest에서 실제 동작을 검증함.
This commit is contained in:
이상진 2025-07-17 18:20:55 +09:00
parent e943fbe0df
commit 9d145f7f66
3 changed files with 438 additions and 159 deletions

View File

@ -0,0 +1,44 @@
package roomescape.theme.util
import jakarta.persistence.EntityManager
import roomescape.member.infrastructure.persistence.Member
import roomescape.reservation.domain.ReservationStatus
import roomescape.reservation.domain.ReservationTime
import roomescape.theme.infrastructure.persistence.Theme
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.ReservationTimeFixture
import roomescape.util.ThemeFixture
import java.time.LocalDate
import java.time.LocalTime
object TestThemeCreateUtil {
fun createThemeWithReservations(
entityManager: EntityManager,
name: String,
reservedCount: Int,
date: LocalDate,
): Long {
val theme: Theme = ThemeFixture.create(name = name).also { entityManager.persist(it) }
val member: Member = MemberFixture.create().also { entityManager.persist(it) }
for (i in 1..reservedCount) {
val time: ReservationTime = ReservationTimeFixture.create(
startAt = LocalTime.now().plusMinutes(i.toLong())
).also { entityManager.persist(it) }
ReservationFixture.create(
date = date,
theme = theme,
member = member,
reservationTime = time,
status = ReservationStatus.CONFIRMED
).also { entityManager.persist(it) }
}
entityManager.flush()
entityManager.clear()
return theme.id
}
}

View File

@ -0,0 +1,112 @@
package roomescape.theme.web
import io.kotest.core.spec.style.FunSpec
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import jakarta.persistence.EntityManager
import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.transaction.support.TransactionTemplate
import roomescape.theme.business.ThemeService
import roomescape.theme.util.TestThemeCreateUtil
import java.time.LocalDate
import kotlin.random.Random
/**
* GET /themes/most-reserved-last-week API 테스트
* 상세 테스트는 Repository 테스트에서 진행
* 날짜 범위, 예약 수만 검증
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MostReservedThemeAPITest(
@LocalServerPort val port: Int,
val themeService: ThemeService,
val transactionTemplate: TransactionTemplate,
val entityManager: EntityManager,
) : FunSpec() {
init {
beforeSpec {
transactionTemplate.executeWithoutResult {
// 지난 7일간 예약된 테마 10개 생성
for (i in 1..10) {
TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager,
name = "테마$i",
reservedCount = 1,
date = LocalDate.now().minusDays(Random.nextLong(1, 7))
)
}
// 8일 전 예약된 테마 1개 생성
TestThemeCreateUtil.createThemeWithReservations(
entityManager = entityManager,
name = "테마11",
reservedCount = 1,
date = LocalDate.now().minusDays(8)
)
}
}
context("가장 많이 예약된 테마를 조회할 때,") {
val endpoint = "/themes/most-reserved-last-week"
test("갯수를 입력하지 않으면 10개를 반환한다.") {
Given {
port(port)
} When {
get(endpoint)
} Then {
log().all()
statusCode(200)
body("data.themes.size()", equalTo(10))
}
}
test("입력된 갯수가 조회된 갯수보다 크면 조회된 갯수만큼 반환한다.") {
val count = 15
Given {
port(port)
} When {
param("count", count)
get("/themes/most-reserved-last-week")
} Then {
log().all()
statusCode(200)
body("data.themes.size()", equalTo(10))
}
}
test("입력된 갯수가 조회된 갯수보다 작으면 입력된 갯수만큼 반환한다.") {
val count = 5
Given {
port(port)
} When {
param("count", count)
get("/themes/most-reserved-last-week")
} Then {
log().all()
statusCode(200)
body("data.themes.size()", equalTo(count))
}
}
test("7일 전 부터 1일 전 까지 예약된 테마를 대상으로 한다.") {
// 현재 저장된 데이터는 지난 7일간 예약된 테마 10개와 8일 전 예약된 테마 1개
// 8일 전 예약된 테마는 제외되어야 하므로, 10개가 조회되어야 한다.
val count = 11
Given {
port(port)
} When {
param("count", count)
get("/themes/most-reserved-last-week")
} Then {
log().all()
statusCode(200)
body("data.themes.size()", equalTo(10))
}
}
}
}
}

View File

@ -1,184 +1,307 @@
package roomescape.theme.web;
package roomescape.theme.web
import static org.hamcrest.Matchers.*;
import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean
import io.kotest.assertions.assertSoftly
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.just
import io.mockk.runs
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import roomescape.theme.business.ThemeService
import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture
import java.util.Map;
import java.util.stream.Stream;
@WebMvcTest(ThemeController::class)
class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.Sql.ExecutionPhase;
@SpykBean
private lateinit var themeService: ThemeService
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
import roomescape.member.infrastructure.persistence.Member;
import roomescape.member.infrastructure.persistence.MemberRepository;
import roomescape.member.infrastructure.persistence.Role;
@MockkBean
private lateinit var themeRepository: ThemeRepository
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/truncate.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
class ThemeControllerTest {
init {
Given("모든 테마를 조회할 때") {
val endpoint = "/themes"
@LocalServerPort
private int port;
When("로그인 상태가 아니라면") {
doNotLogin()
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("모든 테마 정보를 조회한다.")
void readThemes() {
String email = "admin@test.com";
String password = "12341234";
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password);
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.when().get("/themes")
.then().log().all()
.statusCode(200)
.body("data.themes.size()", is(0));
Then("로그인 페이지로 이동한다.") {
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header {
string("Location", "/login")
}
}
}
}
@Test
@DisplayName("테마를 추가한다.")
void createThemes() {
String email = "admin@test.com";
String password = "12341234";
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password);
When("로그인 상태라면") {
loginAsUser()
Map<String, String> params = Map.of(
"name", "테마명",
"description", "설명",
"thumbnail", "http://testsfasdgasd.com"
);
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(params)
.when().post("/themes")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/themes/1");
}
@Test
@DisplayName("테마를 삭제한다.")
void deleteThemes() {
String email = "admin@test.com";
String password = "12341234";
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password);
Map<String, String> params = Map.of(
"name", "테마명",
"description", "설명",
"thumbnail", "http://testsfasdgasd.com"
);
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(params)
.when().post("/themes")
.then().log().all()
.statusCode(201)
.body("data.id", is(1))
.header("Location", "/themes/1");
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.when().delete("/themes/1")
.then().log().all()
.statusCode(204);
}
/*
* reservationData DataSet ThemeID reservation 개수
* 5,4,2,5,2,3,1,1,1,1,1
* 예약 내림차순 + ThemeId 오름차순 정렬 순서
* 1, 4, 2, 6, 3, 5, 7, 8, 9, 10
*/
@Test
@DisplayName("예약 수 상위 10개 테마를 조회했을 때 내림차순으로 정렬된다. 만약 예약 수가 같다면, id 순으로 오름차순 정렬된다.")
@Sql(scripts = {"/truncate.sql", "/reservationData.sql"}, executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
void readTop10ThemesDescOrder() {
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.when().get("/themes/most-reserved-last-week?count=10")
.then().log().all()
.statusCode(200)
.body("data.themes.size()", is(10))
.body("data.themes.id", contains(1, 4, 2, 6, 3, 5, 7, 8, 9, 10));
}
@ParameterizedTest
@MethodSource("requestValidateSource")
@DisplayName("테마 생성 시, 요청 값에 공백 또는 null이 포함되어 있으면 400 에러를 발생한다.")
void validateBlankRequest(Map<String, String> invalidRequestBody) {
String email = "admin@test.com";
String password = "12341234";
String adminAccessTokenCookie = getAdminAccessTokenCookieByLogin(email, password);
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.header(new Header("Cookie", adminAccessTokenCookie))
.port(port)
.body(invalidRequestBody)
.when().post("/themes")
.then().log().all()
.statusCode(400);
}
static Stream<Map<String, String>> requestValidateSource() {
return Stream.of(
Map.of(
"name", "테마명",
"thumbnail", "http://testsfasdgasd.com"
),
Map.of(
"name", "",
"description", "설명",
"thumbnail", "http://testsfasdgasd.com"
),
Map.of(
"name", " ",
"description", "설명",
"thumbnail", "http://testsfasdgasd.com"
Then("조회에 성공한다.") {
every {
themeRepository.findAll()
} returns listOf(
ThemeFixture.create(id = 1, name = "theme1"),
ThemeFixture.create(id = 2, name = "theme2"),
ThemeFixture.create(id = 3, name = "theme3")
)
);
val response: ThemesResponse = runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isOk() }
content {
contentType(MediaType.APPLICATION_JSON)
}
}.andReturn().readValue(ThemesResponse::class.java)
assertSoftly(response.themes) {
it.size shouldBe 3
it.map { m -> m.name } shouldContainAll listOf("theme1", "theme2", "theme3")
}
}
}
}
private String getAdminAccessTokenCookieByLogin(final String email, final String password) {
memberRepository.save(new Member(null, "이름", email, password, Role.ADMIN));
Given("테마를 추가할 때") {
val endpoint = "/themes"
val request = ThemeRequest(
name = "theme1",
description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg"
)
Map<String, String> loginParams = Map.of(
"email", email,
"password", password
);
When("로그인 상태가 아니라면") {
doNotLogin()
Then("로그인 페이지로 이동한다.") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { is3xxRedirection() }
header {
string("Location", "/login")
}
}
}
}
String accessToken = RestAssured.given().log().all()
.contentType(ContentType.JSON)
.port(port)
.body(loginParams)
.when().post("/login")
.then().log().all().extract().cookie("accessToken");
When("관리자가 아닌 회원은") {
loginAsUser()
Then("로그인 페이지로 이동한다.") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { is3xxRedirection() }
jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") }
}
}
}
return "accessToken=" + accessToken;
When("동일한 이름의 테마가 있으면") {
loginAsAdmin()
Then("409 에러를 응답한다.") {
every {
themeRepository.existsByName(request.name)
} returns true
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isConflict() }
jsonPath("$.errorType") { value("THEME_DUPLICATED") }
}
}
}
When("값이 잘못 입력되면 400 에러를 응답한다") {
beforeTest {
loginAsAdmin()
}
val request = ThemeRequest(
name = "theme1",
description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg"
)
fun runTest(request: ThemeRequest) {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isBadRequest() }
}
}
Then("이름이 공백인 경우") {
val invalidRequest = request.copy(name = " ")
runTest(invalidRequest)
}
Then("이름이 20글자를 초과하는 경우") {
val invalidRequest = request.copy(name = "a".repeat(21))
runTest(invalidRequest)
}
Then("설명이 공백인 경우") {
val invalidRequest = request.copy(description = " ")
runTest(invalidRequest)
}
Then("설명이 100글자를 초과하는 경우") {
val invalidRequest = request.copy(description = "a".repeat(101))
runTest(invalidRequest)
}
Then("썸네일이 공백인 경우") {
val invalidRequest = request.copy(thumbnail = " ")
runTest(invalidRequest)
}
Then("썸네일이 URL 형식이 아닌 경우") {
val invalidRequest = request.copy(thumbnail = "invalid-url")
runTest(invalidRequest)
}
}
When("저장에 성공하면") {
loginAsAdmin()
val theme = ThemeFixture.create(
id = 1,
name = request.name,
description = request.description,
thumbnail = request.thumbnail
)
every {
themeRepository.existsByName(request.name)
} returns false
every {
themeRepository.save(any())
} returns theme
Then("201 응답을 받는다.") {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = request,
log = true
) {
status { isCreated() }
header {
string("Location", "/themes/${theme.id}")
}
jsonPath("$.data.id") { value(theme.id) }
jsonPath("$.data.name") { value(theme.name) }
jsonPath("$.data.description") { value(theme.description) }
jsonPath("$.data.thumbnail") { value(theme.thumbnail) }
}
}
}
}
Given("테마를 제거할 때") {
val themeId = 1L
val endpoint = "/themes/$themeId"
When("로그인 상태가 아니라면") {
doNotLogin()
Then("로그인 페이지로 이동한다.") {
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
header {
string("Location", "/login")
}
}
}
}
When("관리자가 아닌 회원은") {
loginAsUser()
Then("로그인 페이지로 이동한다.") {
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { is3xxRedirection() }
jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") }
}
}
}
When("입력된 ID에 해당하는 테마가 없으면") {
loginAsAdmin()
Then("409 에러를 응답한다.") {
every {
themeRepository.isReservedTheme(themeId)
} returns true
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isConflict() }
jsonPath("$.errorType") { value("THEME_IS_USED_CONFLICT") }
}
}
}
When("정상적으로 제거되면") {
loginAsAdmin()
every {
themeRepository.isReservedTheme(themeId)
} returns false
every {
themeRepository.deleteById(themeId)
} just runs
Then("204 응답을 받는다.") {
runDeleteTest(
mockMvc = mockMvc,
endpoint = endpoint,
log = true
) {
status { isNoContent() }
}
}
}
}
}
}