테스트 코드, 안드로이드에서는 어떻게 작성해야 할까?

테스트 코드, 안드로이드에서는 어떻게 작성해야 할까?

모바일 애플리케이션 개발자들 사이에서 테스트 코드가 뜨거운 감자 🔥🥔🔥 인 것은 반박 불가능한 사실입니다. 안드로이드 애플리케이션을 개발할 때 테스트 코드는 어떤 코드에 대해서 얼마나 작성되어야 할까요? 사용자의 입력에 따라 원하는 행동을 하고, 원하는 뷰가 노출되는지를 확인하는 것 정도까지가 테스트 코드로 검증해야 하는 영역이라 생각할 수도 있고, HTTP API 통신 후 응답 값의 성공 및 실패 여부에 따른 값을 올바르게 처리하는지 확인하는 것까지가 테스트 코드가 검증해야 할 영역이라고 생각할 수도 있을 거예요. 테스트 코드 없이 작성된 과거 코드를 변경하게 될 때 테스트 코드를 작성해야 할지, 말지에 대한 고민도 다들 한 번씩은 해 보셨을 테고요. 서비스의 규모와 구조에 따라 다양한 답이 존재하겠지만, MVP 패턴Clean Architecture를 사용하는 뱅크샐러드 안드로이드 팀은 다음과 같은 코드에 대해서는 테스트 코드가 작성되어야 한다고 생각했어요.

테스트 코드 작성 범위

  1. 수정/변경되는 모든 기능에 대해 반드시 테스트 코드를 작성한다.
  2. **Presenting logic(MVP 구조 중 Presenter가 담당하는 역할)**에 대한 검증은 필수적이며, View에 대한 테스트를 꼭 작성할 필요는 없다.
    1. 안드로이드 프레임워크나 UI 관련한 테스트 코드는 작성 및 실행에 있어 상대적으로 어려움과 번거로움이 존재한다고 판단
    2. MVP에서 View를 아주 수동적으로 작성할 경우 View에 대한 테스트 코드를 작성하는 게 큰 효용이 없음
  3. (어쩔 수 없이) View에 Presenting logic이 포함되는 경우에는 아래의 방법으로 테스트 코드를 작성한다.
    1. Instrumented test를 작성한다.
    2. View로부터 Presenter로 presenting 로직을 분리하는 리팩토링을 수행하며, 이에 대한 Presenter 테스트 코드를 작성한다.
  4. 관리되지 않던 기존 코드에 대한 테스트 코드
    1. 위와 같은 코드에 대한 변경이 있는 경우, 해당 변경으로 인해 영향받는 코드에 대한 테스트 코드는 필수적으로 작성한다.
    2. 이 코드가 3번 케이스에 해당하는 경우, 3-1, 3-2의 방법을 따른다.

수정/변경되는 모든 기능

수정/변경되는 모든 기능이란 어느 범위까지를 지칭할까요? 뱅크샐러드 안드로이드 팀에서는 새로운 기능 배포로 새로운 파일이 추가된 경우, 기존 파일에 일부 동작이 추가된 경우, 코드를 수정함으로써 기존 동작과 다른 동작이 되는 경우를 모두 추가되는 모든 기능 영역이라 보았어요. 즉, 변경된 코드가 한 줄이더라도 이로 인해 마땅히 수행할 기능이 달라졌다면 테스트 코드를 필수적으로 작성하는 것이죠.

서비스 장애를 최소화 하기 위해서, 클라이언트 개발자가 가장 적극적으로 할 수 있는 일은 바로 자동화된 테스트 작성입니다. 배포 주기가 빨랐던 우리 서비스는 더욱 위험한 상태였고, 실제로 테스트 코드가 없던 부분에서 장애가 나서 핫픽스-핫픽스의 핫픽스를 쳐야 했던 경험도 더러 있었어요. 이러한 위험을 통해 안드로이드 팀은 우리가 지킬 수 있는 최소한이자 최대한인 테스트 코드에 대해 날카롭게 견제해 주는 문화를 가지게 되었답니다. 테스트 코드를 필수적으로 작성하자는 원칙을 명문화하고 지켜나가기 시작한 지는 아직 얼마 되지 않았지만, 안드로이드 팀은 위 규칙을 철저하게 지키고 있어요.

안드로이드 팀 내 코드 리뷰 중 테스트 코드 추가를 요청하는 의견과 대답

(실제 위 코드는 에러 상황에 사용자에게 보여줄 문구를 하나 추가하는 수준의 작은 PR이었어요.)

Presenting Logic에 대한 검증

안드로이드 팀은 Clean Architecture가 적용된 MVP 구조를 따르고 있고, 이에 따라 Presenter의 presenting logic에 관한 단위 테스트는 필수적으로 작성하도록 했어요. 이 테스트는 이름처럼 사용자의 요청에 따라 올바른 presenting logic의 수행 결과가 View에 올바르게 반영되는가를 검증합니다. 아래와 같은 요구 사항을 가진 간단한 회원가입 화면을 예로 들어 안드로이드 팀이 작성하고 있는 Presenter 테스트 코드를 살짝 보여드릴게요. 😉 (예시로 보여드릴 테스트 코드에는 Mockito 라이브러리를 사용합니다. Mockito에 대한 자세한 내용은 공식 사이트를 참고해 주세요.)

1. 사용자가 입력한 이메일이 유효한 형식에 부합할 경우 올바른 이메일이라는 TextView가 노출된다.
2. 비밀번호와 비밀번호 확인의 입력값이 동일하며, 해당 입력값이 네 글자라면 비밀번호가 규칙에 맞다는 TextView가 노출된다.
3. 이메일과 비밀번호가 모두 유효할 경우 회원가입 버튼이 활성화된다.
4. 활성화된 회원가입 버튼 클릭 시 회원가입이 되었다는 안내 Toast를 보여준다.
Presenter 테스트 코드에 대한 회원가입 예제 이미지

1. 초기화 작업

class SignUpPresenterTest {

    @Mock
    lateinit var view: SignUpView // 뷰 인터페이스
    @Mock
    lateinit var signUp: SignUp // 유즈케이스

    private lateinit var presenter: SignUpPresenter

    @Before
    fun initialize() {
        presenter = SignUpPresenter(
            view,
            signUp
        )
    }
    
    ...
}
  1. view 인터페이스를 @Mock으로 mocking합니다.
  2. 다른 의존성 주입이 필요한 경우, 해당 의존성을 @Mock으로 mocking합니다.
  3. @Before 어노테이션이 달린 initialize()에서 presenter를 초기화합니다.

2. 뷰 함수 호출을 통해 Presenting Logic 검증

@Test
fun onSignUpButtonClicked() {
    val email = "[email protected]"
    val password = "1234"
    
    Mockito.`when`(signUp).thenReturn(Mockito.any())
    
    presenter.onSignUpClicked(email, password)
    
    Mockito.verify(view).showSignUpSucceedToast()
}

@Test
fun `이메일이 규칙에 맞고, 비밀번호와 비밀번호 확인이 같으며, 비밀번호의 글자가 네 글자일 시 버튼이 활성화된다`() {
    val inOrder = Mockito.inOrder(view)
    val email = "[email protected]"
    val password = "1234"
    val passwordConfirm = "1234"
    
    presenter.validateEmail(email)
    presenter.validateConfirmPassword(password, passwordConfirm)
    
    // 뷰 함수가 순서에 맞게 불리는 것을 검증
    inOrder.verify(view).showEmailConfirmedTextView()
    inOrder.verify(view).showPasswordConfirmedTextView()
    inOrder.verify(view).enableSignUpButton()
  
    // 함수가 불리지 않는 것을 검증
    verify(view, Mockito.never()).disableSignUpButton()
}
  1. 유즈케이스를 호출하는 함수를 테스트할 경우, 유즈케이스 호출을 위해 Mockito.when() 함수를 사용합니다. 이를 통해 presenter에서는 유즈케이스의 비즈니스 로직을 알 필요 없이 presenting logic을 테스트할 수 있습니다.
  2. 테스트하고 싶은 Presenter의 함수를 호출합니다.
  3. Mockito.verify(view).호출_되어야_할_함수() 호출을 통해 해당 뷰 함수가 호출되었는지 검증하고, 이를 통해 Presenting logic을 검증합니다.
  4. 뷰 함수가 순서대로 불리는 것을 검증하고 싶은 경우에는 Mockito.inOrder(view)를 통해 순서에 맞게 화면에 나타내는지 검증합니다.
  5. 뷰 함수가 불리지 않는 것을 검증하고 싶은 경우에는 Mockito.verify(view, Mockito.never()).뷰_함수()로 함수가 불리지 않는 것을 검증할 수 있으며, 여러 번 불리는 것을 검증하고 싶은 경우에는 Mockito.verify(view, Mockito.times()).뷰_함수()를 통해 해당 뷰 함수가 몇 번 불리는지 검증할 수 있습니다.
  6. 테스트 케이스가 영문보다 한글로 작성하는 것이 가독성에 더 좋을 경우, 해당 테스트 함수명은 한글로 작성해도 무방합니다.

추가되는 모든 기능 영역에 대해 필수적으로 가져가고 있는 Presenter Unit Test 코드만 포함하더라도 대부분의 영역에 대한 테스트 커버가 가능하고, 실제로 커버리지를 점점 높이고 있어요. 하지만, 이러한 규칙만으로도 안드로이드 팀은 *“테스트 코드 있었으니까 우리 코드에는 문제 없어요!”*라고 말할 수 있을까요? 테스트 코드로 커버하기 어려운 영역도 있지 않을까요?

안드로이드 테스트 코드의 사각지대

안드로이드 유닛 테스트 코드 내에서는 android 패키지의 함수에 접근하지 못해 사각지대에 놓이게 되는 영역들이 있습니다. (관련 문서) 뱅크샐러드의 경우 ViewHolder 관련 코드가 그러한 영역에 포함되었는데요. 생성 시 android.view.View가 필요해 RecyclerView의 Presenting logic은 테스트 하기 어려운 환경이었습니다.

class SaladViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    val textView = view.findViewById<TextView>(R.id.textView)

    fun bind(salad: Salad) {
        if (salad.type == "뱅크") // 이 코드 블럭의 presenting logic은 테스트 코드로 빼낼 수 없을까요?
            textView.text = "뱅크샐러드"
        else
            textView.text = "그냥샐러드"
    }
}

이렇게 뷰 로직에 비즈니스 로직이 포함되어 있는 영역의 코드는 결코 테스트할 수 없는 영역일까요? 뱅크샐러드 안드로이드 팀에서는 이런 상황에는 아래와 같은 두 가지 방법으로 테스트 코드를 작성하도록 제안합니다.

1. Instumented Test를 작성

@RunWith(AndroidJUnit4::class)
class SaladViewHolderTest {
    lateinit var viewHolder: SaladViewHolder
    ...
    
    @Before
    fun initialize() {
        val view = LayoutInflater.from(InstrumentationRegistry.getInstrumentation().targetContext).inflate(R.layout.item_salad, null)
        viewHolder = SaladViewHolder(view)
    }

    @Test
    fun bind() {
        val salad = Salad(type = "뱅크")
        viewHolder.bind(salad)

        Assert.assertEquals(
            "뱅크샐러드",
            viewHolder.textView.text
        )
    }
    ...
}

Presenting logic을 검증하고 싶으나 android 시스템에 의존성을 가지는 경우, 위와 같은 Instrumented Test를 통해 android 시스템에 접근해 Presenting logic을 검증합니다.

  1. @RunWith(AndroidJUnit4::class) 를 통해 Runner를 명시적으로 지정합니다.
  2. InstrumentationRegistry.getInstrumentation().targetContext 를 사용하여 필요한 뷰를 만들어 줍니다.
  3. 함수 호출에 따른 Presenting logic을 검증합니다.

이렇게 Instrumented Test를 통해 android 패키지에 접근하는 방식으로 presenting logic을 검증할 수 있습니다.

2. ViewHolder의 Presenting logic을 Presenter로 옮기는 리팩토링

Presenting logic을 ViewHolder → Adapter → Presenter로 이동시키는 리팩토링을 수행하며, Presenter에서 해당 함수 호출을 검증하는 테스트 코드를 작성합니다.

  1. View Interface 추가
interface SaladView {
    fun setTextView(text: String)
}

ViewHolder의 Presenting logic을 모두 함수로 분리해낸 인터페이스를 작성합니다.

  1. Handler Interface 추가
interface SaladHandler {
    fun onBindSalad(salad: Salad, view: SaladView)
}

Presenter가 호출할 Adapter의 함수를 정의한 Handler interface를 작성합니다.

  1. Adapter 생성 시 handler 주입
class SaladRecyclerAdapter(
    private val salads: List<Salad>,
    private val handler: SaladHandler
) : RecyclerView.Adapter<SaladViewHolder>()
  1. ViewHolder로 handler를 넘겨 줌
class SaladRecyclerAdapter(
    private val salads: List<Salad>,
    private val handler: SaladHandler
) : RecyclerView.Adapter<SaladViewHolder>() {
    ...
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SaladViewHolder =
        SaladViewHolder(
            LayoutInflater.from(parent.context).inflate(R.layout.item_salad, parent, false),
            handler
        )
  
    override fun onBindViewHolder(holder: SaladViewHolder, position: Int) {
        holder.bind(salads[position])
    }
}
  1. ViewHolder에서 1번에서 작성한 view interface(SaladView)를 구현
class SaladViewHolder(
    view: View,
    val handler: SaladHandler
) : RecyclerView.ViewHolder(view), SaladView {

    val textView = view.findViewById<TextView>(R.id.textView)
    
    fun bind(salad: Salad) {
        handler.onBindSalad(salad, this)
    }
    
    override fun setTextView(text: String) {
        textView.text = text
    }
}
  1. Presenter에서 2번에서 작성한 Handler interface(SaladHandler) 상속
class SaladBowlPresenter(
    view: SaladBowlView,
    ...
) : Presenter<SaladBowlView>(view), SaladHandler
  1. override한 handler의 함수들 내에서 ViewHolder의 Presenting logic 수행
class SaladBowlPresenter(
    view: SaladBowlView,
    ...
) : Presenter<SaladBowlView>(view), SaladHandler {
    override fun onBindSalad(salad: Salad, view: SaladView) {
        if (salad.type == "뱅크") 
            view.setTextView("뱅크샐러드")
        else
            view.setTextView("그냥샐러드")
    }
    ...
}
  1. 1번의 view interface mocking 후, 기존 PresenterTest와 같이 뷰 함수 호출을 통해 Presenting logic을 검증
class SaladBowlPresenterTest {
    @Mock
    lateinit var view: SaladBowlView
    @Mock
    lateinit var saladView: SaladView
    ...
    
    @Test
    fun onBind_type_is_bank() {
        val salad = Salad(type = "뱅크")
        presenter.onBindSalad(salad, saladView)
        Mockito.verify(saladView).setTextView("뱅크샐러드")
    }
    
    @Test
    fun onBind_type_is_not_bank() {
        val salad = Salad(type = "")
        presenter.onBindSalad(salad, saladView)
        Mockito.verify(saladView).setTextView("그냥샐러드")
    }
}

이처럼 Presenting Logic을 Presenter 쪽으로 분리해 내는 방법을 통해 View 로직에 비즈니스 로직이 포함되어 있는 경우에도 유의미한 테스트 코드 작성이 가능합니다. 이는 곧 테스트 가능한 코드관심사 분리가 잘 된 좋은 구조의 코드일 가능성이 높다는 것을 의미하기도 해요. View는 Presenter의 실행만 담당하고, 실질적인 로직을 수행하는 것은 Presenter가 담당하도록 하면 테스트 가능한 코드가 될 수 있으니까요. (이와 관련해 우리 팀 승민 님께서 발표하신 멋진 자료도 있어요. 참고하셔도 좋을 것 같습니다. 🙂)

MVP 패턴에서 view를 passive하게 갖는 구조에 대한 설명

물론 테스트 방법은 다양한 형태로 존재할 수 있습니다. 복잡한 로직을 예제에 다 담기는 어렵기 때문에 간단하게 텍스트만 바꾸는 정도의 예제를 들었는데, 이 예제처럼 간단한 변경점이라면 보다 효율적인 다른 방법이 존재할 수 있어요. 또한, 위 방식으로 복잡한 뷰를 핸들링하려고 한다면 presenter 코드가 비대해질 수 있다는 문제점도 있습니다.

하지만 이 설명을 통해 말씀드리고 싶었던 점은, 뱅크샐러드 안드로이드 팀에서 사각지대에 있던 영역까지 테스트하려고 고민하고 있다는 점이에요. 실제로 위와 같은 코드를 작성한 경우, “이 방식이라면 presenter 코드가 점점 비대해져 관리 자체가 어려워질 수 있지 않을까요?”, **“data class 등 bind로 넘기는 자료 객체에 함수를 만들어 해당 객체를 테스트하는 방식은 어떨까요?”**라 리뷰하는 조직이라는 것을 말씀드리고 싶었어요.

안드로이드 팀 내 코드 리뷰 중 구조에 대한 문제점을 포착하고 더 나은 테스트 코드를 제안하는 리뷰

끝맺음

뱅크샐러드 안드로이드 팀은 테스트 코드를 어떻게 작성하고 있는지 살펴보았습니다. 기본적인 Presenter 테스트 코드에서부터 사각지대에 놓여 있던 영역까지의 테스트 코드를요. 초반에 말씀드렸던 것과 같이 서비스의 규모, 성격에 따라 작성해야 할 테스트 코드의 모양새는 달라질 수 있어요. 우리 팀은 코드 변경점으로 인해 영향받는 기능이 있다면, 그 코드가 테스트하기 어려운 영역에 있는 코드이더라도 테스트 코드를 필수적으로 작성해야 한다 생각합니다. 가려진 영역이 있지는 않은지 끊임없이 탐구하고 있어요. 👀

좀 더 나은 테스트에 관심이 있는 분, 테스트에 관심이 생긴 분, 안드로이드 팀에 관심이 생긴 분 모두 💚뱅크샐러드💚에서 함께 이야기해요! 읽어 주셔서 감사합니다. 🙂

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기

Featured Posts

post preview
post preview
post preview
post preview
post preview

Related Posts