본문 바로가기
Android/Testing

[Android] UI 테스트 (2) - Espresso (ViewMatcher, ViewAction, ViewAssertion)

by 태크민 2024. 2. 9.

ViewMatchers, ViewActions, ViewAssertions에 포함된 함수들과 어떤 식으로 사용해야 하는지를 설명하고,

주의할 점들을 알아보려 한다.

 

ViewMatchers

뷰의 상태와 Matcher 함수들을 모아 놓은 클래스이다.
ViewMatchers는 뷰를 찾기 위해서도 ( onView ) 쓰고, 뷰의 상태를 확인하기 ( check ) 위해서도 사용한다.

onView(withText("Welcome"))
    .check(matches(allOf(isDisplayed(),withText("Welcome"))))

위의 코드에서 사용된 withText, isDisplayed 같은 것들이 ViewMatcher이다.

ViewMatchers안에는 몇십 개 이상의 함수가 있기에 모두 소개할 수 없고, 몇 가지만 소개하려 한다.

1. isDisplayed
onView(isDisplayed())

가장 기본적인 ViewMatcher로 화면에 보이는지 여부로 판별한다.
단순히 VISIBLE 확인이 아니라 화면의 보이는 영역에 그려졌는지를 본다.

2. withId
onView(withId(R.id.button))

가장 쉽게 사용할 수 있는 id를 이용하여 판별하는 ViewMatcher이다.

3. withText
onView(withText("Button"))
onView(withText(R.string.button))

onView(withText(object : CustomMatcher<String>("has multiple 't'"){
    override fun matches(item: Any?): Boolean {
        return (item as? String)?.let{
            it.filter { it == 't' }.count() == 2
        }?: false
    }
}))

텍스트를 이용하여 대상을 찾는 방법이다. 기본적인 String이나, stringRes를 지원한다.

 

추가적으로 커스텀한 매처를 사용할 수 도 있다.

대부분의 ViewMatcher 들은 커스텀한 Matcher 인자로 받을 수 있도록 되어 있다.
(withId 역시 커스텀 매처가 가능하다 )

 

위의 예제의 커스텀 매처는 't'를 두 개 가지고 있는 뷰를 찾는 로직이다.

"Button" 은 't'를 두 개 가졌기에 통과될 수 있다.

 

withText 안에 커스텀 매처를 넣은 경우 matchesitem 에는 화면에 존재하는 모든 뷰들의 text 값이 들어오게 되고,
이 값을 이용해 원하는 뷰를 찾도록 구현하면 된다.

( withText -> text , withId -> id , withContentDescription -> contentDescription 가 item으로 들어온다고 생각하면 된다. )

 

 

예제를 위한 것이지 실제로 onView에 저런 매처를 넣으면 뷰가 여러 개 선택될 확률이 높으니 주의해야 한다.

4. hasContentDescription, withContentDescription
onView(allOf(hasContentDescription(), withContentDescription("is TextView")))

contentDescription을 이용하는 방법이다.


hasContentDescription는 contentDescription 이 있는지 확인하고,
withContentDescription는 withText와 동일하게 contentDescription가 동일한 게 있는지 확인한다.

 

여러 개의 매처를 통해 뷰를 찾으려면 위와 같이 allOf 함수를 사용하면 된다.

5. withParent, withChild, hasSibling, hasDescendant
withParent(withText("Welcome"))     // 부모에게서
withChild(withText("Welcome"))      // 자식들에게서
hasSibling(withText("Welcome"))     // 같은 계층의 뷰들
hasDescendant(withText("Welcome"))  // 하위 계층의 모든 뷰들

onView(allOf(withId(R.id.linearLayout), hasDescendant(withText("Hello World!"))))
    .check(matches(isDisplayed()))

위의 함수들은 관계를 통해 탐색 대상을 지정하는 함수들이다.

 

위의 예제는 linearLayout을 찾고, 그 안에서 "Hello World!" 란 텍스트를 가진 뷰를 찾는 것이다.

독립적으로는 사용할 수 없고, 뷰가 주어졌을 때 해당 뷰와 관계를 바탕으로 검색을 한다.

 

주의할 점은 withChild 는 말 그대로 자기 자식만 챙기고 손자까지는 커버 치지 않는다.
hasDescendant 는 자식에 손자에 증손자까지 다 커버 친다.

두 개를 혼동해서 사용하면 안 된다.

6. isDescendantOfA
onView(withId(R.id.textView))
    .check(matches(isDescendantOfA(withId(R.id.linearLayout))))

isDescendantOfA 는 자신의 조상을 찾는 함수이다.
hasDescendant 와 마찬가지로 부모에 조부모에 증조부모까지 다 찾아낸다.

7. isAssignableFrom
onView(Matchers.allOf(isAssignableFrom(TextView::class.java), withText("Hello World!")))
    .check(matches(isDisplayed()))

클래스 타입을 통해 찾는 함수도 있다.
isAssignableFrom 에 인자로 타입을 넣으면 된다. 부모 클래스로도 찾을 수 있다.

8. 그 외..

위의 함수 외에도
isClickable, isEnabled, isSelected, withClassName 등이 존재한다.

 

ViewActions

뷰에게 동작을 시키기 위한 함수들이 모여있는 클래스이다.

1. click, longClick, doubleClick
onView(withId(R.id.button))
    .perform(click(), longClick())
    .perform(doubleClick())

기본적인 클릭 동작이다.
위의 코드는 클릭 -> 롱 클릭 -> 더블 클릭이 순차적으로 일어난다.

 

perform 에 여러 개의 ViewAction을 넣을 수 도 있다. 한 번에 다 넣으나 따로 넣으나 차이는 없다.

 

 

주의할 것은 해당 ViewAction 은 뷰에 직접 클릭 이벤트를 보내는 것이 아니고, 해당 좌표에 클릭을 하는 방식이다.

그래서 해당 좌표 위에 다른 뷰가 올라와 있다면 전혀 다른 상황이 일어날 수 있으니, 주의해야 한다.

 

( 구글이 애니메이션을 끄라는 이유 중 하나이다.
ex) 클릭이 일어나는 순간 애니메이션이 버튼 위를 지나가서 클릭을 대신 먹고 도망가버리는 상황이 연출될 수 있다. )

2. clearText, typeText, typeTextIntoFocusedView
onView(withId(R.id.editText))
    .perform(clearText())
    .perform(typeText("Hello"))
    .perform(typeTextIntoFocusedView("World"))

텍스트를 타이핑하기 위한 함수이다.

EditText와 같이 타이핑이 가능한 뷰에만 사용해야 한다.

 

clearText 는 텍스트를 지우는 동작을 하고,
typeText, typeTextIntoFocusedView 은 타이핑하는 동작을 한다.

 

그럼 타이핑은 왜 두 개인가?
typeText 의 경우 포커싱 잡고, 타이핑을 하는 두 가지 동작을 진행하는 반면,
typeTextIntoFocusedView 는 이미 포커싱이 잡힌 뷰에서 동작하고, 대상 뷰에게 포커싱이 없다면 에러를 발생시킨다.

 

 

그래서 왜?
위에 클릭에서 나왔듯이 에스프레소는 좌표를 기반으로 동작을 한다. 포커싱 역시 마찬가지이며 이때 좌표는 뷰의 중앙이다.

그럼 typeText 를 연달아 쓰면 어떻게 되느냐

 

 

이렇게 된다.

"Hello" 치고 그 중앙인 뷰의 중앙인 'e',  'l' 사이에 포커싱을 다시 잡고 "World"를 쳐버린다.

그러니, 여러 번 타이핑을 하고 싶으면 위의 예제 코드처럼 작성하면 된다.

3. replaceText
onView(withId(R.id.editText))
    .perform(replaceText("Hello World"))     //Hello World
    .perform(typeTextIntoFocusedView("!!"))  //!!Hello World

replaceText 는 텍스트를 대체하는 함수이다.

역시나 타이핑이 가능한 뷰에만 사용해야 한다.

 

해당 함수를 실행하면 "Hello World"로 텍스트가 대체된다.

 

주의할 점은 대체된 후 포커싱은 글자 맨 앞으로 이동하며,
그 상태로 typeTextIntoFocusedView 를 실행하면 맨 앞에 글자를 쓰기 시작한다. ( 최악이다. )

( 에스프레소에서 해결할 방법은 못 찾았고, 뷰를 들고 와서 포커싱을 "직접" 수정해주는 거 말고는 없는 것 같다... )

4. scrollTo
onView(withId(R.id.editText))
    .perform(scrollTo(), click())

만약 화면에 없고, "ScrollView" 안에 있는 부라면 해당 뷰가 보일 때까지 스크롤을 해주어야 한다.

이때, scrollTo 를 사용해주면 된다.

5. swipeUp, swipeDown, swipeRight, swipeLeft
perform(swipeUp(), swipeDown(), swipeRight(), swipeLeft())

swipe를 위한 액션도 존재한다.

 

이역시 위의 클릭, 포커싱과 동일하게 좌표를 기반으로 동작하기에

스와이프가 불가능한 뷰를 설정해도 백그라운드가 스와이프 되는 걸 확인할 수 있다.

6. addGlobalAssertion, removeGlobalAssertion
val viewAssertion = matches(withId(R.id.button))
addGlobalAssertion("button always isDisplayed",viewAssertion)
removeGlobalAssertion(viewAssertion)

화면에서 특정 조건이 불변해야 하는 뷰에 대한 처리도 가능하다.

 

매번 체크하는 것은 매우 귀찮기에 addGlobalAssertion로 지정해 놓으면 모든 perform 이 끝날 때마다 자동으로 실행된다.

removeGlobalAssertion 를 이용해서 취소시킬 수 도 있다.

 

위의 예제는 버튼이 화면에 계속 보이고 있는지를 체크하는 코드이다.

7. repeatedlyUntil
onView(withId(R.id.button))
    .perform(repeatedlyUntil(click(), hasSibling(withText("Welcome")), 10))

클릭과 같은 행동을 계속할 수는 없으니 반복할 수 있는 방법도 존재한다.

 

repeatedlyUntil 함수는 인자로 액션, 조건, 횟수를 받는다.
특정 횟수가 되거나 특정 조건이 만족할 때까지 계속 액션을 실행시키고, 횟수 안에 조건에 만족하지 못할 시 에러가 발생한다.

조건이 만족되면 횟수에 상관없이 바로 통과된다.

 

위의 예제는 button을 누르면 textView의 텍스트가 Welcome으로 변하는 코드를 테스트한 것이다.

 

hasSibling 을 넣었는가?

조건의 대상이 자기 자신으로 한정되기 때문이다. ( 없다면, 무의미하게 button에게 "Welcome" 텍스트가 있는지만 테스트하게 된다. )
때문에, hasSibling 과 같은 관계를 이용하는 함수를 통해 다른 뷰가 조건의 대상에 포함되도록 해야 한다.

8. 그 외..

closeSoftKeyboard, pressImeActionButton, pressKey, pressBack 와 키보드, 키 관련 같은 함수와
openLink, openLinkWithUri 와 같은 링크 관련 함수도 있다.

 

ViewAssertions

뷰의 테스트에 대한 코드가 모여있다.

1. matches
onView(withId(R.id.textView))
    .check(matches(not(withText("Hello World!"))))

matches 는 위에서 계속 나왔던 코드이다. true 일 경우 테스트가 통과된다.

false 일 경우 통과되도록 지정하는 코드는 따로 없다.

대신 not() 사용하면 동일한 기능을 할 수 있다.

2. doesNotExist
onView(withText("Barabarabarabam"))
    .check(doesNotExist())

doesNotExist 은 해당 뷰가 없을 때 통과되는 테스트이다.

화면에 안 보이는 게 아닌 진짜 조건에 만족하는 대상이 없어야만 통과된다.

3. selectedDescendantsMatch
onView(withId(R.id.linearLayout))
    .check(
        selectedDescendantsMatch(
            isAssignableFrom(TextView::class.java),
            hasContentDescription()
        )
    )

같은 조건을 가지는 여러 뷰에 대해 테스트를 진행할 수 도 있다.

selectedDescendantsMatch 을 통해서 가능하며, 첫 번째 인자로는 대상을 찾고, 두 번째 인자로 테스트를 진행한다.

 

위의 예제는 linearLayout 안의 TextView들이 모두 contentDescription을 가지는 테스트한 코드이다.

마무리

위의 내용으로 에스프레소의 여러 함수들과 주의사항을 살펴보았다.

#3 에서는 Rule 에 대해서 정리를 하려 한다.

 

 

출처: https://two22.tistory.com/10 [루크의 코드테라피:티스토리]