테스트 코드 한 줄을 작성하기까지의 고난

– 이 글에서 설명한 내용은  if(kakao)2021 에서 보실 수 있습니다.

안녕하세요. 창작자앱개발파트의 Ronda입니다. 창작자 앱 개발파트에서 브런치와 티스토리 안드로이드 앱을 개발하고 있습니다.

여기에서는 안드로이드 앱을 개발하면서 테스트 환경 구축 중에 경험하고 고민했던 것들 위주로 말씀드리려고 합니다. 먼저, 테스트를 해야 하는 이유, 현재 브런치와 티스토리의 아키텍처, 테스트를 하기 위한 준비 사항, 그리고 테스트를 측정하는 방법에 대해 이야기하겠습니다. 

 


 

테스트를 해야 하는 이유

 

원론적인 이야기를 아주 짧게 해 보면, 테스트를 해야 하는 이유는 다음과 같이 정말 많습니다.

  • 개발 과정에서 문제를 미리 발견할 수 있다. 
  • 리팩토링을 안심하고 할 수 있다. 
  • 빠른 시간 내에 코드의 동작 방식과 결과를 확인할 수 있다. 
  • 좋은 테스트 코드를 연습하다 보면 자연스럽게 좋은 코드가 만들어진다. 
  • 의도한 대로 동작되는 것을 자신감 있게 말할 수 있다.

 

이 외에도, 중요한 이유로 이야기할 수 있는 것이, “애자일 방법론의 도입”입니다.
엉클 밥이라고 불리우는 애자일계의 대부 로버트 C. 마틴은 자신의 저서 중 “Clean Agile”에서,

“애자일의 기술 실천 방법은 모든 애자일 활동 중 가장 핵심적인 요소다.
기술 실천 방법 없이 애자일을 도입하려는 시도는 실패할 수밖에 없다.”

라고 말하고 있습니다.

참고 : 로버트 C. 마틴이 말하는 기술 실천 방법은 다음과 같습니다. 

– TDD(Test-Driven Development)
– TDD 과정에서의 리팩터링
– 단순한 설계
– 짝 프로그래밍

 

저희 팀은 나름 애자일에 대해서 스터디도 했고, 각자가 애자일에 대한 철학을 가지고 애자일스럽게 일한다고 생각해 왔었습니다. 그런데, “단순한 설계”를 위한 노력이나 “짝이나 몹 프로그래밍”을 통해서 기술 실천 방법을 행하고 있는 듯했지만, TDD는 고사하고 테스트 한 줄 작성을 하고 있지 않았습니다.

이에, 저희 팀은 애자일을 실천하고자, 테스트 시작을 위한 준비 작업에 들어갔고, 현재는 테스트 작성을 조금씩 하고 있습니다. 이 테스트 시도에 대한 이야기를 해보고 싶습니다.

 

우리 서비스의 아키텍처

 

테스트를 이야기하다 보면 무조건 따라나오는 것 중 하나가, 아키텍처에 대한 내용입니다. 그래서, 현재 브런치와 티스토리에 적용되어 있는 아키텍처를 간략하게 적어 보겠습니다.

  • Clean Architecture 
  • MVVM 
  • Multi Module 
  • 그리고 Coroutines, Hilt

 

브런치와 티스토리는 클린 아키텍처와 구글이 제시한 MVVM, 그리고 멀티 모듈로 구성되어 있습니다. 그리고, 아키텍처와는 조금 다른 이야기지만 Rx에서 Coroutines로 변경했고, DI(Dependency Injection)에는 Hilt를 사용하고 있습니다.

과거에는 app 모듈 밑에 presentation, domain, data를 모두 패키지로 구분해서 구성했었는데, 현재는 App, Presentation, Domain, Data를 모듈로 구성해서 사용하고 있습니다. 

과거 현재
App(모듈)
– Presentation
– Domain
– Data
App
Presentation
Domain
Data

각각의 모듈의 구성에 대해 조금 더 자세히 살펴보겠습니다.

App
– DI
– Application Class
Presentation
– View
– ViewModel
Domain
– Entity
– Repository Interface
– Use Case
Data
– Repository
– DB
– Service

App 모듈은 DI와 Application Class, Presentation 모듈은 View와 ViewModel, Domain 모듈은 Entity와 Repository Interface 그리고 Use Case, Data 모듈은 Repository와 Local, Remote로 구성되어 있습니다.

나름 잘 구성된 아키텍처이지만, 테스트 코드가 없었습니다.

저희 팀은 테스트 도입과 무엇을 얼마나 테스트했는지 파악하는 것을 올해의 목표로 잡았습니다.

 

테스트를 하기 위한 준비

 

테스트 코드 한 줄 작성을 하기 위해서 제가 겪었던 에러들과 미리 만들어야 했던 것들에 대해서 이야기해 보겠습니다. 

다음과 같이 총 4가지 정도로 나눌 수 있을 것 같습니다.

  • ViewModel Test 
  • Coroutines Test 
  •  JUnit5에서 변경점 
  • 빨간 막대에서 초록 막대를 보기까지 걸린 시간

 

ViewModel Test:  LiveData 변경에 대한 테스트

먼저 ViewModel 테스트에 대해서 이야기해 보겠습니다.

ViewModel에서는 두 가지 정도의 케이스에 대해 이야기해 볼 수 있을 것 같습니다. 첫 번째로 ViewModel에서 LiveData의 변경에 대한 테스트, 두 번째로 비동기 작업에 대한 테스트입니다. 

비동기 작업에 대한 테스트는 뒤의 Coroutines Test에서 살펴보기로 하고, LiveData의 변경에 대한 테스트를 살펴 보겠습니다.

아래의 왼쪽 코드를 보면, SomethingViewModel에서 하나의 메소드가 LiveData를 변경시키고 있습니다. 그리고 오른쪽 테스트를 보면, ViewModel의 메소드를 실행시켰을 때 LiveData의 값이 제대로 변경되었는지를 확인하고 있습니다.

이 테스트 코드를 실행시켜보면, 첫술에 배부를 수 없듯, 테스트는 메인(main) 스레드가 아닌 다른 스레드에서 실행되기 때문에 실패하게 됩니다.

다시 돌아와서 LiveData의 setValue의 내부를 보면 assertMainThread라는 메소드를 호출하고 있고 assertMainThread는 ArchTaskExecutor의 isMainThread 메소드를 이용해서 메인 스레드가 아니면 예외(Exception)를 발생시키는 것을 볼 수 있습니다.

마찬가지로 postValue도 메인 루퍼를 이용해서 메인 스레드로 값을 보내고 있기 때문에, setValue와 postValue 둘 다 메인 스레드가 필요하게 됩니다.

앞에서 본 것처럼 테스트는 다른 스레드에서 실행되기 때문에, 한 가지 룰을 추가해야 합니다.

기본적으로 제공하는 룰 중에 InstantTaskExecutorRule이라는 게 있습니다. 이 룰의 내부를 보면 setValue에서 보았던 ArchTaskExecutor가 새로운 TaskExecutor를 생성하면서 isMainThread 메소드를 강제로 true로 리턴하고 있습니다.

다시 테스트로 돌아와서 룰을 적용하고 테스트를 실행하면 성공하게 됩니다.

 

 

Coroutines Test

다음으로 Coroutines 테스트에서는 viewModelscope.launch에서의 테스트와 다른 Dispatcher가 있을 때의 테스트에 대해 이야기해 보겠습니다.

 

viewModelScope.launch에서의 테스트

앞에서 본 ViewModel과 달라진 점은 메소드에 viewModelScope.launch가 들어갔다는 점입니다. 동일하게 오른쪽의 테스트를 실행시켜 보면 실패하게 됩니다. 

viewModelScope.launch는 기본적으로 다른 Dispatcher가 적용되지 않으면 메인 스레드를 사용합니다. 그래서 LiveData 때와 마찬가지로 테스트는 다른 스레드에서 실행되기 때문에 실패하게 됩니다.

여기서 또 한 가지 정보를 얻을 수 있는데, 에러를 자세히 보게 되면, kotlinx-coroutines-test module에 있는 Dispatchers.setMain을 이용해 테스트를 하라고 친절히 알려주고 있습니다.

위에서 작성한 코드의 흐름도를 보면, 파란색 줄의 테스트 스레드는 계속 흐르고 있고, viewModelScope.launch에서 메인 스레드의 노란색 줄이 생겨, 각자 다른 스레드에서 동작하는 것을 알 수 있습니다.

그럼, 앞에서 나온 에러의 힌트를 이용해서 kotlinx-coroutines-test의 TestCoroutineDispatcher를 사용해 보겠습니다.

먼저 testDispatcher를 하나 만들어 줍니다. @Before에서 메인을 사용하는 Dispatcher를 testDispatcher로 주입하여 변경하는 작업을 하고, 테스트가 끝난 후 @After에서는 testDispatcher로 변경한 작업을 해제해 주고 있습니다.

testDispatcher 작업을 하기 전 흐름도는, setMain을 이용해서 testDispatcher 를 주입한 후 다음과 같이 변합니다. 

즉, viewModelScope.launch가 테스트 스레드에서 도는 것을 확인할 수 있습니다.

모든 테스트에 @Before와 @After를 만들면 귀찮으니 룰을 하나 만들어 보겠습니다. 

MainCoroutineRule 클래스를 생성하고 앞에서 본 InstantTaskExecutorRule과 동일하게 TestWatcher를 상속받습니다.

starting에서 메인(main) 스레드를 사용하는 Dispatcher를 testDispatcher로 변경해 주고, finished도 마찬가지로 resetMain을 똑같이 해 주면 룰이 완성됩니다. 마지막의 runBlockingTest라는 메소드는 테스트에서 testDispatcher의 runBlockingTest를 좀 더 간편하게 쓰기 위해 만들어놓은 메소드입니다.

테스트로 돌아와서 룰을 달아주면 끝이 납니다.

 

다른 Dispatcher가 있을 때의 테스트

다음으로, viewModelScope.launch에 다른 Dispatcher가 들어 있다고 가정해 보겠습니다. 마찬가지로 Use Case나 다른 곳에서 withContext로 Dispatcher를 변경했을 때도 동일한 경우입니다.

앞에서 만든 룰은 메인을 testDispatcher로 사용할 수 있게 해주는 것이었습니다. 이제, 또 다른 Dispatcher가 들어가게 되면 기다리지 않고 파란색 테스트 스레드가 흐르고, 또 다른 Dispatcher가 노란색으로 빠져나와 흘러 버려서 테스트는 실패하게 됩니다.

그래서 Dispatcher를 사용하는 곳에서 Dispatcher를 주입받을 수 있게 만들고 launch에서 주입받은 Dispatcher를 설정해 주면 됩니다.

Dispatcher를 주입하는 방법에는 여러 가지가 있지만, 여기에서는 DispatcherProvider라는 인터페이스를 만들고, TestDispatcherProvider라는 클래스를 만들어서 어떤 Dispatcher를 호출하던 testDispatcher를 사용하게 만들었고, 마찬가지로 DefaultDispatcherProvider 클래스를 만들어서 네이밍에 맞게 Dispatcher를 사용하도록 구현하고 있습니다.

테스트로 돌아와서 앞에서 본 DispatcherProvider를 구현한 TestDispatcherProvider 구현체를 SomethingViewModel에 주입해 주면, 어떤 dispatcher를 호출하던 testDispatcher가 호출되게 됩니다.

테스트를 다시 실행시키면 같은 곳에서 실행이 가능해집니다.

다음의 그림에서 왼쪽은 앞에서 봤던 다른 Dispatcher를 넣었을 때의 흐름도이고,  오른쪽은 Dispatcher를 주입받을 수 있게 만들고 테스트에서 testDispatcher를 주입받아 실행한 이미지입니다. 테스트가 성공한 것을 확인할 수 있습니다.

또 다른 케이스를 하나 보겠습니다.

ViewModel의 somethingMethod에서 launch가 실행되기 전에 progressEvent를 true로 변경해 UI에서 progress가 실행되게 만들었고, launch에서 특정 작업이 끝난 후 progressEvent를 false로 변경해 progress를 종료하게 만들었습니다.

이제 progressEvent에 대해서 테스트를 해보면, 첫 번째 assert에 대한 기댓값은 true인데 progressEvent가 false 나와 실패하게 됩니다.

이유는, ViewModel의 somethingMethod를 실행시키면 첫 번째 assert를 확인하기 전에 launch까지 다 끝나버려서 progressEvent는 true로 변경되었다가 다시  false로 변경되게 되고, launch가 끝난 후 첫 번째 assert를 확인하게 되면 true를 기대했지만 progressEvent는 당연히 false를 가지게 되어 실패하는 것입니다.

우리가 알고 싶은 progressEvent의 변경 타이밍은, somethingMethod에서 launch가 실행되기 전  첫 번째 progressEvent의 변경, 그리고 launch에서 특정 작업이 끝이 난 후 두 번째 progressEvent의 변경이 되겠죠.

이런 타이밍을 제어하기 위해, 앞에서 만들고 사용했던 runBlockingTest에 있는 pauseDispatcher와 resumeDispatcher를 사용할 수 있습니다. 

somethingMethod를 실행하기 전에 pauseDispatcher를 실행시켜, testDispatcher를 사용하고 있는 viewmodelscope.launch를 정지시키고, ViewModel의 somethingMethod를 실행시키면 오른쪽 이미지와 같이 progressEvent는 true로 변경되지만 launch는 정지 상태가 됩니다. 이때 첫 번째 assert에서 progresEvent가 true인 것을 확인할 수 있습니다. 

다시 resumeDispatcher를 실행시켜 정지되어 있던 testDispatcher를 실행하게 되면, somethingMethod의 launch가 실행되게 되고, 특정 작업이 끝난 후 progressEvent를 false로 변경하게 됩니다. 따라서, 두 번째 assert에서 progressEvent가 false가 나온 것을 확인할 수 있습니다.

 

 

JUnit5에서의 변경점

다음으로는 JUnit5에서의 변경점에 대한 내용입니다. 저희 팀은 테스트를 도입하면서 JUnit4 대신 JUnit5를 적용했습니다.

여러 가지 변경점이 있겠지만, 그중에서도 Rule이 Extension으로 대체되었고, 앞서 적용했던 InstantTaskExecutorRule과 만들었던 MainCoroutineRule을 사용할 수 없게 되어 변경이 필요했습니다.

다음의 왼쪽 이미지는 기존에 제공되었던 TestWatcher를 상속받은 InstantTaskExecutorRule이고 오른쪽 이미지는 BeforeEachCallback과 AfterEachCallback을 구현해 직접 만든 Extension 클래스입니다.

BeforeEachCallback과 AfterEachCallback은 각각 Extension을 상속받고 있고, 나머지 코드들은 starting 메소드는 beforeEach로 finished는 afterEach로 그대로 이동한 것을 볼 수 있습니다.

다시 테스트로 돌아와서 기존에 적용했던 Rule은 제거하고, ExtendWith 어노테이션에 직접 만든 InstantTaskExecutorExtension을 넣습니다.

현재 보이는 코드는 mainCoroutineRule이 있어서 테스트가 실패하기 때문에, 마저 변경해 보겠습니다.

앞에서 변경한 InstantTaskExecutorExtension과 동일하게, 기존에 만들었던 왼쪽 이미지의 MainCoroutineRule을 오른쪽 이미지와 같이 beforeEachCallback과 afterEachCallback을 구현한  MainCoroutineExtension으로 변경해 줍니다. 마찬가지로 나머지 메소드들은 동일한 내용으로 복사를 해주면 됩니다.

MainCoroutineExtension을 다른 Extension 들과 같이 ExtendWith에 넣지 않고 RegisterExtension 어노테이션을 달아 개별로 등록해 준 이유는, 기존에 테스트 코드에서 runBlockingTest나 다른 dispatcher가 필요한 상황에서 testDispatcher를 사용할 수 있게 하기 위해서입니다. 

사용하고 있던 두 개의 룰은 위와 같이 변경을 완료했습니다. 혹시나 다른 룰을 사용하고 있으시다면 동일한 방법으로 마이그레이션하시면 JUnit5에서도 사용이 가능합니다.

 

UnnecessaryStubbingException 에러

이번에는 변경점이라기보단, 다음의 이미지와 같은 에러를 만나게 되었는데 에러 내용은 제목 그대로 불필요한 스텁(stub)이 존재한다는 내용이었습니다. 

저의 경우에는 몇몇 테스트 케이스에서 동일하게 사용하던 stubbing 코드를 @Before에 넣어 뒀기 때문에 그 stubbing 코드를 사용하지 않는 케이스가 있어서 에러가 발생했습니다. 마찬가지로 하나의 테스트 케이스에서 불필요한 스텁이 존재할 때도 에러가 발생하게 됩니다.

간단한 예시를 보면 기존과 동일한 테스트 코드에서 viewmodel에서는 따로 repository를 사용하고 있지 않지만 코드에서 불필요한 repository를 스터빙하고 있는 것을 볼 수 있습니다.

이런 에러를 회피하기 위해서 여러 방법들이 있는데, 스터빙을 할 때 when 앞에 lenient를 사용할 수 있고 RunWith에 mockito runner를 설정할 때 Silent를 이용할 수도 있습니다. JUnit5에서는 mockito jupiter의 MockitoSettings를 이용하여 해결할 수 있습니다.

하지만, 사실 이러한 회피는 좋은 방법이나 해결책이 아닙니다. 

불필요하게 일어나는 스터빙은 비슷한 테스트에서 복사/붙여넣기를 하거나 지워야 하는 것을 깜빡하고 넘어가는 경우가 대부분입니다. 

이러한 코드들은 테스트 코드를 더럽힐뿐더러 테스트 코드를 보고 어떤 것을 추론할 때 추론을 어렵게 하거나 잘못된 정보를 제공할 수도 있습니다. 따라서 불필요한 스터빙은 수정하거나 제거를 통해서 해결하는 것이 좋습니다.

 

빨간 막대에서 초록 막대를 보기까지 걸린 시간

다음으로 위에서 언급했던 내용들을 공부하고 도입하기까지 걸린 시간에 대해서 이야기해 보겠습니다.

그전에 잠깐 조금 다른 내용으로 빠져보면 테스트를 하기 위해서 테스트가 가능하거나 혹은 좀 더 편하게 할 수 있는 코드가 준비되어 있다면, 생각보다 첫 초록 막대는 빨리 볼 수 있을 거라고 생각합니다. 

예를 들어, 브런치는 2018년도부터 조금씩 MVVM 전환을 시작했고 2020년에 마무리했으며, 동시에 Clean Architecture와 Hilt를 도입했습니다.

작년 한 해 많은 일들이 있었는데 테스트와는 상관없지만 Java to Kotlin, Rx to Coroutines 그리고 Multi mModule과 같이 코드를 리팩토링하면서 테스트 가능한 코드들로 조금씩 수정해 나갔습니다. 그리고 올해 테스트와 더불어 Compose도 추가해서 진행하고 있습니다.

본론으로 돌아와서, ViewModel Test, Coroutines Test 그리고 JUnit5에 대해서 공부하고, 라이트닝 토크(Lightning Talk)까지 소요한 시간은 대략 3주가 걸렸고, Mockito에 어떤 것들이 있는지 몰라서 마찬가지로 공부하고 라이트닝 토크까지 소요한 시간이 대략 1주 정도 걸렸습니다. 

이 기간은 하루에 몇 시간씩 조금씩 투자해서 그 시간들을 집약했을 때 대략 이 정도 걸렸다고 추산했고, 적용까지 다한 실제 날짜로 따지면 3달은 걸린 것 같습니다.

처음에는 이것저것 공부하고 머리로는 “이렇게 저렇게 해야지”라고 이론적인 부분에서는 정리가 된 듯했지만 실제로 코드를 짜는 순간, “그래서 무엇을 테스트해야 되지?” “언제 어떻게 측정하지?”라는 물음표가 생기기 시작했습니다.

 

테스트 측정: 무엇을 언제 어떻게?

 

그래서 “무엇을 테스트 할 것인가”, 그리고 “테스트의 결과물을 어떻게 산출할 것인가”에 대한 논의가 있었고, 결론부터 말씀드리면 브런치와 티스토리는 ViewModel만 우선 테스트하기로 했습니다.

이유를 보면, 첫 번째로 팀에서 UI 테스트도 스터디하고 나름 준비를 해 봤지만 굉장히 까다롭고 어렵습니다. 그래서 UI의 상태를 가지고 있는 ViewModel을 테스트하기로 했습니다.

두 번째로 각 레이어 사이에 매퍼(Mapper)를 아직 제대로 적용하지 않아서 데이터 레이어의 테스트는 미뤄놓은 상태입니다. 

마지막으로 가장 큰 이유이기도 하지만, 모든 레이어를 다하기엔 너무 많습니다.

무슨 레이어를 테스트할지는 정해졌고, 이제 테스트한 결과를 어떻게 도출해 낼 것인가를 보면, Jacoco라는 플러그인을 이용해서 ViewModel Class만 뽑아 커버리지를 확인하고 있습니다.

다음으로 측정의 시기를 보면 브런치와 티스토리 두 서비스 다, 카카오 사내의 모바일앱 빌드 배포 시스템인 MoBil에서 Release 빌드 시 자동으로 테스트 후 커버리지를 측정하고, QA를 위한 apk 파일, 마켓 배포를 위한 aab 파일, 그리고 커버리지 압축 파일을 생성하고 있습니다.

그리고 Wiki에 매 Release 버전 별 커버리지 정보를 기록하고 있습니다.

 

마치며

 

마지막으로 결론입니다. 세 가지 정도로 요약할 수 있을 것 같습니다.

첫 번째로, 테스트를 시도할 수 있었던 배경에는 천천히 그리고 급하지 않게 레거시를 꾸준히 제거해 왔고 본인이 하고 싶은 것에 대해서 샘플 작성, 라이트닝 토크, 그리고 실제 적용을 위해 매일 짧은 시간을 활용해서 몹 프로그래밍을 계속 실천해 왔던 게 가장 큰 이유인 것 같습니다

두 번째로, 현재는 커버리지가 11%로 매번 기록은 하고 있지만 따로 목표 커버리지는 정하지 않았습니다. 단순한 목표 커버리지보다 배포 시마다 조금씩 성장해 있는 것에 더 큰 의미를 두고 진행하고 있습니다. 그리고 미래에는 Domain과 Data 레이어까지 테스트하는 게 목표입니다.

마지막으로, 앞으로 계속 시행착오는 겪겠지만 아주 간단하게라도 이렇게 테스트를 시작할 수 있어서 “테스트를 시작해야 하는데..”라는 마음의 짐이나 “어떻게 시작하지”라는 막연한 두려움으로부터 자유로워진 것 같습니다.

아직 테스트 작성을 시작하지 않으셨다면 저처럼 아주 간단한 것부터 시작해서, 테스트에 대한 막연한 두려움이나 마음의 짐으로부터 자유로워졌으면 좋겠습니다. 

감사합니다.

 



👉 if(kakao)2021 해당 세션 보러가기

 

카카오톡 공유 보내기 버튼

Latest Posts

제5회 Kakao Tech Meet에 초대합니다!

Kakao Tech Meet #5 트렌드와 경험 및 노하우를 자주, 지속적으로 공유하며 개발자 여러분과 함께 성장을 도모하고 긴밀한 네트워크를 형성하고자 합니다.  다섯 번째

테크밋 다시 달릴 준비!

(TMI: 이 글의 썸네일 이미지는 ChatGPT와 DALL・E로 제작했습니다. 🙂) 안녕하세요, Kakao Tech Meet(이하 테크밋)을 함께 만들어가는 슈크림입니다. 작년 5월에 테크밋을 처음 시작하고,