[TDD] Fake vs Mock (왜 우리는 Fake를 사용해야 하는가?)

일단 Fake와 Mock을 설명하기 전에 Test Double 먼저 짚고 넘어가겠습니다.

Test Double이란?

xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다.


영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 비롯되었다.


예를 들어 우리가 데이터베이스로부터 조회한 값을 연산하는 로직을 구현했다고 하자. 해당 로직을 테스트하기 위해선 항상 데이터베이스의 영향을 받을 것이고, 이는 데이터베이스의 상태에 따라 다른 결과를 유발할 수도 있다.(SideEffect가 생각하자)
또한 테스트임에도 실제 데이터베이스에 값이 쌓일수도 있다는것은 문제가 생긴다.


이렇게 테스트하려는 객체와 연관된 객체를 사용하기가 어렵고 모호할 때 대신해 줄 수 있는 객체를 테스트 더블이라 한다.

TestDouble에서 저희가 주로 사용하는것은 Mock(Stub)과 Fake입니다.

하지만 공식문서에서는 Fake가 선호된다고 하는데 왜 이런 얘기가 나왔을까요?

1. BlackBox vs WhiteBox

우리는 Mock을 보통 화이트 박스 테스트라고 부릅니다.
Test를 짤때, 테스트중인 객체가 어떻게 동작하는지 알고 지시하기 때문입니다.
그래서 Mock은 보통 동작(호출이 되었는지 확인할떄) 테스트를 할때 사용되기 좋습니다.

    @Test
    fun `테스트 MOCK입니다! `() = runTest {
    // 협력객체(insertMemo())를 알아야하고, "OK가 나올지도 알아야한다
   	coEvery{mockRepository.insertMemo()}.return "OK"
    
    memoViewModel = MemoViewmodel(mockRepository)
   
   //getMemo가 mockRepsitory객체에서 어떤 함수를 호출하는지 알아야한다.
    memoViewModel.getMemo() shouldbe "OK"
   
   }

그에 반면에 Fake는 블랙박스 테스트입니다.
Test를 짤때, 어떤 상태만 나오는지 알면 됩니다.
그래서 Fake는 보통 상태를 테스트할때 많이 사용됩니다.

    @Test
    fun `테스트 Fake입니다! `() = runTest {
    
    val fakeRepository = FakeRepository()
    
    memoViewModel = MemoViewmodel(fakeRepository)
   // 우리는 momoViewModel이 어떤 행위를 하려는지 몰라도 되요.
   // Mock은 어떤 행위를 하는지 알아야하는데.. 
    memoViewModel.getMemo() shouldbe "OK"
   
   }

2. 라이브러리가 필요치 않다.

Fake는 라이브러리를 필요로 하지 않습니다.

Interface로 이루어진 추상객체에 임의로 만들어진 추상객체를 꽂아넣기만 하면 되니까요.

반면에 Mock은 Mockito나 Mockk이라는 라이브러리를 통해 이루어 집니다.

3. 이해하기 어렵다.

아래는 Fake를 이용해 작성된 코드입니다.

    private val fakeSuccessDataSourceRecent: FlickrRemoteDataSource =
        FakeSuccessFlickRemoteSourceImpl()
    private val fakeFailedDataSourceRecent: FlickrRemoteDataSource =
        FakeFailedFlickRemoteSourceImpl()

    @Test
    fun `페이징 정상적으로 반환되는가? Fake용`() = runTest {
        val pagingSource = PhotoPagingSourceRecent(fakeSuccessDataSourceRecent)

        pagingSource.load(
            PagingSource.LoadParams.Refresh(
                key = 1,
                loadSize = 2,
                placeholdersEnabled = false
            )
            //kotest Assertion
        ) shouldBeEqualToComparingFields
            PagingSource.LoadResult.Page(
                data = listOf(FakePhoto.testPhoto1.toDomain(), FakePhoto.testPhoto2.toDomain()),
                prevKey = null,
                nextKey = FakePhoto.testPhoto2.id
            )
    }

Fake를 사용할때는 실제 주입하는 코드와 Assertion만 있으면 되기에 매우 깔끔해집니다.

Mock은 어떨까요?

    @Test
    fun `페이징 정상적으로 반환되는가? Mock용`() = runTest {
    //dto 
        val mockSuccess = NetworkResult.Success(
            GetPhotosResponse(
                photos = PhotosResponse(
                    page = 0,
                    pages = 20,
                    perpage = 20,
                    total = 5,
                    photo = listOf(
                        FakePhoto.testPhoto1,
                        FakePhoto.testPhoto2

                    )
                ),
                stat = ""
            )
        )
        
        //mocking
        coEvery { mockRemoteDataSource.getPhotosForRecent(10, 10) } returns mockSuccess
        
        //기존과 똑같은 Assertion
        val pagingSource = PhotoPagingSourceRecent(mockRemoteDataSource)
        pagingSource.load(
            PagingSource.LoadParams.Refresh(
                key = 1,
                loadSize = 2,
                placeholdersEnabled = false
            )
        ) shouldBeEqualToComparingFields
            PagingSource.LoadResult.Page(
                data = listOf(FakePhoto.testPhoto1.toDomain(), FakePhoto.testPhoto2.toDomain()),
                prevKey = null,
                nextKey = FakePhoto.testPhoto2.id
            )
            // mock이 제대로 사용되었는지 검증
        coVerify { mockRemoteDataSource.getPhotosForRecent(10, 10) }
    }

Dto는 공통 object에 둔다 하더라도, 협력객체가 어떻게 행동해야하는지 명시해야하기때문에 테스트 코드를 이해하기 어렵게 만듭니다.

3. 잘못 Mock을 할 우려가 있다.

    @Test
    fun `테스트 MOCK입니다! `() = runTest {
   	coEvery{mockRepository.insertMemo()}.return "OK"
    
    memoViewModel = MemoViewmodel(mockRepository)
    
    memoViewModel.getMemo() shouldbe "OK"
   
   }

위의 코드는 정상적으로 작동할까요?

아닙니다. no answer found for 이라는 MockException이 떨어지는데, 협력객체를 잘못 mock했기때문에 발생한 일입니다. 이런 테스트코드가 수백개가 된다면 좀 많이 골치 아파질것 같은데요.
만약 Fake였다면 이런 위험은 존재하지 않을것 같습니다.

4. 그렇다면 과면 Mock은 사용하면 안되는것일까?

Fake가 상태를 검증한다면, Mock은 행위를 검증한다고 했습니다.
Verify을 통해, 해당 메소드가 사용되는지 아닌지 확인 가능합니다.

	//Mock을 이용해 가상객체 사용
	coEvery { mockRemoteDataSource.getPhotosForRecent(10, 10) }
	
    //해당 객체의 함수가 사용되었는지 검증이 가능하다.
    coVerify { mockRemoteDataSource.getPhotosForRecent(10, 10) }

제어할 수 없는 외부 라이브러리가 해당 함수를 사용하는지 안하는지 검증할때 Mock을 사용하기 좋습니다.


참고

모의 객체(Mock) 대신 페이크 객체(Fake)를 사용하라

Test Double을 알아보자

Mock 객체 남용은 테스트 코드를 망친다.

블랙박스 테스트, 화이트박스 테스트

모의는 실용적이지 않습니다. 가짜를 사용하세요.

profile
쉽게 가르칠수 있도록 노력하자

0개의 댓글