본문 바로가기

Android

ViewModel Testing 코드 작성하기

1. ViewModel의 역할

ViewModel은 MVVM 패턴에서 Data Source와 View를 연결하는 컴포넌트이다.

ViewModel의 첫번째 목적은 Data를 꺼내서 보관하고 필요한 때에 UI에게 제공하는 기능을 담당한다.
View 자체에서 데이터를 보관하면 화면회전과 같은 Configuration Change 상황에서 데이터를 관리하는데 어려움이 발생한다.
하지만 ViewModel은 Activity보다 생명주기가 더 길기 때문에 ViewModel이 홀드하고 있는 데이터는 Activty가 새롭게 그려져도 다시 사용할 수 있게 된다.

기존의 다른 패턴들과 달리 ViewModel은 View에 대한 레퍼런스를 가지고 있지 않다는 것이 또 다른 큰 특징이다.
MVP 패턴에서는 View - Presenter가 1:1로 생성되어 View가 Presenter에게 요청을 하고 Presenter가 View에게 결과를 제공하는 구조였다.
하지만 ViewModel은 자신의 데이터를 소비하는 소비자인 View에 대해서 전혀 알지 못한다. 단지 데이터를 공급하는 생산자역할만 담당한다.
이러한 특징은 ViewModel과 함께 사용되는 LiveData를 사용함으로써 나타나는 장점이다.
ViewModel의 역할은 LiveData에 데이터를 추가하고 홀드하는 역할에서 끝이나고 이 LiveData의 변화를 감지하기 위해서 View가 구독을 하는 형태인다.

그러므로 ViewModel에서 우리가 테스트해야할 지점은 DataSource로부터 데이터를 가져와서 LiveData에 업데이트하는 기능이다.

2. Repository의 역할

이전에 만든 Database Testing을 통해서 Database의 기본 CRUD가 올바르게 동작하는 것을 검증하였다.
이 Database에 접근하여 실제 Dao를 이용해서 데이터를 꺼내오는 역할은 TaskRepository 클래스가 맡는다.

즉 물리적으로 Database의 Dao를 이용해서 데이터를 꺼내고 저장하는 역할 -> Repository
Repository에게 요청해서 데이터를 받아서 저장해놓는 역할(View에게 제공하기 위해서) -> ViewModel

ViewModel 테스트하기 위해서는 실제 데이터베이스를 사용하기보다 Repository의 기능을 흉내낸 FakeRepository 클래스를 이용하면 더 빠른 테스트를 진행할 수 있다.(DB를 배제함으로써 Instrumentation Test가 아니라 Unit Test로 진행할 수 있다.)

3. FakeRepository 만들기

실제 데이터베이스는 UUID를 Key값으로 가지고 있는 Task 테이블이다.
ViewModel 테스트를 위해 데이터베이스처럼 데이터를 보관하고 ViewModel의 요청에 따라 반환하는 FakeRepository를 만들어보자.

Unit test이기 때문에 test 디렉토리 안에 FakeTaskRepository 클래스를 생성한다.
Map<String, Task>의 자료구조를 사용해서 데이터를 보관하도록 구현한다. 데이터베이스처럼 Persistence는 없지만 테스트를 진행하는 동안 데이터를 홀드하기에는 충분하다.

    public class FakeTaskRepository implements TaskRepository {
        HashMap<String, Task> data;            // <Key : UUID.toString(), Value : Task>;

        public FakeTaskRepository(HashMap<String, Task> initialData) {
            data = initialData;
        }
    }

테스트할 더미 데이터값을 생성자로 갖는 FakeTaskRepository를 만들었다.
이제 TaskRepository 인터페이스를 구현해야한다.
이때 인터페이스가 제공하는 메소드는 실제 TaskRepository의 메소드와 동일하기 때문에 TaskRepository와 ViewModel이 잘 동작할 것을 보장할 수 있다.

실제 TaskRepository가 Dao를 통해서 하는 동작을 Map 데이터에 똑같이 하도록 구현한다.

3-1. 모든 Tasks 불러오기 : List<Task> getAllTasks()

3-2. Task 삽입, 수정하기

차례차례 모든 TaskRepository 인터페이스의 메소드를 DB처럼 데이터를 보관하고 가져오도록 구현한다.

4. LiveDataTestUtil

ViewModel 테스트 코드 작성 시 가장 어려웠던 점은 LiveData의 데이터를 확인하는 것이었다.
라이브 데이터는 내부적으로 변화가 발생 시 onChanged() 콜백 메소드에게 전달이 되는데.
테스트 코드에서 라이브 데이터 값을 정확히 찍어볼 수 있는 방법을 찾기 어려웠다.
구글에서 검색해본 결과 라이브데이터 값 테스트를 위해서 LiveDataTestUtil을 사용하는 것을 알 수 있었다.

public class LiveDataTestUtil {
    public static <T> T getValue(LiveData<T> liveData) throws InterruptedException {
        final Object[] data = new Object[1];
        CountDownLatch latch = new CountDownLatch(1);

        Observer<T> observer = new Observer<T>() {
            @Override
            public void onChanged(T t) {
                data[0] = t;
                latch.countDown();
                liveData.removeObserver(this);
            }
        };

        liveData.observeForever(observer);
        latch.await(2, TimeUnit.SECONDS);

        return (T) data[0];
    }
}

비동기 처리인 라이브데이터를 Lock을 이용해서 동기식으로 변환한 후 중간에 값을 찍어볼 수 있게 만들어진 테스트용 유틸이다.
이제 getValue() 메소드를 통해서 라이브데이터의 값을 확인해 볼 수 있다.

5. TaskViewModelTest 작성하기

이제 ViewModel Testing 코드 작성을 위한 준비를 마쳤다.

1에서 정리했던 ViewModel의 역할대로 Repository에게 데이터를 잘 요청하고 전달하는지를 테스트한다.

5-1. 테스트를 위한 프리셋 설정

테스트를 위한 초기 데이터 값으로 3개의 Task를 만들고 FakeRepository와 TaskViewModel을 연결한다.

 @Before
    public void setup() {
        Timber.d("[setup()] : 3 Tasks are created and inserted in FakeRepository.");
        Task task1 = new Task(saved.toString(), "title1");
        Task task2 = new Task(UUID.randomUUID().toString(), "title2", true);
        Task task3 = new Task(UUID.randomUUID().toString(), "title3", true);

        HashMap<String, Task> data = new LinkedHashMap<>();
        data.put(task1.getUuid(), task1);
        data.put(task2.getUuid(), task2);
        data.put(task3.getUuid(), task3);
        repository = new FakeTaskRepository(data);

        // FakeRepository 를 이용해서 ViewModel 인스턴스를 생성한다.
        viewModel = new TaskViewModel(repository);
    }

5-2. 모든 Tasks를 불러오기 : loadTasks()

@Test
    public void loadTasks_ShouldReturnTasks() {
        // [Given] : setUp(), DB에 3개의 Task 데이터가 입력 되어있다.

        // [When] : ViewModel runs loadTasks() method.
        viewModel.loadTasks();
        try {
            // [Then] : LiveData<Boolean> loading should be false to notify the loading is done.
            boolean loading = LiveDataTestUtil.getValue(viewModel.getLoading());
            Timber.d("loading is " + loading);
            Assert.assertFalse(loading);

            // [Then] : loadTasks() 메소드는 Task Table에 있는 모든 Task를 리스트로 반환한다.
            // Now there are 3 data in DB. Check the size of the retrieved data.
            List<Task> result = LiveDataTestUtil.getValue(viewModel.getTasks());
            Assert.assertEquals(3,  result.size());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

5-3. UUID가 일치하는 Task 불러오기 : `loadTaskByUuid()'

@Test
    public void loadTaskByUuid_ThenShouldReturnTask() {
        // [Given] : setUp(), DB에 3개의 Task 데이터가 입력 되어있다.
        // Title == "title1"인 Task 의 UUID 는 생성할때 saved 변수에 보관되어 있다.
        String savedUUID = saved.toString();

        // [When] : 저장해놓은 UUID를 조건으로 Task를 불러온다.
        viewModel.loadTaskByUuid(savedUUID);
        try {
            // [Then] : UUID는 PrimaryKey이므로 하나의 Task만 반환된다.
            // viewModel에서 불러온 Task의 uuid가 savedUUID 와 같아야한다.
            Task result = LiveDataTestUtil.getValue(viewModel.getTask());
            Assert.assertEquals(savedUUID, result.getUuid());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

5-4. 새로운 Task 저장하기 : `insertTask()'

@Test
    public void insertTask_ThenShouldAddNewTask() {
        // [Given] : setUp(), DB에 3개의 Task 데이터가 입력 되어있다.
        try {
            // [When] : 새로운 데이터인 newTask 를 DB에 삽입한다.
            viewModel.insertTask(newTask);

            // [Then] :  After insert a new task, the total data size should be 4.
            viewModel.loadTasks();
            List<Task> tasks = LiveDataTestUtil.getValue(viewModel.getTasks());
            Assert.assertEquals(4, tasks.size());


            // [Then] : 새롭게 추가한 newTask가 올바르게 삽입되었는지 확인하기 위해서.
            // newTask.uuid로 검색해서 반환된 Task 는 newTask 와 동일한 값을 가져야한다.
            viewModel.loadTaskByUuid(newTask.getUuid()); 

            Task result = LiveDataTestUtil.getValue(viewModel.getTask());
            Assert.assertEquals(newTask, result);

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

5-5. Unit Test Run 결과

ViewModel의 메소드가 FakeRepository와 테스트 진행 시 올바르게 동작한다는 결과를 확인할 수 있다.
TaskViewModel은 TaskRepository 인터페이스와 문제 없이 동작하므로 해당 과정에서 문제가 발생할 경우
실제 TaskRepository와 FakeRepository의 동작과정이 같은지를 점검해보고.
다음으로 Database의 동작을 DatabaseTest코드를 통해 확인해 볼 수 있을 것이다.

Summary

ViewModel 테스트 코드를 작성하기 위해서 ViewModel의 역할을 정리했다.
실제 데이터베이스와 일하는 TaskRepository 대신 흉내낸 FakeTaskRepository를 만들었다.
이는 ViewModel은 Repository가 건네주는 데이터값에 대한 검증이 필요한 것이지
실제 Database가 필요한 것(DatabaseTest의 대상)이 아니기 때문이다.
그리고 속도가 빠른 Unit Testing으로 진행하기 위함이다.

ViewModel은 결과값을 LiveData에 업데이트하는데. View는 옵저버로 이 라이브데이터를 구독한다.
LiveData의 결과값을 확인하기 위해서 LiveDataTestUtil을 사용해서 값을 찍어볼 수 있다.

Given에서 테스트 전에 주어진 환경을 작성하고,
When은 테스트하고 싶은 동작이 발생하는 시점을 의미하며,
Then에서 동작 후 예상되는 결과값을 Assert로 확인한다.
예상값과 결과값이 일치하면 테스트 통과이다.