DevLog ๐Ÿ˜ถ

[Spring] ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ๋กœ์ง์˜ ์ผ๋ถ€๋ฅผ stubbing ํ•˜๊ธฐ ๋ณธ๋ฌธ

๊ฐœ๋ฐœ์ผ์ง€

[Spring] ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ๋กœ์ง์˜ ์ผ๋ถ€๋ฅผ stubbing ํ•˜๊ธฐ

dolmeng2 2024. 6. 16. 19:27

 

๐ŸŒฑ ๋“ค์–ด๊ฐ€๊ธฐ ์ „

ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ํ•˜๋‹ค ๋ณด๋ฉด, ์‹ค์ œ ๋นˆ ์ค‘์— ์ผ๋ถ€๋งŒ stubbing์„ ํ•˜๊ณ  ์‹ถ์€ ์ƒํ™ฉ์ด ์ƒ๊ธด๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด, ์™ธ๋ถ€ API๋ฅผ ํ†ต์‹ ํ•˜๊ฑฐ๋‚˜ ์™ธ๋ถ€ ์‹œ์Šคํ…œ์„ ํ™œ์šฉํ•˜๊ฒŒ ๋˜๋ฉด ํ…Œ์ŠคํŠธ์—์„œ ๊ทธ๋Œ€๋กœ ํ™œ์šฉํ•˜๊ธฐ ์–ด๋ ค์šด๋ฐ, ํ•ด๋‹น ๋ถ€๋ถ„์— ๋Œ€ํ•ด์„œ๋งŒ ์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ œ๊ณตํ•˜์—ฌ ์šฐ๋ฆฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ณ  ์‹ถ์„ ์ˆ˜ ์žˆ๋‹ค. ํ•˜์ง€๋งŒ, ๋ณดํ†ต Spring Context์—์„œ๋Š” ๋™์ผํ•œ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋นˆ์„ ๊ทธ๋Œ€๋กœ ๋“ฑ๋กํ•˜๊ฒŒ ๋˜๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ๋ช‡ ๊ฐ€์ง€ ํŠน๋ณ„ํ•œ ์กฐ์น˜๋ฅผ ํ•ด์ฃผ์–ด์•ผ ํ•˜๋Š”๋ฐ ํ•œ ๋ฒˆ ์‚ดํŽด๋ณด์ž.

 

 

โ˜๏ธŽ ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค

 - ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ์„ ๊ตฌ๋งคํ•˜๊ฒŒ ๋˜๋ฉด, ์™ธ๋ถ€ API๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
  - ๋งŒ์•ฝ ์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋ฉด ์™ธ๋ถ€ API๋Š” ‘SUCCESS’๋ผ๋Š” ์‘๋‹ต์„, ์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ‘FAIL’์ด๋ผ๋Š” ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
- ์šฐ๋ฆฌ๋Š” ์—ฌ๊ธฐ์—์„œ ‘์™ธ๋ถ€ API๊ฐ€ SUCCESS ํ˜น์€ FAIL’์ด๋ผ๋Š” ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ถ€๋ถ„์— ๋Œ€ํ•ด์„œ stubbing์„ ์ง„ํ–‰ํ•  ๊ฒƒ์ด๋‹ค.

 

์œ„์˜ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณด์ž.
๋ณธ ๊ธ€์—์„œ๋Š” ์•„ํ‚คํ…์ฒ˜๋‚˜ ์„ธ๋ถ€ ์ฝ”๋“œ์— ๋Œ€ํ•œ ๊ตฌํ˜„์€ ์ค‘์š”ํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ€์žฅ ๊ธฐ๋ณธ์ ์ธ ์ฝ”๋“œ๋กœ ์ž‘์„ฑํ•˜์˜€๋‹ค.

 

โœ”๏ธ ๊ธฐ๋ณธ ์ฝ”๋“œ

@RestController
class OrderController(
    private val orderService: OrderService,
) {

    @PostMapping("/order")
    fun order(
        @RequestBody request: OrderRequest
    ) {
        orderService.order(request.productId)
    }
}

data class OrderRequest(
    val productId: Long,
)
@Service
class OrderService(
    private val externalStockCheckApi: ExternalStockCheckApi
) {

    fun order(productId: Long) {
        val result = externalStockCheckApi.check(productId)
        if (result == StockStatus.FAIL) {
            throw OutOfStockException("์žฌ๊ณ ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
        }
        otherLogic()
    }

    private fun otherLogic() {
        // do something...
    }
}

class OutOfStockException(
    override val message: String
) : RuntimeException(message) {
}


์—ฌ๊ธฐ์—์„œ otherLogic์˜ ๊ฒฝ์šฐ ๋งค์šฐ ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๋‹ด๋Š”๋‹ค๊ณ  ๊ฐ€์ •ํ•œ๋‹ค.

์‹คํŒจ์˜ ์ผ€์ด์Šค์— ๋Œ€ํ•ด์„œ๋„ ๋ณต์žกํ•œ ๊ฒฝ์šฐ๊ฐ€ ํฌํ•จ๋˜์ง€๋งŒ, ์—ฌ๊ธฐ์—์„œ๋Š” ๊ฐ„๋‹จํ•˜๊ฒŒ ์˜ˆ์™ธ๋งŒ ๋ฐœ์ƒ์‹œํ‚ค๋„๋ก ์ œ์–ด๋ฅผ ํ•ด๋‘์—ˆ๋‹ค.

@Component
class ExternalStockCheckApi {
    fun check(productId: Long): StockStatus {
        return callExternalApi(productId)
    }

    private fun callExternalApi(productId: Long): StockStatus {
        // call external api...
        if (Random().nextInt() % 2 == 0) {
            return StockStatus.SUCCESS
        }
        return StockStatus.FAIL
    }
}

enum class StockStatus {
    SUCCESS,
    FAIL
}


ExternalStockCheckApi ์˜ ๊ฒฝ์šฐ, ์‹ค์ œ๋กœ๋Š” ์™ธ๋ถ€ API์™€ ํ†ต์‹ ํ•˜๋Š” ๊ตฌ๊ฐ„์ด๋‹ค.

ํ˜„์žฌ ์šฐ๋ฆฌ์˜ ์ฝ”๋“œ์—์„œ๋Š” ํ•ด๋‹น ๋ถ€๋ถ„์€ ์ค‘์š”ํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ์ œ์™ธํ•˜๊ณ , ๊ทธ๋ƒฅ ๋žœ๋คํ•œ ๊ฐ’์— ๋”ฐ๋ผ์„œ ์„ฑ๊ณต๊ณผ ์‹คํŒจ์— ๋Œ€ํ•ด์„œ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ˆ˜์ •ํ•˜์˜€๋‹ค.

์šฐ๋ฆฌ๋Š” ์œ„์™€ ๊ฐ™์€ ์ฝ”๋“œ๊ฐ€ ์žˆ์„ ๋•Œ, `ExternalStockCheckApi`์˜ ๊ฒฐ๊ณผ๋ฅผ ์ œ์–ดํ•˜์—ฌ ์šฐ๋ฆฌ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์˜๋„ํ•˜๋Š”๋Œ€๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š”์ง€๋ฅผ ํ™•์ธํ•ด๋ณผ ์˜ˆ์ •์ด๋‹ค. ๋˜ํ•œ, ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ์•„๋‹Œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ํ™œ์šฉํ•  ์˜ˆ์ •์ด๋‹ค. ๋ฌผ๋ก , ํ…Œ์ŠคํŠธ์˜ ๋ฐฉ๋ฒ•์—๋Š” ์—ฌ๋Ÿฌ ๊ฐ€์ง€๊ฐ€ ์žˆ๊ณ , ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ํ™œ์šฉํ•ด์„œ ์šฐ๋ฆฌ์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ๋„ ๊ฐ€๋Šฅํ•˜๋‹ค.

 

๊ทธ๋Ÿฌ๋‚˜, ์‹ค์ œ๋กœ ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” ๊ฒฝ์šฐ์—๋Š” ์ƒ๋‹นํžˆ ๋งŽ์€ ๋นˆ๋“ค์ด ์„œ๋กœ ํ˜‘๋ ฅํ•˜์—ฌ ๋™์ž‘ํ•˜๊ฒŒ ๋œ๋‹ค. ๋ชจ๋“  ๋นˆ์— ๋Œ€ํ•ด์„œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜์—ฌ ๊ฐ๊ฐ์˜ ์ปดํฌ๋„ŒํŠธ๋“ค์— ๋Œ€ํ•œ ์ •์ƒ์ ์ธ ๋™์ž‘์„ ํŒ๋‹จํ•  ์ˆ˜๋„ ์žˆ์ง€๋งŒ, ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ๋“ค์˜ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ์ž‘์„ฑ๋˜์ง€ ์•Š์€ ์ƒํ™ฉ์ด๊ฑฐ๋‚˜ ๊ฐ ์ปดํฌ๋„ŒํŠธ๋“ค๊ฐ„์˜ ์ •์ƒ์ ์ธ ํ˜‘๋ ฅ ๊ด€๊ณ„๊ฐ€ ์ž˜ ๋™์ž‘ํ•˜๊ณ  ์žˆ๋Š”์ง€ ํŒ๋‹จํ•˜๊ณ  ์‹ถ์„ ๋•Œ๋Š” ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๊ฐ€ ์ข‹์€ ๋ฐฉ๋ฒ•์ด ๋  ์ˆ˜ ์žˆ๋‹ค.

 

๊ทธ๋ ‡๋‹ค๋ฉด, ‘ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋„ ์‹ค์ œ ์™ธ๋ถ€ API๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋˜์ง€ ์•Š๋Š”๊ฐ€?’ ๋ผ๊ณ  ์ƒ๊ฐํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜, ๋งŒ์•ฝ ์™ธ๋ถ€ API๊ฐ€ ์ผ์‹œ์ ์œผ๋กœ ์žฅ์• ๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์„œ ๋Š˜ ํ†ต๊ณผํ•˜๋˜ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ‘์ž๊ธฐ ํ†ต๊ณผํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ? ํ…Œ์ŠคํŠธ์˜ ๊ฒฝ์šฐ ๋Š˜ ์ผ๊ด€์ ์ธ ์‹œ๋‚˜๋ฆฌ์˜ค๋กœ ๋™์ž‘ํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ด์— ๋Œ€ํ•ด ์œ„๋ฐฐํ•˜๊ฒŒ ๋œ๋‹ค. (FIRST ์›์น™, ๋ฌผ๋ก  ์ด๋Š” ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์— ๋Œ€ํ•œ ์›์น™์ด์ง€๋งŒ ๊ทธ๋ž˜๋„ ์‚ด์ง ์ธ์šฉํ•˜์ž๋ฉด ๊ทธ๋ ‡๋‹ค.)

 

๋˜ํ•œ, ์‚ฌ์—…์ ์ธ ๊ด€์ ์œผ๋กœ ๋ดค์„ ๋•Œ ์™ธ๋ถ€ API์— ๋Œ€ํ•œ ํ˜ธ์ถœ ๋น„์šฉ์„ ๋ฐ›๋Š” ๊ณณ์ด๋ผ๋ฉด? ํ…Œ์ŠคํŠธ ํ•  ๋•Œ๋งˆ๋‹ค ๋น„์šฉ ์ง€์ถœ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์–ด๋Š ์ •๋„์˜ stubbing์€ ํ•„์š”ํ•œ ์ƒํ™ฉ์ด๋‹ค. (์‹ค์ œ๋กœ ์‚ฌ๋‚ด์—์„œ ๋™์ผํ•œ ์ผ€์ด์Šค๊ฐ€ ์žˆ์–ด์„œ, ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” ์•„์˜ˆ dummy ๊ฐ’์„ ๋‚ด๋ ค์ฃผ๋„๋ก ์ฒ˜๋ฆฌํ•˜๋Š” ๋ถ€๋ถ„๋„ ์กด์žฌํ•œ๋‹ค.)

 

์•„๋ฌดํŠผ, ์œ„์˜ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ํŒ๋‹จํ•˜๊ธฐ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณด์ž. RestAssured๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ž‘์„ฑํ•ด์ฃผ์—ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์„ค์ • ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด์„œ๋Š” ๋‹ค์Œ ํฌ์ŠคํŒ…์—์„œ ์กฐ๊ธˆ ๋” ๋‹ค๋ฃจ์–ด๋ณด๊ณ ์ž ํ•œ๋‹ค.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class OrderControllerTest() {

    @LocalServerPort
    private var port: Int = 0

    @BeforeAll
    fun setUp() {
        RestAssured.port = port
    }

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(1L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.OK.value())
    }

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(2L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
    }

    private fun callOrderApi(
        productId: Long
    ): ExtractableResponse<Response> {
        return RestAssured.given()
            .given().log().all()
            .contentType("application/json")
            .body(
                mapOf("productId" to productId)
            )
            .`when`()
            .post("/order")
            .then().log().all().extract()
    }
}


์šฐ๋ฆฌ๊ฐ€ ์›ํ•˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค๋Š” ์œ„์™€ ๊ฐ™์€ 2๊ฐ€์ง€ ์ƒํ™ฉ์„ ํ…Œ์ŠคํŠธ ํ•  ์ˆ˜ ์žˆ๋‹ค.
- ์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ ์•„๋ฌด ์˜ค๋ฅ˜๋„ ๋ฐœ์ƒํ•˜์ง€ ์•Š์Œ
- ์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•จ

 

๋‹น์—ฐํžˆ ํ˜„์žฌ์˜ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋Š” ๊นจ์ง€๋Š” ๊ฒƒ์ด ๋งž๋‹ค.




๐ŸŒฑ @MockBean์„ ํ™œ์šฉํ•˜์—ฌ ์žฌ์ •์˜ํ•˜๊ธฐ

 

โ˜๏ธŽ @MockBean์ด ๋ฌด์—‡์ผ๊นŒ?

๋จผ์ €, ์ฒซ ๋ฒˆ์งธ ๋ฐฉ๋ฒ•์œผ๋กœ๋Š” @MockBean์„ ํ™œ์šฉํ•˜์—ฌ `ExternalStockCheckApi` ์— ๋Œ€ํ•ด ์žฌ์ •์˜๋ฅผ ํ•  ์ˆ˜ ์žˆ๋‹ค.

@MockBean์˜ ๊ฒฝ์šฐ Spring 1.4์—์„œ ์ถ”๊ฐ€๋œ ํ…Œ์ŠคํŠธ ์„œํฌํŒ…์šฉ ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ, ์Šคํ”„๋ง์˜ ApplicationContext์— mocking ๋œ ๊ฐ์ฒด๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ•˜๋Š” ์–ด๋…ธํ…Œ์ด์…˜์ด๋‹ค. ๋นˆ์˜ ํƒ€์ž…์ด๋‚˜ ์ด๋ฆ„์œผ๋กœ ๋“ฑ๋ก์ด ๊ฐ€๋Šฅํ•˜๋ฉฐ, ๊ธฐ์กด์˜ ์ปจํ…์ŠคํŠธ์—์„œ ์ผ์น˜ํ•˜๋Š” ๋นˆ์— ๋Œ€ํ•ด์„œ mock ๋นˆ์œผ๋กœ ๋Œ€์ฒดํ•˜๊ฒŒ ๋งŒ๋“ค์–ด์ค€๋‹ค. ๋งŒ์•ฝ ๊ธฐ์กด์— ๋นˆ์ด ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ์ƒˆ๋กœ์šด ๋นˆ์œผ๋กœ ์ถ”๊ฐ€ํ•˜์—ฌ ๋“ฑ๋กํ•ด์ค€๋‹ค.


Junit4๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด `@RunWith(SpringRunner.class)`์™€ ํ•จ๊ป˜, Junit5๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด `@ExtendWith(SpringExtension.class)` ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ๋‚˜๋Š” @SpringBootTest ์–ด๋…ธํ…Œ์ด์…˜์„ ์‚ฌ์šฉํ•  ์˜ˆ์ •์ด๊ธฐ ๋•Œ๋ฌธ์— ๋”ฐ๋กœ ๋ถ™์—ฌ์ฃผ์ง€๋Š” ์•Š์•˜๋‹ค.


์ฐธ๊ณ ๋กœ, ์ด๋Ÿฌํ•œ @MockBean์˜ ๊ฒฝ์šฐ `MockitoTestExecutionListener` ์— ์˜ํ•ด์„œ ์ฒ˜๋ฆฌ๋˜๊ณ  ์žˆ๋‹ค.

`MockitoTestExecutionListener` ์˜ ๊ฒฝ์šฐ `AbstractTestExecutionListener` ์„ ์ƒ์†๋ฐ›์•˜์œผ๋ฉฐ, ํ…Œ์ŠคํŠธ ์ปจํ…์ŠคํŠธ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜ํ–‰ ์‹œ ์–ด๋–ค ์ „์ฒ˜๋ฆฌ / ํ›„์ฒ˜๋ฆฌ๋ฅผ ์ง„ํ–‰ํ• ์ง€ ์ •ํ•ด์ค„ ์ˆ˜ ์žˆ๋‹ค.

 

public class MockitoTestExecutionListener extends AbstractTestExecutionListener {

    @Override
    public void prepareTestInstance(TestContext testContext) throws Exception {
        closeMocks(testContext);
        initMocks(testContext);
        injectFields(testContext);
    }
}

private void initMocks(TestContext testContext) {
    if (hasMockitoAnnotations(testContext)) {
        testContext.setAttribute(MOCKS_ATTRIBUTE_NAME, MockitoAnnotations.openMocks(testContext.getTestInstance()));
    }
}

private void injectFields(TestContext testContext) {
    postProcessFields(testContext, (mockitoField, postProcessor) -> postProcessor.inject(mockitoField.field,
    mockitoField.target, mockitoField.definition));
}

private void postProcessFields(TestContext testContext, BiConsumer<MockitoField, MockitoPostProcessor> consumer) {
    DefinitionsParser parser = new DefinitionsParser();
    parser.parse(testContext.getTestClass());

    if (!parser.getDefinitions().isEmpty()) {
        MockitoPostProcessor postProcessor = testContext.getApplicationContext()
            .getBean(MockitoPostProcessor.class);

        for (Definition definition : parser.getDefinitions()) {
            Field field = parser.getField(definition);
            if (field != null) {
                consumer.accept(new MockitoField(field, testContext.getTestInstance(), definition), postProcessor);
            }
        }
    }
}


testContext๋ฅผ ํ™œ์šฉํ•˜๋ฉด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์— ์„ ์–ธ๋œ ์–ด๋…ธํ…Œ์ด์…˜ ์ •๋ณด๋ฅผ ์ฝ์–ด์˜ฌ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, mockito ๊ธฐ๋ฐ˜ ์–ด๋…ธํ…Œ์ด์…˜๋“ค์„ ์ฐพ์•„์„œ mock field๋กœ ์ฃผ์ž…ํ•ด์ฃผ๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

 


โ˜๏ธŽ ์‹ค์ œ๋กœ ์ ์šฉํ•ด๋ณด๊ธฐ

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class OrderControllerTest(
    @MockBean
    private val externalStockCheckApi: ExternalStockCheckApi
) {

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        Mockito.`when`(
            externalStockCheckApi.check(anyLong())
        ).thenReturn(StockStatus.SUCCESS)

        val result = callOrderApi(1L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.OK.value())
    }

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        Mockito.`when`(
            externalStockCheckApi.check(anyLong())
        ).thenReturn(StockStatus.FAIL)

        val result = callOrderApi(2L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
    }
}


Mockito.when().thenReturn()์„ ์‚ฌ์šฉํ•˜๋ฉด mockBean์œผ๋กœ ๋“ฑ๋ก๋œ mock ๊ฐ์ฒด์— ๋Œ€ํ•ด์„œ ์‰ฝ๊ฒŒ stubbing์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค.

when์ ˆ์—๋Š” ์šฐ๋ฆฌ๊ฐ€ ์Šคํ„ฐ๋น™์„ ์ง„ํ–‰ํ•˜๊ณ  ์‹ถ์€ ๋ฉ”์„œ๋“œ๋ฅผ ์ž…๋ ฅํ•˜๊ณ , then ์ ˆ์—๋Š” ํ•ด๋‹น ์ƒํ™ฉ์—์„œ ์–ด๋–ค ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด ์ข‹์„์ง€ ์ž‘์„ฑํ•ด์ฃผ๋ฉด ๋œ๋‹ค. thenReturn ์™ธ์—๋„ thenThrow, thenAnswer ๋“ฑ์œผ๋กœ ๋” ์œ ์—ฐํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

 

 

โ˜๏ธŽ ๋‹จ์ ์€ ์—†์„๊นŒ?


์•„์‰ฝ๊ฒŒ๋„, @MockBean์„ ํ™œ์šฉํ•˜๊ฒŒ ๋˜๋ฉด ์Šคํ”„๋ง ์ปจํ…์ŠคํŠธ๋ฅผ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

์šฐ์„ , @MockBean์„ ์‚ฌ์šฉํ•˜๊ฒŒ ๋˜๋ฉด ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋ฅผ ์ˆ˜ํ–‰ํ•  ๋•Œ๋งˆ๋‹ค Spring Context์— ๋Œ€ํ•ด refresh๋ฅผ ์ง„ํ–‰ํ•˜๋Š”๋ฐ, ์ด๋Š” ์œ„์— ์ฒจ๋ถ€ํ•ด๋‘” `postProcessFields()` ํ•จ์ˆ˜์˜ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ถ€๋ถ„์„ ์‚ดํŽด๋ณด๋ฉด ์•Œ ์ˆ˜ ์žˆ๋‹ค.

// postProcessFields 
if (!parser.getDefinitions().isEmpty()) {
    MockitoPostProcessor postProcessor = testContext.getApplicationContext()
        .getBean(MockitoPostProcessor.class);
}

// DefaultTestContext.getApplicationContext
@Override
public ApplicationContext getApplicationContext() {
    ApplicationContext context = this.cacheAwareContextLoaderDelegate.loadContext(this.mergedConfig);
    ...
}

// DefaultCacheAwareContextLoaderDelegate.loadContext
@Override
public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) {
    ...
    synchronized (this.contextCache) {
        ApplicationContext context = this.contextCache.get(mergedConfig);
        ...
        context = loadContextInternal(mergedConfig);
        ...
    }


    protected ApplicationContext loadContextInternal(MergedContextConfiguration mergedConfig) throws Exception {
        ContextLoader contextLoader = getContextLoader(mergedConfig);
        if (contextLoader instanceof SmartContextLoader smartContextLoader) {
            return smartContextLoader.loadContext(mergedConfig);
        }
        ...
    }

    // SpringBootContextLoader.loadContext
    private ApplicationContext loadContext(MergedContextConfiguration mergedConfig, Mode mode,
        ApplicationContextInitializer<ConfigurableApplicationContext> initializer) throws Exception {
        ...
        return hook.run(() -> application.run(args));
    }

    // SpringApplication.run
    public ConfigurableApplicationContext run(String... args) {
    refreshContext(context);
}


์—ฌ๊ธฐ์—์„œ ํ…Œ์ŠคํŠธ ์ปจํ…์ŠคํŠธ ๋‚ด์— ์žˆ๋Š” applicationContext๋ฅผ ๊บผ๋‚ด์˜ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๋‚ด๋ถ€๋กœ ํƒ€๊ณ  ๋“ค์–ด๊ฐ€๊ฒŒ ๋˜๋ฉด context์— ๋Œ€ํ•ด refresh๋ฅผ ํ•˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.
์ฝ”๋“œ์˜ ์ค‘๊ฐ„ ๋ถ€๋ถ„์„ ๋ณด๋ฉด ์Šคํ”„๋ง์—์„œ๋Š” ์ตœ๋Œ€ํ•œ ์บ์‹œ๋œ ํ…Œ์ŠคํŠธ ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ•˜์ง€๋งŒ (contextCache ๊ฐ™์€ ํ•„๋“œ๋“ค์ด ์กด์žฌํ•˜๋Š” ๊ฑธ ๋ณผ ์ˆ˜ ์žˆ๋‹ค), @MockBean์„ ์‚ฌ์šฉํ•˜๊ฒŒ ๋˜๋ฉด ํ•ด๋‹น ๋นˆ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ํ•ด๋‹น ๋นˆ์„ ์‚ฌ์šฉํ•˜๋Š” ๋‹ค๋ฅธ ์นœ๊ตฌ๋“ค๋„ ๋ชจ๋‘ ๊ต์ฒดํ•ด์ค˜์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์บ์‹œ๋ฅผ ํ•˜์ง€ ์•Š๊ณ  ์•„์˜ˆ refresh๋ฅผ ํ•ด์ฃผ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

 

class A(val b: B) ์™€ ๊ฐ™์€ ์ฝ”๋“œ๊ฐ€ ์žˆ์„ ๋•Œ, B๊ฐ€ mockBean์ด๋ผ๋ฉด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ B๋ฅผ ์˜์กดํ•˜๊ณ  ์žˆ๋Š” A ์—ญ์‹œ mocking๋œ B๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ต์ฒด ์ž‘์—…์ด ํ•„์š”ํ•ด์ง„๋‹ค. ๋งŒ์•ฝ ๋นˆ๋ผ๋ฆฌ ๋” ๋ณต์žกํ•œ ์˜์กด ๊ด€๊ณ„๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค๋ฉด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋” ๋งŽ์€ ๋นˆ๋“ค์— ๋Œ€ํ•ด์„œ ๊ต์ฒดํ•˜๋Š” ์ž‘์—…์ด ํ•„์š”ํ•ด์งˆ ๊ฒƒ์ด๋‹ค.

 

์ฐธ๊ณ ๋กœ, ๊ธฐ๋ณธ์ ์œผ๋กœ ์Šคํ”„๋ง์˜ context caching์˜ ๊ฒฝ์šฐ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ƒํ™ฉ์— ๋Œ€ํ•ด์„œ ๋ฐœ์ƒํ•œ๋‹ค.
  - ๋™์ผํ•œ bean์˜ ์กฐํ•ฉ์„ ์‚ฌ์šฉํ–ˆ์„ ๋•Œ
  - ์ด์ „์˜ ํ…Œ์ŠคํŠธ์—์„œ applicationContext๊ฐ€ ์˜ค์—ผ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ
  - @DirtyContext ๋ฅผ ํ™œ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ






๐ŸŒฑ @Profile ์„ค์ •์„ ํ†ตํ•ด์„œ ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์„ ๋ถ„๋ฆฌํ•˜๊ธฐ

๋งŒ์•ฝ, ์œ„์™€ ๊ฐ™์€ spring context refresh๊ฐ€ ์‹ซ๋‹ค๋ฉด, ํ…Œ์ŠคํŠธ์—์„œ ์‚ฌ์šฉํ•  ํ”„๋กœํŒŒ์ผ์„ ์ง€์ •ํ•ด์ฃผ๊ณ  ํ•ด๋‹น ํ”„๋กœํŒŒ์ผ์—์„œ๋Š” ๋‹ค๋ฅธ ๋™์ž‘์„ ํ•˜๋„๋ก ๋งŒ๋“ค์–ด ์ค„ ์ˆ˜ ์žˆ๋‹ค. ํ•˜์ง€๋งŒ, ์Šคํ”„๋ง์—์„œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋™์ผํ•œ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋นˆ์„ 2๊ฐœ ๋„์šธ ์ˆ˜ ์—†๋‹ค. ์ด๋ฅผ ์œ„ํ•ด `ExternalStockCheckApi`๋ฅผ ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ถ„๋ฆฌํ•ด์ฃผ๋Š” ์ž‘์—…์„ ์ง„ํ–‰ํ•ด์ฃผ์ž. ์ธํ„ฐํŽ˜์ด์Šค์˜ ๊ฒฝ์šฐ ๊ตฌ์ฒด์ ์ธ ํ–‰์œ„๊ฐ€ ๋“ค์–ด๋‚˜์ง€ ์•Š๋„๋ก, ์žฌ๊ณ ๋ฅผ ํ™•์ธํ•˜๋Š” ์šฉ๋„์˜ ๊ธฐ๋Šฅ ์ •๋„๋งŒ ๋‚˜ํƒ€๋‚ด ์ค„ ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค์—ˆ๋‹ค.

 

interface CheckStockPort {
    fun check(productId: Long): StockStatus
}


 ๊ทธ๋ฆฌ๊ณ , @Profile ์–ด๋…ธํ…Œ์ด์…˜์„ ํ™œ์šฉํ•˜์—ฌ profile ๋ณ„๋กœ ๋‹ค๋ฅธ ์ฝ”๋“œ๊ฐ€ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค์–ด์ฃผ์ž.

@Component
@Profile("!test")
class ExternalStockCheckApi: CheckStockPort {
    override fun check(productId: Long): StockStatus {
        return callExternalApi(productId)
    }
}

@Component
@Profile("test")
class TestStockCheckApi: CheckStockPort {
    override fun check(productId: Long): StockStatus {
        if (productId == 1L) {
            return StockStatus.SUCCESS
        }
        return StockStatus.FAIL
    }
}


๊ธฐ์กด์˜ ExternalStockCheckApi ์— ๋Œ€ํ•ด์„œ CheckStockPort ์„ ์˜ค๋ฒ„๋ผ์ด๋“œ ํ•˜๋„๋ก ๋งŒ๋“ค๊ณ , ํŠน์ • Test ํ”„๋กœํŒŒ์ผ์— ๋Œ€ํ•ด์„œ๋Š” TestStockCheckApi ๊ฐ€ ๋™์ž‘ํ•˜๋„๋ก ๋งŒ๋“ค์—ˆ๋‹ค.

๊ทธ๋ฆฌ๊ณ , ์šฐ๋ฆฌ์˜ ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค์— ๋งž์ถฐ productId ๋ณ„๋กœ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜ํƒ€๋‚  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค์—ˆ๋‹ค.

๊ธฐ์กด์— ExternalStockApi๋ฅผ ์˜์กดํ•˜๋˜ ์„œ๋น„์Šค ์ฝ”๋“œ ์—ญ์‹œ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ์˜์กดํ•˜๋„๋ก ๋ณ€๊ฒฝํ•ด์ฃผ์ž.

 

@Service
class OrderService(
    private val checkStockPort: CheckStockPort,
) {

    fun order(productId: Long) {
        val result = checkStockPort.check(productId)
        ...
    }
}

 

๊ทธ๋ฆฌ๊ณ , ๋‹ค์‹œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋กœ ๋Œ์•„๊ฐ€์„œ ‘test’๋ผ๋Š” profile์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋„๋ก @ActiveProfiles ์–ด๋…ธํ…Œ์ด์…˜์„ ์ถ”๊ฐ€ํ•ด์ฃผ๋„๋ก ํ•˜์ž.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ActiveProfiles("test")
class OrderControllerTestV2 {

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(1L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.OK.value())
    }

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(2L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
    }
}


์ด๋Ÿฌ๋ฉด @MockBean์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋”๋ผ๋„ ๊น”๋”ํ•˜๊ฒŒ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค์— ๋”ฐ๋ผ์„œ ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์ด ์–ด๋–ค ํ–‰์œ„๋ฅผ ์ˆ˜ํ–‰ํ• ์ง€ ์„ค์ •ํ•ด์ค€๋‹ค๋ฉด ๋™์ผํ•œ ์ปจํ…์ŠคํŠธ๋ฅผ ๊ฐ€์ง€๋„๋ก ์žฌ์‚ฌ์šฉ๋„ ํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํ…Œ์ŠคํŠธ ์„ฑ๋Šฅ ์—ญ์‹œ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๋‹ค.

 


 

๐ŸŒฑ @TestConfiguration ํ™œ์šฉํ•ด์ฃผ๊ธฐ

์œ„์™€ ๊ฐ™์ด Profile ์–ด๋…ธํ…Œ์ด์…˜์„ ํ™œ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•๋„ ์žˆ์ง€๋งŒ, @TestConfiguration ์„ ํ™œ์šฉํ•ด์„œ๋„ ๋‹ค๋ฅธ ๋นˆ์„ ์‚ฌ์šฉํ•˜๋„๋ก ์ œ์–ดํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋œ๋‹ค. ๋งŒ์•ฝ, ์šด์˜ ์ฝ”๋“œ์™€ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์™„์ „ํžˆ ๋ถ„๋ฆฌํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์„ ๋“ฑ๋กํ•ด์ฃผ๋„๋ก ํ•˜์ž.

 

๊ธฐ์กด์˜ ์ฝ”๋“œ์˜ ๊ฒฝ์šฐ ์šด์˜ ์ฝ”๋“œ์—์„œ ์ปดํฌ๋„ŒํŠธ ์Šค์บ”์œผ๋กœ ์ธํ•ด์„œ `TestStockCheckApi` ๊ฐ€ ๋นˆ์œผ๋กœ ๋“ฑ๋ก์ด ๋˜์—ˆ์—ˆ๋Š”๋ฐ, ํ…Œ์ŠคํŠธ ์ปจํ…์ŠคํŠธ ๋‚ด์—์„œ๋งŒ ๋นˆ์œผ๋กœ ๋“ฑ๋กํ•˜๋„๋ก ๋งŒ๋“ค์–ด๋ณด์ž. ๋จผ์ €, @TestConfiguration์„ ํ™œ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์„ ์„ ์–ธํ•ด์ฃผ์ž.

 

@TestConfiguration
class TestConfig {

    @Bean
    fun checkStockPort() : CheckStockPort {
        return TestStockCheckApi()
    }
}

class TestStockCheckApi: CheckStockPort {
    override fun check(productId: Long): StockStatus {
        if (productId == 1L) {
            return StockStatus.SUCCESS
        }
        return StockStatus.FAIL
    }
}


๋งŒ์•ฝ ๋™์ผํ•œ ํƒ€์ž…์˜ ๋นˆ์„ ๋“ฑ๋กํ•ด์ค„ ์ผ์ด ์žˆ๋‹ค๋ฉด @Primary๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ ์ ˆํ•˜๊ฒŒ ์กฐ์ ˆํ•˜์ž.

๊ทธ๋ฆฌ๊ณ , ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ @Import๋ฅผ ํ†ตํ•ด์„œ (ํ˜น์€ @ContextConfiguration์„ ํ™œ์šฉํ•ด๋„ ๋œ๋‹ค) ํ•ด๋‹น Configuration ์— ๋Œ€ํ•ด ๋กœ๋“œ๋ฅผ ํ•ด์ค€๋‹ค.

 

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
//@ContextConfiguration(classes = [TestConfig::class])
@Import(TestConfig::class)
class OrderControllerTestV3 {

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(1L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.OK.value())
    }

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(2L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
    }
}


์ด๋ ‡๊ฒŒ ํ•˜๋ฉด @Profile ์–ด๋…ธํ…Œ์ด์…˜๊ณผ ๋™์ผํ•˜๊ฒŒ ํ…Œ์ŠคํŠธ์šฉ checkStockPort ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋œ๋‹ค.

 


โ˜๏ธŽ @ConditionalOnProperty ํ™œ์šฉํ•˜๊ธฐ

๋˜ํ•œ, @TestConfiguration๊ณผ @ConditionalOnProperty 2๊ฐ€์ง€์˜ ์–ด๋…ธํ…Œ์ด์…˜์„ ์กฐํ•ฉํ•ด์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฝ”๋“œ๋ฅผ ๊ตฌ์„ฑํ•ด๋ณผ ์ˆ˜๋„ ์žˆ๋‹ค. ๋Œ€์‹  ์กฐ๊ธˆ ๋” ๋ณต์žก๋„๊ฐ€ ๋†’์•„์ง€๊ธฐ ๋•Œ๋ฌธ์— ๊ทธ๋ƒฅ ์ด๋Ÿฐ ๋ฐฉ๋ฒ•๋„ ์žˆ๋‹ค๋Š” ์ •๋„๋งŒ ๋„˜์–ด๊ฐ€๋„ ๋  ๊ฒƒ ๊ฐ™๋‹ค.
๋จผ์ €, ์šด์˜ ์ฝ”๋“œ์—์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด `@ConditionalOnProperty` ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ํŠน์ • ํ”„๋กœํผํ‹ฐ๊ฐ€ ์กด์žฌํ•  ๋•Œ ํ•ด๋‹น ๋นˆ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์ˆ˜์ •ํ•ด์ฃผ์ž.

@Component
@ConditionalOnProperty(name = ["check-stock.test"], havingValue = "false")
class ExternalStockCheckApi: CheckStockPort {
    override fun check(productId: Long): StockStatus {
        return callExternalApi(productId)
    }
}


์—ฌ๊ธฐ์—์„œ๋Š” `check-stock.test` ๋ผ๋Š” ํ”„๋กœํผํ‹ฐ๊ฐ€ false์ธ ๊ฒฝ์šฐ์— ์šด์˜ ์ฝ”๋“œ๊ฐ€ ๋™์ž‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ์„ค์ •ํ•ด์ฃผ์—ˆ๋‹ค.

 

๊ทธ๋ฆฌ๊ณ , ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์กฐ๊ธˆ ์ˆ˜์ •ํ•ด์ฃผ์ž. ์šฐ์„ , ํ”„๋กœํผํ‹ฐ ๊ฐ’์„ ์‚ฌ์šฉํ•˜๋Š” ๊น€์— ํ•ด๋‹น ํ”„๋กœํผํ‹ฐ ๊ฐ’์— ๋”ฐ๋ผ์„œ success๋ฅผ ์‘๋‹ตํ•˜๋Š” ์™ธ๋ถ€ API์™€ fail์„ ์‘๋‹ตํ•˜๋Š” ์™ธ๋ถ€ API ๋นˆ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ ์ž ํ•œ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด Conditional ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ™œ์šฉํ•ด์ค„ ์ˆ˜ ์žˆ๋‹ค.

class CheckStockSuccessCondition : Condition {
    override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean {
        val environment = context.environment
        val checkStockTest = environment.getProperty("check-stock.test") == "true"
        val checkStockResultSuccess = environment.getProperty("check-stock.result-success") == "true"
        return checkStockTest && checkStockResultSuccess
    }
}

class CheckStockFailCondition : Condition {
    override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean {
        val environment = context.environment
        val checkStockTest = environment.getProperty("check-stock.test") == "true"
        val checkStockResultFailure = environment.getProperty("check-stock.result-success") == "false"
        return checkStockTest && checkStockResultFailure
    }
}

 

`check-stock.test` ํ”„๋กœํผํ‹ฐ๊ฐ€ true์ด๋ฉด์„œ, `check-stock.result-success` ํ”„๋กœํผํ‹ฐ๊ฐ€ true์ธ์ง€ false์ธ์ง€์— ๋”ฐ๋ผ์„œ Condition์„ ๊ฒ€์ฆํ•˜๋Š” ๋กœ์ง์ด๋‹ค. ๊ทธ๋ฆฌ๊ณ , ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋นˆ์„ ๋“ฑ๋กํ•ด์ฃผ์ž.

@TestConfiguration
class TestConfigV2 {

    @Bean(name = ["checkStockPort"])
    @Conditional(CheckStockSuccessCondition::class)
    fun checkStockPortReturnSuccess(
        environment: Environment
    ): CheckStockPort {
        return object : CheckStockPort {
            override fun check(productId: Long): StockStatus {
                return StockStatus.SUCCESS
            }
        }
    }

    @Bean(name = ["checkStockPort"])
    @Conditional(CheckStockFailCondition::class)
    fun checkStockPortReturnFail(
        environment: Environment
    ): CheckStockPort {
        return object : CheckStockPort {
            override fun check(productId: Long): StockStatus {
                return StockStatus.FAIL
            }
        }
    }
}


 ๋‘˜ ๋‹ค ‘checkStockPort’ ๋ผ๋Š” ์ด๋ฆ„์œผ๋กœ ๋นˆ ๋“ฑ๋ก์ด ๋  ์ˆ˜ ์žˆ๊ฒŒ ๋„ค์ž„ ์ง€์ •์„ ํ•ด์ฃผ๊ณ , ๊ฐ๊ฐ์˜ ์กฐ๊ฑด์ด true์ผ ๋•Œ ํ•ด๋‹น ๋นˆ์„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก @Conditional ์–ด๋…ธํ…Œ์ด์…˜์„ ํ™œ์šฉํ•ด์ฃผ์—ˆ๋‹ค.

๋งˆ์ง€๋ง‰์œผ๋กœ, ํ…Œ์ŠคํŠธ์—์„œ ํ•ด๋‹น ๋นˆ ์ •๋ณด๋ฅผ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก import ํ•˜๊ณ , ํ”„๋กœํผํ‹ฐ๋ฅผ ์ฃผ์ž…ํ•ด์ฃผ์ž. ํ”„๋กœํผํ‹ฐ ์ฃผ์ž…์„ ์œ„ํ•ด์„œ ์—ฌ๋Ÿฌ ๋ฐฉ๋ฒ•์ด ์žˆ๊ฒ ์ง€๋งŒ, ๋‚˜๋Š” @SpringBootTest๊ฐ€ ์ œ๊ณตํ•˜๋Š” ํ”„๋กœํผํ‹ฐ ์†์„ฑ์„ ํ™œ์šฉํ•˜์—ฌ ์ฃผ์ž…ํ•ด์ฃผ์—ˆ๋‹ค.


๋‹ค๋งŒ, ์ด๋Ÿฌ๋‹ค ๋ณด๋‹ˆ๊นŒ ์‹คํŒจ์™€ ์„ฑ๊ณต ์ผ€์ด์Šค์— ๋Œ€ํ•ด ๊ฐ๊ฐ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋ฅผ ๋ถ„๋ฆฌํ•˜์˜€๋‹ค. ์ด๋Ÿฐ ๋ฐฉ๋ฒ•๋„ ์žˆ์„ ๋ฟ์ด์ง€, ์‹ค์ œ๋กœ ํ™œ์šฉํ•˜๊ธฐ์—๋Š” ํšจ์šฉ์ด ์ข‹์„์ง€๋Š” ์ž˜ ๋ชจ๋ฅด๊ฒ ๋‹ค. (์ •๋ง ์ด๋Ÿฐ ๋ฐฉ๋ฒ•์ด ์žˆ๊ธฐ๋งŒ ํ•˜๋‹ค๋Š” ๊ฒƒ ์ •๋„๋งŒ ์ธ์ง€ํ•˜์ž!)

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = ["check-stock.test=true", "check-stock.result-success=true"]
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@Import(TestConfigV2::class)
class OrderControllerTestV4_success {

    @Test
    fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
        val result = callOrderApi(1L)
        assertThat(result.statusCode())
            .isEqualTo(HttpStatus.OK.value())
    }
}

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = ["check-stock.test=true", "check-stock.result-success=false"]
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@Import(TestConfigV2::class)
class OrderControllerTestV4_fail {
    ...
}

 




๐ŸŒฑ mock ๊ฐ์ฒด๋ฅผ ํ™œ์šฉํ•˜๊ธฐ

๋‹ค์Œ์œผ๋กœ, ๋งŒ์•ฝ ํ…Œ์ŠคํŠธ ์ƒํ™ฉ์— ๋”ฐ๋ผ ์—ฌ๋Ÿฌ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ๋‹ค.

์ด๋•Œ๋Š” ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์„ ๋“ฑ๋กํ•  ๋•Œ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๊ฐ€ ์•„๋‹Œ, mock ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

@TestConfiguration
class TestConfigV3 {

    @Bean
    fun checkStockPort() : CheckStockPort {
        return Mockito.mock(CheckStockPort::class.java)
    }
}

 

๋งŒ์•ฝ kotest๋ฅผ ํ™œ์šฉํ•œ๋‹ค๋ฉด mockk<CheckStockPort> ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ด์ค„ ์ˆ˜ ์žˆ๋‹ค.
๊ทธ๋ฆฌ๊ณ , ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ checkStockPort๋ฅผ ์ฃผ์ž…ํ•ด์ฃผ์ž. ์ด๋Ÿฌ๋ฉด ์œ„์—์„œ ์„ ์–ธํ•œ mock ๊ฐ์ฒด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.

 

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@Import(TestConfigV3::class)
class OrderControllerTestV5(
    private val checkStockPort: CheckStockPort,
) {

}


์ด ์ƒํ™ฉ์—์„œ ๊ทธ๋ƒฅ ํ…Œ์ŠคํŠธ๋ฅผ ๋Œ๋ฆฌ๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ?


mocking๋œ ๊ฐ์ฒด์— ๋Œ€ํ•ด์„œ ํ•˜๋Š” ์ผ์„ ์ง€์ •ํ•ด์ฃผ์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ๊ฒฐ๊ณผ๋กœ null์„ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ๋˜๋ฉฐ, ๋‹น์—ฐํ•˜๊ฒŒ๋„ ํ…Œ์ŠคํŠธ๋„ ์‹คํŒจํ•œ๋‹ค.
์ด๋ฅผ ์œ„ํ•ด, ๊ฐ ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค์— ๋งž์ถฐ ์–ด๋–ค ํ–‰์œ„๋ฅผ ํ• ์ง€ ์ง€์ •ํ•ด์ฃผ๋„๋ก ํ•˜์ž.

@Test
fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ์—๋Š” ์ •์ƒ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
    Mockito.`when`(
        checkStockPort.check(Mockito.anyLong())
    ).thenReturn(StockStatus.SUCCESS)

    val result = callOrderApi(1L)
    assertThat(result.statusCode())
        .isEqualTo(HttpStatus.OK.value())
}

@Test
fun `์žฌ๊ณ ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค`() {
    Mockito.`when`(
        checkStockPort.check(Mockito.anyLong())
    ).thenReturn(StockStatus.FAIL)

    val result = callOrderApi(2L)
    assertThat(result.statusCode())
        .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value())
}


Mockito์˜ When, ThenReturn ์ ˆ์„ ํ™œ์šฉํ•˜๋ฉด ์–ด๋–ค ํ–‰์œ„๋ฅผ ํ• ์ง€ ์ง€์ •ํ•ด์ค„ ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋งŒ์•ฝ kotest๋ฅผ ์‚ฌ์šฉ ์ค‘์ด๋ผ๋ฉด every - returns๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

 



๐ŸŒฑ ๋งˆ๋ฌด๋ฆฌ

์š”์ฆ˜ ํšŒ์‚ฌ์—์„œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ์ž์ฃผ ์งœ๋ ค๊ณ  ๋…ธ๋ ฅ ์ค‘์ธ๋ฐ, ํ•œ ๋ฒˆ ๊ตฌ์ถ•ํ•˜๊ณ  ๋‚˜๋‹ˆ๊นŒ ๋‹ค์Œ ๋ฒˆ์— ํ…Œ์ŠคํŠธ๋ฅผ ์งค ๋•Œ ๋” ๊ธˆ๋ฐฉ ์งค ์ˆ˜ ์žˆ๋Š” ๊ฒƒ ๊ฐ™๋‹ค.
๊ฐœ์ธ์ ์œผ๋กœ๋Š” ์œ„ 4๊ฐ€์ง€ ๋ฐฉ๋ฒ• ์ค‘์—์„œ 2๋ฒˆ๊ณผ 4๋ฒˆ์„ ๋งŽ์ด ์“ฐ๊ณ  ์žˆ๋‹ค.
2๋ฒˆ์€ ์Šฌ๋ž™ ๋ฉ”์‹œ์ง€๋‚˜ ๊ธˆ์› ์ง€๊ธ‰, ์™ธ๋ถ€ API๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ ๋“ฑ๋“ฑ, ํ…Œ์ŠคํŠธ์—์„œ ๋ฐ˜๋“œ์‹œ ํ˜ธ์ถœ์ด ๋˜์ง€ ์•Š์„ ํ•„์š”๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ๊ณ ์ •๋œ ํŠน์ •ํ•œ ์‘๋‹ต์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์‚ฌ์šฉํ•˜๊ณ , 4๋ฒˆ์€ ์™ธ๋ถ€ API์ด์ง€๋งŒ ํ•ด๋‹น API๊ฐ€ ์–ด๋–ค ์‘๋‹ต์„ ๋‚ด๋ ค์ฃผ๋Š”์ง€์— ๋”ฐ๋ผ์„œ ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๋‹ฌ๋ผ์ง€๋Š” ๊ฒฝ์šฐ ์‹œ๋‚˜๋ฆฌ์˜ค๋ณ„๋กœ ์ •์˜ํ•˜๊ธฐ ์œ„ํ•ด์„œ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค.

 

์š”์ฆ˜ ํ…Œ์ŠคํŠธ ์†๋„์— ๋Œ€ํ•ด์„œ๋„ ๊ณ ๋ฏผ์ด ๋งŽ์•„์„œ, ์•„์˜ˆ ํ…Œ์ŠคํŠธ์šฉ ๋นˆ์„ ๊ด€๋ฆฌํ•˜๋Š” @TestConfiguration ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•œ ๋‹ค์Œ์—, ๋ชจ๋“  ํด๋ž˜์Šค์—์„œ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค์–ด์„œ ์†๋„๋ฅผ ๊ฐœ์„ ํ•ด๋ณผ๊นŒ๋„ ๊ณ ๋ฏผ ์ค‘์ด๋‹ค. ๋˜ํ•œ, ๊ฐœ์ธ์ ์œผ๋กœ๋Š” kotest๋ฅผ ์„ ํ˜ธํ•˜๋Š” ํŽธ์ด๋ผ์„œ mockk์™€ ํ•จ๊ป˜ ์กฐํ•ฉํ•ด์„œ ๋งŽ์ด ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค.

 

์˜ค๋žœ๋งŒ์— ๋ธ”๋กœ๊ทธ ๊ธ€์„ ์ž‘์„ฑํ•ด์„œ ์–ด์ƒ‰ํ•œ๋ฐ, ์–ผ๋ฅธ ๋‹ค๋ฅธ ๊ธ€๋„ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋„๋ก ๋…ธ๋ ฅํ•ด์•ผ๊ฒ ๋‹ค... ๋!

Comments