matcher ๊ฐœ๋…

matcher = โ€œ์ธ์ž ๋งค์นญ ๊ทœ์น™โ€

  • any() = ์•„๋ฌด ๊ฐ’์ด๋ฉด ๋งค์น˜
  • eq(x) = equals ๊ธฐ์ค€์œผ๋กœ x์™€ ๊ฐ™์œผ๋ฉด ๋งค์น˜
  • isNull() = null์ด๋ฉด ๋งค์น˜
  • argThat { ... } = ์‚ฌ์šฉ์ž ์ •์˜ ์กฐ๊ฑด์ด๋ฉด ๋งค์น˜

ํ•ต์‹ฌ ๋™์ž‘: โ€œ์Šคํƒ์— ๊ธฐ๋ก + ๋”๋ฏธ๊ฐ’(dummy value) ๋ฐ˜ํ™˜โ€

Mockito ๊ณต์‹ Javadoc์€ matcher๊ฐ€ ์ด๋ ‡๊ฒŒ ๋™์ž‘ํ•œ๋‹ค๊ณ  ๋ช…์‹œํ•œ๋‹ค.

  • any(), eq()๋Š” matcher๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ
  • ๋งค์นญ ๊ทœ์น™์„ ๋‚ด๋ถ€ ์Šคํƒ์— ๊ธฐ๋กํ•˜๊ณ 
  • ์ปดํŒŒ์ผ๋Ÿฌ ํƒ€์ž… ์š”๊ตฌ ๋•Œ๋ฌธ์— ๋”๋ฏธ ๊ฐ’(๋Œ€๊ฐœ null)์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค
  • ๊ทธ๋ž˜์„œ any(), eq() ๊ฐ™์€ matcher๋Š” verified/stubbed method ๋ฐ”๊นฅ์—์„œ ์‚ฌ์šฉํ•˜๋ฉด ์•ˆ ๋œ๋‹ค

Q) โ€œmatcher๋กœ ํ‘œ์‹œ๋œ ๊ฐ’์€ ํ…Œ์ŠคํŠธํ•  ๋•Œ ๊ทœ์น™์— ๋งž๋Š” ๋žœ๋ค๊ฐ’์ด ๋“ค์–ด๊ฐ„๋‹ค๊ณ  ๋ณด๋ฉด ๋˜๋‚˜?โ€

  • ์•„๋‹ˆ๋‹ค. matcher๊ฐ€ ๊ฐ’์„ โ€œ์ƒ์„ฑโ€ํ•˜์ง€ ์•Š๋Š”๋‹ค.
  • SUT๊ฐ€ ์‹ค์ œ๋กœ ์ „๋‹ฌํ•œ ๊ฐ’์ด ๋“ค์–ด์˜ค๊ณ , matcher๋Š” ๊ทธ ๊ฐ’์ด ๊ทœ์น™์— ๋งž๋Š”์ง€ ํŒ์ •ํ•œ๋‹ค.
  • ๋‹จ, matcher๋ฅผ ์‹ค์ˆ˜๋กœ SUT ํ˜ธ์ถœ ์ธ์ž์— ๋„ฃ์œผ๋ฉด matcher๊ฐ€ ๋ฐ˜ํ™˜ํ•œ ๋”๋ฏธ๊ฐ’(null ๋“ฑ) ์ด ์‹ค์ œ ์ธ์ž๋กœ ๋“ค์–ด๊ฐ€ ํ๋ฆ„์ด ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ๋‹ค.

ํ•ด๋‹น ๊ฐœ๋…์ด ํ•„์š”ํ•œ ์ด์œ 

  • ์œ ์—ฐํ•œ ์ธ์ž ์กฐ๊ฑด์„ ํ‘œํ˜„ํ•˜๊ธฐ ์œ„ํ•ด(๊ฐ’์ด ์•„๋‹ˆ๋ผ ๊ทœ์น™)
  • ๋ณ€๋™ ๊ฐ’(UUIDยท์‹œ๊ฐ„ ๋“ฑ) ๋•Œ๋ฌธ์— ํ…Œ์ŠคํŠธ๊ฐ€ ์ž์ฃผ ๊นจ์ง€๋Š” ๊ฑธ ์ค„์ด๊ธฐ ์œ„ํ•ด
  • ์ค‘์š”ํ•œ ์ธ์ž๋งŒ ์ •ํ™•ํžˆ ๊ฒ€์ฆํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ๋‹จ์ˆœํ™”ํ•ด ์˜๋„๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๊ธฐ ์œ„ํ•ด

AS-IS / TO-BE ์˜ˆ์‹œ

์˜ˆ: ๊ฒฐ์ œ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๋กœ์ง์—์„œ requestId๋Š” ๋งค๋ฒˆ ๋ฐ”๋€Œ๊ณ , userId/amount๋งŒ ์ •ํ™•ํžˆ ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ๋‹ค.

data class PayRequest(val userId: String, val requestId: String, val amount: Int)
 
interface PaymentGateway {
    fun send(req: PayRequest)
}
 
class PaymentService(private val gateway: PaymentGateway) {
    fun pay(userId: String, amount: Int) {
        val req = PayRequest(
            userId = userId,
            requestId = java.util.UUID.randomUUID().toString(), // ๋งค๋ฒˆ ๋ฐ”๋€œ
            amount = amount
        )
        gateway.send(req)
    }
}

AS-IS (matcher๊ฐ€ ์—†์„ ๋•Œ)

@Test
fun `pay sends request`() {
    val gateway = mock<PaymentGateway>()
    val sut = PaymentService(gateway)
 
    sut.pay("u-1", 1000)
 
    // โŒ requestId๊ฐ€ ๋งค๋ฒˆ ๋‹ฌ๋ผ์„œ ๊ธฐ๋Œ€๊ฐ’์„ ๊ณ ์ •ํ•˜๊ธฐ ์–ด๋ ค์›€ โ†’ ํ…Œ์ŠคํŠธ๊ฐ€ ์ทจ์•ฝํ•ด์ง
    verify(gateway).send(PayRequest("u-1", "๊ณ ์ •๊ฐ’-๋ถˆ๊ฐ€๋Šฅ", 1000))
}

TO-BE (matcher ๋„์ž… ํ›„)

@Test
fun `pay sends request - rule based`() {
    val gateway = mock<PaymentGateway>()
    val sut = PaymentService(gateway)
 
    sut.pay("u-1", 1000)
 
    // โœ… requestId๋Š” "์žˆ๊ธฐ๋งŒ ํ•˜๋ฉด ๋จ", userId/amount๋Š” ์ •ํ™•ํžˆ ํ™•์ธ
    verify(gateway).send(
        argThat { userId == "u-1" && amount == 1000 && requestId.isNotBlank() }
    )
}
flowchart TD
  A[ํ…Œ์ŠคํŠธ์—์„œ ์ธ์ž ๊ฒ€์ฆ ํ•„์š”] --> B{์ธ์ž๊ฐ€ ํ•ญ์ƒ ๊ณ ์ •๊ฐ’์ธ๊ฐ€?}
  B -- Yes --> C[raw ๊ฐ’์œผ๋กœ verify/stub ๊ฐ€๋Šฅ]
  B -- No --> D[AS-IS: ๊ฐ’ ๊ณ ์ • ์–ด๋ ค์›€ โ†’ ํ…Œ์ŠคํŠธ ์ทจ์•ฝ/์˜๋ฏธ ์•ฝํ™”]
  D --> E[TO-BE: matcher๋กœ '๊ทœ์น™' ์„ ์–ธ]
  E --> H[์˜๋„ ์„ ๋ช… + ๋ณ€๊ฒฝ์— ๋œ ๊นจ์ง]

matcher ๊ทœ์น™ 3๊ฐœ

๊ทœ์น™ 1. matcher๋ฅผ ํ•˜๋‚˜๋ผ๋„ ์“ฐ๋ฉด, ๋ชจ๋“  ์ธ์ž๋ฅผ matcher๋กœ ํ†ต์ผ

Mockito Javadoc์˜ ๋Œ€ํ‘œ ๊ฒฝ๊ณ ๋‹ค.

// โœ… OK
verify(gateway).send(anyInt(), anyString(), eq("third"))
 
// โŒ NG: matcher + raw ๊ฐ’ ํ˜ผ์šฉ โ†’ ์˜ˆ์™ธ
verify(gateway).send(anyInt(), anyString(), "third")

Q) โ€œ๊ทธ๋Ÿผ eq๋Š” ์ •ํ™•ํ•œ ๊ฐ’์„ ๊ฐ์‹ธ๊ธฐ ์œ„ํ•œ ๊ทœ์น™์ด๋ผ๊ณ  ๋ณด๋ฉด ๋˜๋‚˜?โ€
๋งž๋‹ค. eq(value)๋Š” โ€œ์ •ํ™•ํ•œ ๊ฐ’์ด์–ด์•ผ ํ•จโ€์„ matcher๋กœ ํ‘œํ˜„ํ•˜๋ฉด์„œ, โ€œmatcher ํ†ต์ผ ๊ทœ์น™โ€์„ ์ง€ํ‚ค๊ฒŒ ํ•ด์ค€๋‹ค.

๊ทœ์น™ 2. matcher๋Š” verify / stubbing ์•ˆ์—์„œ๋งŒ

matcher๋Š” โ€œ์Šคํƒ์— ๊ธฐ๋ก + ๋”๋ฏธ๊ฐ’ ๋ฐ˜ํ™˜โ€ ๋ฐฉ์‹์ด๋ฏ€๋กœ, verified/stubbed method ๋ฐ”๊นฅ(= SUT ํ˜ธ์ถœ ์ธ์ž)์—์„œ ์‚ฌ์šฉํ•˜๋ฉด ์•ˆ ๋œ๋‹ค.

  • โœ… verify(mock).doSomething(any())
  • โœ… whenever(mock.load(eq("id"))).thenReturn(data)
  • โŒ sut.process(any()) โ† SUT ํ˜ธ์ถœ ์ธ์ž์— matcher ์‚ฌ์šฉ

๊ทœ์น™ 3. null์€ โ€œ์˜๋„์ ์œผ๋กœโ€ ๋งค์นญํ•œ๋‹ค

any(Class) / anyInt ๊ฐ™์€ ํƒ€์ž… ์ฒดํฌ matcher๋Š” null์„ ๋งค์นญํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ์ ์ด Javadoc์— ๋ช…์‹œ๋˜์–ด ์žˆ๋‹ค. null์ด ํ•„์š”ํ•˜๋ฉด isNull()์„ ์„ ํƒํ•˜๋Š” ๊ฒƒ์ด ๊ฐ€์žฅ ๋ช…ํ™•ํ•˜๋‹ค.


doReturn)์—์„œ matcher ์‚ฌ์šฉํ•˜๋Š” ๋ฒ•

whenever(...).thenReturn(...) ๊ธฐ๋ณธ ํ˜•ํƒœ

interface FeatureFlagClient {
    fun isEnabled(key: String, userId: String): Boolean
}
 
val client = mock<FeatureFlagClient>()
 
// stubbing: ํŠน์ • ์กฐ๊ฑด์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜
whenever(client.isEnabled(eq("NEW_UI"), eq("u-1"))).thenReturn(true)

stubbing์—์„œ๋„ โ€œmatcher๋ฅผ ํ•˜๋‚˜๋ผ๋„ ์“ฐ๋ฉด ์ „๋ถ€ matcherโ€

์•„๋ž˜๋Š” stubbing์—์„œ ํ”ํžˆ ๋งŒ๋‚˜๋Š” InvalidUseOfMatchersException ํŒจํ„ด์ด๋‹ค. (๊ทœ์น™ #1 ์œ„๋ฐ˜)

// โŒ raw + matcher ํ˜ผ์šฉ
whenever(client.isEnabled("NEW_UI", any())).thenReturn(true)
 
// โœ… ์ „๋ถ€ matcher๋กœ ํ†ต์ผ
whenever(client.isEnabled(eq("NEW_UI"), any())).thenReturn(true)

3.3 null์ด ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ์œผ๋ฉด isNull() ๋˜๋Š” nullable ํ—ˆ์šฉ matcher๋ฅผ ์„ ํƒ

Mockito ๊ด€์ ์—์„œ ๊ฐ€์žฅ ๋ช…ํ™•ํ•œ ์„ ํƒ์€ isNull()์ด๋‹ค.

interface TokenStore {
    fun find(tokenId: String?): String?
}
 
val store = mock<TokenStore>()
 
whenever(store.find(isNull())).thenReturn("guest-token")

doReturn(...).whenever(...)๋Š” ์–ธ์ œ ์“ฐ๋‚˜?

Mockito๋Š” doReturn() | doThrow() | doAnswer() ... ๊ฐ™์€ do-ํŒจ๋ฐ€๋ฆฌ API๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

์‹ค๋ฌด ํŒ(ํŠนํžˆ spy ์ƒํ™ฉ):

  • whenever(spy.method())๋Š” spy์˜ ์‹ค์ œ ๋ฉ”์„œ๋“œ๊ฐ€ ๋จผ์ € ์‹คํ–‰๋  ์ˆ˜ ์žˆ์–ด ์œ„ํ—˜ํ•œ ๊ฒฝ์šฐ๊ฐ€ ์žˆ๋‹ค.
  • ์ด๋Ÿฐ ๊ฒฝ์šฐ doReturn(x).whenever(spy).method(...) ํ˜•ํƒœ๋กœ โ€œ์‹ค์ œ ํ˜ธ์ถœ ์—†์ดโ€ ์Šคํ…์„ ๊ฑธ ์ˆ˜ ์žˆ๋‹ค.
val spyClient = spy(RealClient())
 
doReturn("cached").whenever(spyClient).fetch(eq("key"))

์ž์ฃผ ๋‚˜์˜ค๋Š” ์—๋Ÿฌ ๋กœ๊ทธ๋ฅผ โ€œ๊ทœ์น™โ€์œผ๋กœ ํ™˜์›ํ•˜๊ธฐ

InvalidUseOfMatchersException: X matchers expected, Y recorded

์˜๋ฏธ(๊ฑฐ์˜ ํ•ญ์ƒ): ๊ฐ™์€ ํ˜ธ์ถœ์—์„œ matcher์™€ raw ๊ฐ’์„ ์„ž์—ˆ๋‹ค.
ํ•ด๊ฒฐ: raw ๊ฐ’์„ eq(...)๋กœ ๊ฐ์‹ธ์„œ โ€œ์ „๋ถ€ matcherโ€๋กœ ๋งŒ๋“ค๊ฑฐ๋‚˜, ์ „๋ถ€ raw๋กœ ๋ฐ”๊พผ๋‹ค.

// โŒ
verify(gateway).send(any(), "tenantA")
 
// โœ…
verify(gateway).send(any(), eq("tenantA"))

Argument(s) are different! Wanted ... Actual ...

์˜๋ฏธ: ํ˜ธ์ถœ์€ ์žˆ์—ˆ๋Š”๋ฐ, ์ธ์ž๊ฐ€ ๊ธฐ๋Œ€์™€ ๋‹ค๋ฅด๋‹ค.

์ž์ฃผ ๋‚˜์˜ค๋Š” ์›์ธ:

  • null์ด ์‹ค์ œ๋กœ ๋“ค์–ด์˜ค๋Š”๋ฐ any(Class)/primitive ๊ณ„์—ด๋กœ ๊ฒ€์ฆํ•ด์„œ ์‹คํŒจ
    • ํ•ด๊ฒฐ: null์ด๋ฉด isNull()๋กœ ์˜๋„๋ฅผ ๋ช…ํ™•ํžˆ
verify(store).find(isNull())

Wanted but not invoked (Actually, there were zero interactions with this mock)

์˜๋ฏธ: ํ•ด๋‹น mock์— ๋Œ€ํ•ด ์›ํ•˜๋Š” ํ˜ธ์ถœ ์ž์ฒด๊ฐ€ ์—†์—ˆ๋‹ค.

์šฐ์„ ์ˆœ์œ„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ:

  1. SUT๊ฐ€ ํ•ด๋‹น ๋กœ์ง ๋ถ„๊ธฐ/์ƒํƒœ๋ฅผ ์‹ค์ œ๋กœ ํƒ€๋Š”์ง€(์กฐ๊ฑด/์ž…๋ ฅ/์ƒํƒœ)
  2. stubbing์ด ๋งค์นญ ์‹คํŒจํ•ด์„œ ์ค‘๊ฐ„์— ํ๋ฆ„์ด ๋Š๊ธด ๊ฑด ์•„๋‹Œ์ง€(ํŠนํžˆ matcher ํ˜ผ์šฉ/nullable)
  3. ๋น„๋™๊ธฐ/์ฝ”๋ฃจํ‹ด์ด๋ผ๋ฉด ํ…Œ์ŠคํŠธ ์Šค์ผ€์ค„๋Ÿฌ ๋ฐ– ๋””์ŠคํŒจ์ฒ˜/์Šค์ฝ”ํ”„์—์„œ ์‹คํ–‰ ์ค‘์ธ ๊ฑด ์•„๋‹Œ์ง€
  4. โ€œ๊ฒ€์ฆ ๋Œ€์ƒโ€ mock ์ธ์Šคํ„ด์Šค๊ฐ€ ๋งž๋Š”์ง€(๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค๋ฅผ ์ฃผ์ž…/์ƒ์„ฑํ•œ ๊ฑด ์•„๋‹Œ์ง€)

์ตœ์ข… ์š”์•ฝ(์šด์˜ ๊ทœ์น™ 6์ค„)

  1. matcher๋Š” โ€œ๊ฐ’โ€์ด ์•„๋‹ˆ๋ผ โ€œํŒ์ • ๊ทœ์น™โ€์ด๋‹ค.
  2. matcher๋ฅผ ํ•˜๋‚˜๋ผ๋„ ์“ฐ๋ฉด ๋ชจ๋“  ์ธ์ž๋ฅผ matcher๋กœ ํ†ต์ผํ•œ๋‹ค.
  3. matcher๋Š” verify/stubbing ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. (SUT ํ˜ธ์ถœ์—๋Š” ์‹ค์ œ ๊ฐ’๋งŒ)
  4. ๊ฐ’ ๊ณ ์ •์ด ํ•„์š”ํ•˜๋ฉด eq(value)๋ฅผ ์“ด๋‹ค. (์ •ํ™•ํ•œ ๊ฐ’๋„ matcher๋กœ ํ‘œํ˜„)
  5. null์€ isNull()๋กœ ์˜๋„๋ฅผ ๋ช…ํ™•ํžˆ ํ•œ๋‹ค. (any(Class)/primitive ๊ณ„์—ด matcher๋Š” null ๋ฏธ๋งค์นญ)
  6. spy์—์„œ ์‹ค์ œ ํ˜ธ์ถœ์„ ํ”ผํ•ด์•ผ ํ•˜๋ฉด doReturn(...).whenever(...)๋ฅผ ๊ณ ๋ คํ•œ๋‹ค.

์ฐธ๊ณ (๊ณต์‹)