개요
[ACC] Room 입문에 이어 이를
사용해 작성된 코드를 테스트 하는 방법을 다룹니다. 안드로이드의 ACC에 포함된 ORM인 Room을 사용해 만든 앱 데이타베이스는 사용자의 데이타를 안전하게 보관해야 합니다. 그래서 Room으로 작성한 데이타베이스가 개발자 의도대로 작동하는지 테스트 하는 것은 이러한 이유로 꼭 필요한 절차입니다.
테스트 하는 방법은 2가지가 있습니다.
- 안드로이드 기기에서 테스트
- 호스트 개발 시스템에서 테스트(권장하지 않음).
여기서는 안드로이드 기기에서 테스트 방법만 다룹니다. 호스트 개발 시스템에서는 빠른 테스트가 가능하지만 장치에서 실행중인 SQLite 버전과 호스트 시스템의 버전과 일치하지 않을 수 있기 때문에 그다지 권장하지 않습니다.
참고로 DB 마이그레이션 테스트는 여기서는 다루지 않습니다. 또한 테스트에 방법을
다루는 정보는 공식 문서를 참고하셔도 됩니다.
테스트를 위한 Room 데이타베이스 만들기
테스트를 위한 데이타베이스를 구성해 봅시다. 1개의 Entity와 DAO를 만들고 이를 재료로 DB를 만들 것입니다.
다음 코드는 회원 Entity입니다.
@Entity(tableName = "member") public class MemberEntity { @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "member_rowid") public int rowid; public String email; public String name; public String phone; }
@Entity 애노테이션의 tableName 속성 값 때문에 테이블 명은 member이 됩니다. 그리고 member_rowid, email, name, phone, regdate가 컬럼명임을 확인할 수 있습니다. member_rowid는 primary key이면서 auto increment 되도록 설정했습니다.
다음은 회원 DAO 코드입니다.
@Dao public interface MemberDao { @Insert(onConflict = OnConflictStrategy.ROLLBACK) long add(@NonNull MemberEntity entity); @Delete int delete(@NonNull MemberEntity entity); @Update int update(@NonNull MemberEntity entity); //동기 @Query("SELECT * FROM member WHERE member_rowid=:memberRowid") MemberEntity getSync(long memberRowid); @Query("SELECT * FROM member") List<MemberEntity> getAllSync(); //비동기 @Query("SELECT * FROM member WHERE member_rowid=:memberRowid") LiveData<MemberEntity> get(long memberRowid); @Query("SELECT * FROM member") LiveData<List<MemberEntity>> getAll(); }
DAO에 정의된 메서드를 유심히 보시면 반환값에 LiveData를 붙인 것과 아닌 것이 있습니다. 반환값이 LiveData인 것은 질의의 응답을 LiveData에 등록한 Observer를 통해 비동기로 받습니다. 없으면 응답값을 동기로 받게 되죠. 테스트는 동기와 비동기를 나눠서 테스트를 진행할 것입니다.
Entity와 DAO가 준비 되었으니 아래 코드처럼 데이타베이스를 정의할 수 있습니다.
@Database( entities = {MemberEntity.class}, version = 1 ) @TypeConverters(CV.class) public abstract class DB extends RoomDatabase { public abstract MemberDao memberDao(); }
RoomDatabase를 상속해서 추상 클래스를 구현했습니다. 또한 앞서 만든 Entity를 이 데이타베이스에 사용할 수 있도록 @Database 어노테이션 내부의 속성에 entities에 등록했습니다. 이렇게 하면 데이타베이스가 빌드될 때 자동으로 member 테이블이 만들어 집니다. 그리고 추상클래스 내부에는 DAO 객체를 얻기 위해 memberDao() 추상메소드도 선언합니다. 이 DB클래스와 memberDao()가 abstract이지만 ACC가 필요한 코드를 담은 구상클래스를 자동으로 만들어 줍니다.
테스트 코드 작성하기
이제 안드로이드 디바이스 환경에 JUnit 테스트 코드를 작성해 지금까지 만든 코드를 테스트 해보겠습니다. 참고로 이 테스트는 Activity를 만들지 않기 때문에 단위 테스트 보다는 느리지만 일반적인 UI 테스트 보다는 빠른 테스트가 가능합니다.
테스트 코드 구조 만들기
아래처럼 안드로이드 테스트에 클래스를 만듭니다.
@RunWith(AndroidJUnit4.class) public class DBTest { private DB db; private MemberDao memberDao; @Before public void start() throws Exception { Log.i("dbtest", "테스트 시작"); Context appContext = InstrumentationRegistry.getTargetContext(); db = Room.inMemoryDatabaseBuilder(appContext, DB.class).build(); memberDao = db.memberDao(); } @Test public void testSync() throws Exception { //동기 테스트 코드 } @Test public void testAsync() throws Exception { //비동기 테스트 코드 } @After public void end() throws IOException { Log.i("dbtest", "테스트 종료"); db.close(); } }
클래스 상단에 @RunWith(AndroidJUnit4.class)는 안드로이드 JUnit 테스트 코드임을 알려줍니다.
@Before에 정의된 함수인 start()에서 Room.inMemoryDatabaseBuilder() 함수를 이용해 앞서 작성한 DB 클래스를 가지고 데이타베이스 객체를 생성합니다. 이 함수는 메모리 상에 데이타베이스를 만들기 때문에 테스트를 종료하면 모든 데이터가 삭제됩니다. 그러므로 테스트 할때는 이 함수를 사용해 데이타베이스를 만드는 것이 유리합니다.
@After는 별 것없이 데이타베이스를 닫기만 합니다.
테스트 코드는 동기 테스트와 비동기 테스트로 나눴습니다. @Test 애노테이션 붙은 2개의 메소드를 구현하면서 실현할 것입니다.
동기 테스트
동기나 비동기 테스트, 둘 다 다음 시나리오로 테스트를 진행할 것입니다.
- 회원 2명 등록하고 등록이 잘 되었는지 확인
- 등록된 회원 리스트가 2명인지 확인
- 회원의 정보를 수정하고 수정이 잘 되었는지 확인
- 회원을 삭제하고 삭제되었는지 확인
- 최종 회원수가 1명임을 확인
이 시나리오에 맞게 동기 테스트 코드를 작성하면 다음과 같습니다.
@Test public void testSync() throws Exception { Log.i("dbtest", "동기 테스트 시작"); int cnt; MemberEntity m1a, m1b, m2a, m2b; long memberRowid1, memberRowid2; List<MemberEntity> list; //회원1 등록 및 확인 m1a= new MemberEntity(); m1a.email = "a1@email.com"; m1a.name = "아무개1"; m1a.phone = "010-1111-1111"; memberRowid1 = memberDao.add(m1a); m1b = memberDao.getSync(memberRowid1); assertNotNull(m1b); assertEquals(m1a.email, m1b.email); assertEquals(m1a.name, m1b.name); assertEquals(m1a.phone, m1b.phone); //회원2 등록 및 확인 m2a = new MemberEntity(); m2a.email = "a2@email.com"; m2a.name = "아무개2"; m2a.phone = "010-2222-2222"; memberRowid2 = memberDao.add(m2a); m2b = memberDao.getSync(memberRowid2); assertNotNull(m2b); assertEquals(m2a.email, m2b.email); assertEquals(m2a.name, m2b.name); assertEquals(m2a.phone, m2b.phone); //회원 리스트 2명 확인 list = memberDao.getAllSync(); assertNotNull(list); assertEquals(list.size(), 2); //회원1 전화번호 수정 및 확인 m1b.phone = "010-1111-2222"; cnt = memberDao.update(m1b); assertEquals(cnt, 1); m1a = memberDao.getSync(memberRowid1); assertNotNull(m1a); assertEquals(m1a.phone, m1b.phone); //회원1 삭제 및 확인 cnt = memberDao.delete(m1a); assertEquals(cnt, 1); m1a = memberDao.getSync(memberRowid1); assertEquals(m1a, null); //회원 리스트 1명 확인 list = memberDao.getAllSync(); assertNotNull(list); assertEquals(list.size(), 1); Log.i("dbtest", "동기 테스트 종료"); }
코드는 읽는데는 크게 어려움이 없을 것 같아 설명은 생략하겠습니다.
이 테스트 코드를 수행하기 위해 함수 내부에 커서를 위치시키고 Ctrl + Alt + F10 을 누릅니다.
그럼 컴파일을 무사히 마치고 지정한 안드로이드 디바이스에서 테스트 코드가 실행이 될 것이고 그 결과를 확인할 수 있을겁니다. 테스트를 무사히 통과하면 여러분은 “All Tests Passed” 문구를 확인할 수 있을 것입니다. 만약 테스트를 통과하지 못하면 그에 상응하는 런타임 에러를 출력할 것입니다.
참고로 Room으로 만든 데이타베이스에서 @Query이 붙은 DAO의 메소드를 UI 스레드에서 직접 실행하면 런타임 에러가 발생합니다. 만약 UI 스레드에서도 동작하도록 하려면 다음처럼 데이타베이스 인스턴스를 빌드해야 합니다.
db = Room.inMemoryDatabaseBuilder(appContext, DB.class).allowMainThreadQueries().build();
위 코드에서 빌드하기 전에 allowMainThreadQueries() 를 호출했음을 확인하세요. 하지만 그리 권장할 사항은 아닙니다.
비동기 테스트
ACC Room은 비동기 데이터 질의/응답을 LiveData로 지원합니다. LiveData는 Activity나 Fragment등의 생명주기(Life Cycle)를 인식하여 데이터 요청에 대한 응답을 Observer에 해줄지 말지 결정할 수 있습니다. 그래서 LiveData를 사용하면 데이터 요청에 대해 비동기로 응답하는 것 뿐만아니라 View의 생명주기와 함께 고려하는 코드를 따로 작성해야 하는 부담으로부터 어느정도 해방될 수 있습니다.
DAO를 만들 때 Query 실행의 결과에 LiveData<?> 처리한 부분을 사용하면 데이타베이스에 대한 질의를 비동기로 응답 받을 수 있습니다. 다만, 비동기 코드는 테스트 할 때 어려움이 있는데요. 비동기 코드라도 테스트는 정해진 시나리오에 따라서 순차적으로 진행해야 하므로 이에 관련된 추가적인 작업이 필요합니다. 여기서는 순차적 처리를 위해 CountDownLatch 클래스를 사용했습니다. 동기 테스트 코드에서 회원1을 등록하는 부분만 비동기로 만들어 보겠습니다.
@Test public void testAsync() throws Exception { Log.i("dbtest", "비동기 테스트 시작"); MemberEntity m1, m2; //회원1 등록 및 확인 { Log.i("dbtest", "회원1 등록 시작"); m1 = new MemberEntity(); m1.email = "a1@email.com"; m1.name = "아무개1"; m1.phone = "010-1111-1111"; long memberRowid = memberDao.add(m1); CountDownLatch lock = new CountDownLatch(1); LiveData<MemberEntity> d = memberDao.get(memberRowid); Observer<MemberEntity> observer = new Observer<MemberEntity>(){ @Override public void onChanged(@Nullable MemberEntity v) { assertNotNull(v); assertEquals(m1.email, v.email); assertEquals(m1.name, v.name); assertEquals(m1.phone, v.phone); m1.rowid = v.rowid; d.removeObserver(this); Log.i("dbtest", "회원1 등록 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } Log.i("dbtest", "비동기 테스트 종료"); }
이 테스트를 실행하면 로그가 아래 순서로 출력되어야 합니다. Logcat에서 다음 순서로 실행되는지 꼭 확인하시길 바랍니다.
테스트 시작
비동기 테스트 시작
회원1 등록 실시
회원1 등록 완료
비동기 테스트 종료
테스트 종료
위 코드에서 CountDownLatch 클래스 관련 부분을 확인하세요. CountDownLatch를 기본 카운터 값이 1로 해서 생성했고 await()를 실행하면 정해진 시간동안 countDown()가 호출될 때까지 await() 아래 코드들이 실행되지 않습니다. 만약 이 코드를 없애면 실행 순서가 꼬이게 되어 위 로그 순서대로 출력되지 않을 것입니다. 실제로 해보셔도 됩니다.
그리고 DAO를 통해 질의 요청 후 LiveData 객체를 반환 받는데 여기에 observeForever() 함수로 observer를 등록하면 Obsesrver의 onChanged() 함수로 비동기로 질의 응답 데이타를 인자값으로 받게 됩니다. 그리고 onChanged() 내부에서 마지막에 LiveData의 removeObserver()로 observer를 제거했습니다.
이런 방법으로 나머지 시나리오를 진행하면 다음과 같습니다.
@Test public void testAsync() throws Exception { Log.i("dbtest", "비동기 테스트 시작"); MemberEntity m1, m2; //회원1 등록 및 확인 { Log.i("dbtest", "회원1 등록 시작"); m1 = new MemberEntity(); m1.email = "a1@email.com"; m1.name = "아무개1"; m1.phone = "010-1111-1111"; long memberRowid = memberDao.add(m1); CountDownLatch lock = new CountDownLatch(1); LiveData<MemberEntity> d = memberDao.get(memberRowid); Observer<MemberEntity> observer = new Observer<MemberEntity>(){ @Override public void onChanged(@Nullable MemberEntity v) { assertNotNull(v); assertEquals(m1.email, v.email); assertEquals(m1.name, v.name); assertEquals(m1.phone, v.phone); m1.rowid = v.rowid; d.removeObserver(this); Log.i("dbtest", "회원1 등록 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } //회원2 등록 및 확인 { Log.i("dbtest", "회원2 등록 시작"); m2 = new MemberEntity(); m2.email = "a2@email.com"; m2.name = "아무개2"; m2.phone = "010-2222-2222"; long memberRowid = memberDao.add(m2); CountDownLatch lock = new CountDownLatch(1); LiveData<MemberEntity> d = memberDao.get(memberRowid); Observer<MemberEntity> observer = new Observer<MemberEntity>(){ @Override public void onChanged(@Nullable MemberEntity v) { assertNotNull(v); assertEquals(m2.email, v.email); assertEquals(m2.name, v.name); assertEquals(m2.phone, v.phone); m2.rowid = v.rowid; d.removeObserver(this); Log.i("dbtest", "회원2 등록 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } //회원 리스트 2명 확인 { Log.i("dbtest", "회원 리스트 2명 확인 시작"); CountDownLatch lock = new CountDownLatch(1); LiveData<List<MemberEntity>> d = memberDao.getAll(); Observer<List<MemberEntity>> observer = new Observer<List<MemberEntity>>(){ @Override public void onChanged(@Nullable List<MemberEntity> list) { assertNotNull(list); assertEquals(list.size(), 2); d.removeObserver(this); Log.i("dbtest", "회원 리스트 2명 확인 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } //회원1 전화번호 수정 및 확인 { Log.i("dbtest", "회원1 전화번호 수정 및 확인 시작"); m1.phone = "010-1111-2222"; int cnt = memberDao.update(m1); assertEquals(cnt, 1); CountDownLatch lock = new CountDownLatch(1); LiveData<MemberEntity> d = memberDao.get(m1.rowid); Observer<MemberEntity> observer = new Observer<MemberEntity>(){ @Override public void onChanged(@Nullable MemberEntity v) { assertNotNull(v); assertEquals(m1.phone, v.phone); d.removeObserver(this); Log.i("dbtest", "회원1 전화번호 수정 및 확인 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } //회원1 삭제 및 확인 { Log.i("dbtest", "회원1 삭제 시작"); int cnt = memberDao.delete(m1); assertEquals(cnt, 1); CountDownLatch lock = new CountDownLatch(1); LiveData<MemberEntity> d = memberDao.get(m1.rowid); Observer<MemberEntity> observer = new Observer<MemberEntity>(){ @Override public void onChanged(@Nullable MemberEntity v) { assertEquals(v, null); d.removeObserver(this); Log.i("dbtest", "회원1 삭제 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } //회원 리스트 1명 확인 { Log.i("dbtest", "회원 리스트 1명 확인 시작"); CountDownLatch lock = new CountDownLatch(1); LiveData<List<MemberEntity>> d = memberDao.getAll(); Observer<List<MemberEntity>> observer = new Observer<List<MemberEntity>>(){ @Override public void onChanged(@Nullable List<MemberEntity> list) { assertNotNull(list); assertEquals(list.size(), 1); d.removeObserver(this); Log.i("dbtest", "회원 리스트 1명 확인 완료"); lock.countDown(); } }; d.observeForever(observer); lock.await(1, TimeUnit.DAYS); } Log.i("dbtest", "비동기 테스트 종료"); }
마찬가지로 함수 내부 커서를 두고 Ctrl + Shift + F10를 누르면 테스트가 실행됩니다. 결과가 “All Tests Passed”가 나오면 모든 테스트를 통과했음을 뜻합니다. 이때 주의할 점은 다시 한번 강조하지만 Log가 아래 순서로 잘 나왔는지 확인하세요.
비동기 테스트 시작
회원1 등록 시작
회원1 등록 완료
회원2 등록 시작
회원2 등록 완료
회원 리스트 2명 확인 시작
회원 리스트 2명 확인 완료
회원1 전화번호 수정 및 확인 시작
회원1 전화번호 수정 및 확인 완료
회원1 삭제 시작
회원1 삭제 완료
회원 리스트 1명 확인 시작
회원 리스트 1명 확인 완료
비동기 테스트 종료
테스트 종료
결론
이상으로 안드로이드 ACC에서 제공하는 ORM인 ROOM으로 작성한 코드를 테스트 하는 방법을 알아보았습니다. 특히나 비동기 코드를 테스트 할 수 있는 샘플 코드도 살펴보았습니다. 실무에서 데이타베이스는 복잡한 도메인을 다루기 때문에 테스트가 까다롭습니다. 테스트 시나리오를 잘 작성해서 제대로 테스트 코드를 만들어 놓으면 앞으로 있을 수정사항에 대비할 수 있습니다. 그러한 의미에서 이 글이 조금이나마 도움이 되었으면 합니다.
recent comment