본문 바로가기

안드로이드

Android Espresso #2 - ViewMatcher, ViewAction, ViewAssertion

반응형

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 에서는 RecyclerView에서 어떤 식으로 테스트하면 되는지를 다루려고 한다.

반응형

'안드로이드' 카테고리의 다른 글

Android Espresso #4 - ActivityRules  (0) 2020.06.29
Android Espresso #3 - RecyclerView  (0) 2020.06.26
Android Espresso #1 - 시작  (0) 2020.06.24
[And] navigation #2 - 딥 링크  (0) 2020.06.24
[And] navigation #1 - 기본 사용법  (1) 2020.06.18