관리 메뉴

나만을 위한 블로그

[Android] espresso를 사용한 UI 테스트(+리사이클러뷰) 본문

Android

[Android] espresso를 사용한 UI 테스트(+리사이클러뷰)

참깨빵위에참깨빵 2022. 2. 7. 00:04
728x90
반응형

이전 글들에선 순수 자바 로직만 테스트했다면 이젠 UI도 테스트해야 한다.

UI를 테스트할 때는 주로 에스프레소라는 라이브러리를 사용하는데, 부분적으로 hamcrest라는 단위 테스트 라이브러리를 사용할 수도 있다. 이 포스팅에선 둘 다 사용하는 방향으로 작성했다.

 

먼저 앱 수준 gradle에 필요한 의존성들을 넣어준다. 안드로이드 디벨로퍼에서 발견한 의존성 문구들을 전부 가져왔고 오늘 날짜 기준으로 3.4.0이 최신이다.

 

testImplementation 'junit:junit:4.+'

androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.4.0'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.4.0'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
testImplementation "org.mockito:mockito-core:3.6.28"
testImplementation "org.mockito:mockito-inline:3.4.0"
testImplementation 'org.hamcrest:hamcrest:2.2'

 

다음은 UI 테스트를 위한 간단한 액티비티다. 버튼을 누르면 인텐트에 데이터를 넣어서 다른 화면으로 이동하는 로직만 있는 액티비티다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical">

    <TextView
        android:id="@+id/resultView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="160dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:text="텍스트"
        android:textAppearance="?android:attr/textAppearanceLarge" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/second_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="250dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/resultView"
        android:text="버튼"/>

</androidx.constraintlayout.widget.ConstraintLayout>
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;

import androidx.appcompat.app.AppCompatActivity;

public class SecondActivity extends AppCompatActivity {

    private final String TAG = this.getClass().getSimpleName();

    private Button button;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_second);

        button = findViewById(R.id.second_button);

        button.setOnClickListener(v -> {
            Intent intent = new Intent(this, ResultActivity.class);
            intent.putExtra("data", "data");
            startActivity(intent);
        });
    }
}

 

그리고 리사이클러뷰에 필요한 파일들을 만들어준다. 테스트하기 위한 리사이클러뷰기 때문에 정말 대충 틀만 만들었다.

먼저 아이템 XML이다. recyclerview_item.xml 이란 이름으로 만들었다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/title_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/message_textview"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

</LinearLayout>

 

다음은 모델 클래스다.

public class ResultModel {
    private String title;
    private String message;

    public ResultModel(String title, String message) {
        this.title = title;
        this.message = message;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

 

다음은 어댑터다.

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;

public class ResultAdapter extends RecyclerView.Adapter<ResultAdapter.ResultViewHolder> {

    private Context context;
    private List<ResultModel> list;

    public ResultAdapter(Context context, List<ResultModel> list) {
        this.context = context;
        this.list = list;
    }

    @NonNull
    @Override
    public ResultAdapter.ResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(context).inflate(R.layout.recyclerview_item, parent, false);
        return new ResultViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull ResultAdapter.ResultViewHolder holder, int position) {
        final ResultModel model = list.get(position);
        holder.title.setText(model.getTitle());
        holder.message.setText(model.getMessage());
    }

    @Override
    public int getItemCount() {
        return list.size();
    }

    public class ResultViewHolder extends RecyclerView.ViewHolder {

        private TextView title;
        private TextView message;

        public ResultViewHolder(@NonNull View view) {
            super(view);

            title = view.findViewById(R.id.title_textview);
            message = view.findViewById(R.id.message_textview);
        }
    }
}

 

다음은 액티비티의 자바 파일과 XML 파일이다.

 

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ResultActivity">

    <TextView
        android:id="@+id/getTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="250dp"
        android:text="글자"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:textSize="40sp"
        android:textColor="@color/black"/>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/result_recyclerview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toBottomOf="@+id/getTextView"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import android.content.Intent;
import android.os.Bundle;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.List;

public class ResultActivity extends AppCompatActivity {

    private TextView getTextView;
    private RecyclerView recyclerView;
    private ResultAdapter adapter;
    private List<ResultModel> list;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_result);

        list = new ArrayList<>();
        getTextView = findViewById(R.id.getTextView);
        recyclerView = findViewById(R.id.result_recyclerview);

        for (int i = 0; i < 11; i++) {
            list.add(new ResultModel("제목", "내용"));
        }
        adapter = new ResultAdapter(this, list);
        recyclerView.setAdapter(adapter);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        recyclerView.setHasFixedSize(true);

        Intent intent = getIntent();
        getTextView.setText(intent.getStringExtra("data"));
    }
}

 

 

이제 테스트를 해보자. UI 테스트를 하려면 패키지 중 (androidTest)가 적힌 패키지 안에 자바 파일을 만들면 된다.

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.Matchers.instanceOf;

import androidx.test.espresso.contrib.RecyclerViewActions;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
@LargeTest
public class SecondActivityTest {

    @Rule
    public ActivityScenarioRule<SecondActivity> activityScenarioRule = new ActivityScenarioRule<>(SecondActivity.class);

    @Test
    public void testActivity() {
        onView(withId(R.id.second_button))
                .perform(click());

        onView(withId(R.id.getTextView))
                .check(matches(withText("data")));

        onView(withId(R.id.result_recyclerview))
                .perform(RecyclerViewActions.scrollToPosition(5));

        onView(withId(R.id.result_recyclerview))
                .check(matches(isDisplayed()));

        onView(withId(R.id.result_recyclerview))
                .perform(RecyclerViewActions.scrollTo(hasDescendant(withText("제목"))).atPosition(0));

        onView(withId(R.id.result_recyclerview))
                .perform(RecyclerViewActions.scrollToHolder(instanceOf(ResultAdapter.ResultViewHolder.class)).atPosition(10));
    }

}

 

 

이제 이 파일에 쓰여진 어노테이션, 메서드들이 뭔지 정리해본다.

 

  • @RunWith : androidx.test 패키지를 사용하기 때문에 필요한 어노테이션이다. 이게 있냐 없냐에 따라 UI를 포함한 androidTest인지 일반 단위 테스트인지 구분한다. 내 기준으로 AndroidJUnit4가 최고 버전이기 때문에 이걸 썼다.
  • @LargeTest : 메서드나 클래스에 사용할 수 있는 어노테이션이다. androidx.test 라이브러리로 작성된 테스트에 추가하는 걸 권장한다고 안드로이드 디벨로퍼에서 말하고 있다. 이것 말고도 @SmallTest, @MediumTest 어노테이션이 있는데 이 어노테이션의 구분은 아래와 같다.

 

  • Small : 파일 시스템, 네트워크와 상호작용하지 않음
  • Medium : 테스트를 실행 중인 상자(box)의 파일 시스템에 접근
  • Large : 외부 파일 시스템, 네트워크 등에 접근

안드로이드 개발자 블로그에선 SmallTest는 100ms 미만, MediumTest는 2초 미만, 대규모 테스트는 120초 미만이 소요된다. 대충 @LargeTest 하나 박아두면 편할 것 같아서 난 저거 쓸란다.

 

  • @Rule : 테스트할 액티비티를 연결하는 어노테이션이다. ActivityScenarioRule을 정의할 때 붙인다.
  • ActivityScenarioRule : 테스트가 시작되면 주어진 액티비티를 시작하고 테스트가 종료되면 액티비티를 닫는다. 테스트가 끝나면 이 Rule은 아무 작업도 수행하지 않고, deprecated된 ActivityTestRule 대신 나온 것이다.

 

다음은 메서드 안에 사용한 static 메서드들이다.

 

  • onView() : 액티비티 안의 뷰와 상호작용하기 위한 진입점. withId()와 같이 사용한다.
  • withId() : R.id.xxx 식으로 리소스 id를 사용해서 테스트할 뷰를 찾는 메서드
  • perform() : 매개변수로 받는 메서드를 수행하는 메서드
  • click() : 뷰 클릭을 수행하는 메서드
  • matches() : ViewMatcher 객체를 통해 지정한 뷰가 내가 원하는 상태인지 확인하는 메서드
  • withText() : WithTextMatcher 객체를 생성해 리턴하는 메서드. 매개변수로 받은 텍스트를 이용해 대상을 찾는다
  • isDisplayed() : 뷰의 가시성을 확인하는 메서드

 

다음은 리사이클러뷰에 사용한 메서드다.

 

  • scrollTo() : 뷰를 통해 스크롤하는 메서드. 매개변수로 찾으려는 Matcher를 넣으면 그 뷰로 이동한다. 리사이클러뷰 특성 상 같은 조건의 뷰가 여러 개 나올 수 있어 atPosition()으로 특정 position을 선택해야 한다
  • scrollToPosition() : position을 써서 스크롤하는 메서드. 그 위치에 아이템이 있다면 테스트는 성공한다
  • scrollToHolder() : 뷰홀더를 통해 스크롤하는 메서드. hamcrest의 instanceOf() 또는 커스텀 Matcher를 인자로 넘기거고, scrollTo()처럼 atPosition()으로 하나를 선택해줘야 한다
반응형
Comments