안드로이드 유닛 테스트 소개
유닛 테스트는 소프트웨어에서 독립적으로 실행될 수 있는 가장 작은 부분 (함수, 메소드)와 같은 부분을 테스트 하는 것이다.
안드로이드에서 제공하는 유닛 테스트 라이브러리는 아래와 같다.
// Local unit tests
testImplementation "androidx.test:core:1.4.0"
testImplementation "junit:junit:4.13.2"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1"
testImplementation "com.google.truth:truth:1.1.3"
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"
testImplementation "io.mockk:mockk:1.10.5"
debugImplementation "androidx.compose.ui:ui-test-manifest:1.1.0-alpha04"
1년전 테스트 예제 소스를 가져온 것이기에 지금(2023.02.02)와 버전에서 차이가 있다. 대표적으로 보이는 것이 Junit4, truth, mockk이 있다.
코드와 함께 유닛 테스트를 알아보도록 하자.
예제 1) GetNotes.kt
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.repository.NoteRepository
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.NoteOrder
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.OrderType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetNotes(
private val repository: NoteRepository
) {
operator fun invoke(
noteOrder: NoteOrder = NoteOrder.Date(OrderType.Descending)
): Flow<List<Note>> {
return repository.getNotes().map { notes ->
when(noteOrder.orderType) {
is OrderType.Ascending -> {
when(noteOrder) {
is NoteOrder.Title -> notes.sortedBy { it.title.lowercase() }
is NoteOrder.Date -> notes.sortedBy { it.timestamp }
is NoteOrder.Color -> notes.sortedBy { it.color }
}
}
is OrderType.Descending -> {
when(noteOrder) {
is NoteOrder.Title -> notes.sortedByDescending { it.title.lowercase() }
is NoteOrder.Date -> notes.sortedByDescending { it.timestamp }
is NoteOrder.Color -> notes.sortedByDescending { it.color }
}
}
}
}
}
}
위 코드는 NoteRepository에서 Note List를 불러오는 유스케이스이다. 해당 함수의 파라미터로 Sealed Class인 OrderType을 넘겨줄 수 있다. 만약 오름차순일 경우(Ascending), Note의 제목과 날짜 색을 오름차순으로 정렬해서 반환한다. (내림차순일 경우 내림차순으로 정렬해 반환) 해당 유스케이스에서 테스트 할 것은 정말 오름차순/내림차순으로 정렬되었는지 테스트 할 수 있다.
또한 해당 유스케이스의 경우 파라미터로 NoteRepository를 받고 있다. 테스트에서 사용되는 데이터를 DB에 접근하여 사용하는 것을 방지하기 위해 FakeRepository를 만들어 사용한다.
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.repository.NoteRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class FakeNoteRepository: NoteRepository {
private val notes = mutableListOf<Note>()
// 가짜 레포지토리를 만들어 간단한 리스트를 통해서 실제 데이터베이스를 시뮬레이션하고
// 해당 유스케이스는 가짜 레포지토리를 사용함.
override fun getNotes(): Flow<List<Note>> {
return flow { emit(notes) }
}
override suspend fun getNoteById(id: Int): Note? {
return notes.find{ it.id == id}
}
override suspend fun insertNote(note: Note) {
notes.add(note)
}
override suspend fun deleteNote(note: Note) {
notes.remove(note)
}
}
진짜 Repository는 내부 DB에 접근하는 반면, FakeRepository에서는 notes 리스트를 만들어 해당 리스트에 CRUD 작업을 진행한다.
GetNotesTest.kt
import com.google.common.truth.Truth.assertThat
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.use_case.data.repository.FakeNoteRepository
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.NoteOrder
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.OrderType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class GetNotesTest{
private lateinit var getNotes: GetNotes
private lateinit var fakeRepository: FakeNoteRepository
@Before // 이전 세팅 함수, 유닛 테스트 전 설정함수, 객체를 초기화 하는 작업이 해당
fun setUp(){
fakeRepository = FakeNoteRepository()
getNotes = GetNotes(fakeRepository)
val notesToInsert = mutableListOf<Note>()
('a' .. 'z').forEachIndexed{ index, c ->
notesToInsert.add(
Note(
title = c.toString(),
content = c.toString(),
timestamp = index.toLong(),
color = index
)
)
}
notesToInsert.shuffle()
runBlocking {
notesToInsert.forEach{
fakeRepository.insertNote(it)
}
}
}
// 테스트는 무엇을 하는지, 테스트는 무엇을 반환하는지 네이밍
@Test
fun `Order notes by title ascending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Title(OrderType.Ascending)).first()
// 제목을 오름차순으로 정렬했는데, 정말 정렬이 되었는지 확인
for(i in 0 .. notes.size - 2){
assertThat(notes[i].title).isLessThan(notes[i+1].title) // 주장 함수, 무언가를 확인하고 맞으면 성공
}
}
}
위의 코드는 GetNotes를 테스트 하는 클래스이다. 해당 테스트 코드에 어노테이션이 있는데, 이는 테스트 코드를 작성할 때 진행할 섹션을 담당한다. 해당 코드에서는 @Before, @Test만 사용하였는데, 다른 Junit 어노테이션의 종류에는 @Before, @After, @Test, @Ignore 등이 있다.
사용한 어노테이션중 @Before은 @Test 섹션이 실행되기 전 실행되는 섹션으로 보통 테스트 전 객체를 초기화 하거나, 테스트 셋업을 위해 사용된다. 위의 코드에서는 FakeRepository를 초기화 하고, 오름차순, 내림차순을 검증하기 위해 데이터를 넣는 작업이 해당 섹션에 포함되었다.
@Test는 실제 테스트를 실행하는 섹션으로 위의 코드에서는 현재 인덱스와 다음 인덱스의 제목의 오름차순으로 정렬했는지 확인하고 있다.
실제 테스트 코드가 제대로 작동하고 있는지 확인하기 위해 내림차순으로 데이터가 들어가도록 설정하였다.
내림차순으로 코드를 수정하였을 때는 아래와 같이 테스트가 실패하는 것을 확인할 수 있다.
GetNotesTest.kt (전체 코드, 다른 요소와 내림차순까지 모두 구현)
import com.google.common.truth.Truth.assertThat
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.use_case.data.repository.FakeNoteRepository
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.NoteOrder
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.util.OrderType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class GetNotesTest{
private lateinit var getNotes: GetNotes
private lateinit var fakeRepository: FakeNoteRepository
@Before // 이전 세팅 함수, 유닛 테스트 전 설정함수, 객체를 초기화 하는 작업이 해당
fun setUp(){
fakeRepository = FakeNoteRepository()
getNotes = GetNotes(fakeRepository)
val notesToInsert = mutableListOf<Note>()
('a' .. 'z').forEachIndexed{ index, c ->
notesToInsert.add(
Note(
title = c.toString(),
content = c.toString(),
timestamp = index.toLong(),
color = index
)
)
}
notesToInsert.shuffle()
runBlocking {
notesToInsert.forEach{
fakeRepository.insertNote(it)
}
}
}
// 테스트는 무엇을 하는지, 테스트는 무엇을 반환하는지 네이밍
@Test
fun `Order notes by title descending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Title(OrderType.Descending)).first()
// 제목을 오름차순으로 정렬했는데, 정말 정렬이 되었는지 확인
for(i in 0 .. notes.size - 2){
assertThat(notes[i].title).isGreaterThan(notes[i+1].title) // 주장 함수, 무언가를 확인하고 맞으면 성공
}
}
@Test
fun `Order notes by date ascending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Date(OrderType.Ascending)).first()
for(i in 0 .. notes.size - 2){
assertThat(notes[i].timestamp).isLessThan(notes[i+1].timestamp)
}
}
@Test
fun `Order notes by date descending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Date(OrderType.Descending)).first()
for(i in 0 .. notes.size - 2){
assertThat(notes[i].timestamp).isGreaterThan(notes[i+1].timestamp)
}
}
@Test
fun `Order notes by color ascending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Color(OrderType.Ascending)).first()
for(i in 0 .. notes.size - 2){
assertThat(notes[i].color).isLessThan(notes[i+1].color)
}
}
@Test
fun `Order notes by color descending, correct order`() = runBlocking(){
val notes = getNotes(NoteOrder.Color(OrderType.Descending)).first()
for(i in 0 .. notes.size - 2){
assertThat(notes[i].color).isGreaterThan(notes[i+1].color)
}
}
}
예제 2) AddNote.kt
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.InvalidNoteException
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.repository.NoteRepository
class AddNote(
private val repository: NoteRepository
) {
@Throws(InvalidNoteException::class)
suspend operator fun invoke(note: Note) {
if(note.title.isBlank()) {
throw InvalidNoteException("The title of the note can't be empty.")
}
if(note.content.isBlank()) {
throw InvalidNoteException("The content of the note can't be empty.")
}
repository.insertNote(note)
}
}
위의 코드는 입력으로 들어온 데이터의 제목, 내용이 빈칸으로 들어온 경우 예외를 발생시키고, 그렇지 않은 경우에는 데이터를 삽입하는 코드이다.
해당 코드에서 제목이 빈칸으로 들어왔을 때 제대로 예외를 발생시키는지, 내용이 빈칸으로 들어왔을 때 예외를 발생시키는지, 정상적인 입력(내용과 제목이 빈칸이 아닌 경우) 제대로 데이터가 들어가는지 테스트 해볼 수 있다.
AddNoteTest.kt
import com.google.common.truth.Truth.assertThat
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.InvalidNoteException
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.model.Note
import com.plcoding.cleanarchitecturenoteapp.feature_note.domain.use_case.data.repository.FakeNoteRepository
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
class AddNoteTest{
private lateinit var addNote : AddNote
private lateinit var fakeRepository: FakeNoteRepository
@Before
fun setUp(){
fakeRepository = FakeNoteRepository()
addNote = AddNote(fakeRepository)
}
@Test
fun `Check the title of note is blank, correct check`(){
val blankTitleNote = Note(
title = " ",
content = "hello, world",
color = 2,
timestamp = 22
)
runBlocking {
try {
addNote(blankTitleNote)
} catch (e: InvalidNoteException) {
assertThat(e.message == "The title of the note can't be empty.").isTrue()
}
}
}
@Test
fun `Check the content of note is blank, correct check`(){
val blankTitleNote = Note(
title = "hello, world",
content = " ",
color = 2,
timestamp = 22
)
runBlocking {
try {
addNote(blankTitleNote)
} catch (e: InvalidNoteException) {
assertThat(e.message == "The content of the note can't be empty.").isTrue()
}
}
}
@Test
fun `Insert note into Repository, correct Inserting`(){
// Given
val noteItem = Note(
title = "hello",
content = "world",
color = 1,
timestamp = 2
)
runBlocking {
// When
addNote(noteItem)
// Then
fakeRepository.getNotes().collect { noteList ->
assertThat(noteItem in noteList).isTrue()
}
}
}
}
위와 같이 테스트 코드를 작성할 수 있다. 예외를 발생시키는 경우는 Try-Catch문으로 예외를 발생시켜 예외 메세지를 확인하였다. 데이터가 정상적으로 들어갔는지 테스트 하기위해 데이터를 넣고, 모든 데이터를 받아온 후 해당 데이터가 존재하는지 확인하였다. 마지막 테스트 메소드 주석에서 테스트 작성 법칙을 확인할 수 있는데, 테스트 작성 법칙은 Given, When, Then으로 구성된다.
Given - 테스트의 조건/환경/기본 상태이다.
When - 테스트 대상 행위의 발생이다.
Then - 의도한 결과의 검증이다.
예시) 계좌 송금 : GIVEN : 계좌 개설, WHEN : 송금, THEN : 현재 금액 확인
유닛 테스트 함수의 네이밍
직접 작성한 테스트 함수에서는 백틱을 사용하고, 무엇을 테스트하는지, 어떠한 결과가 나와야하는지로 구성했다.
테스트 네이밍 컨벤션에는 아래와 같은 것이 있다.
- Test name should express a specific requirement (명백한 요구사항을 표현해야 한다.)
- Test name could include the expected input or state and the expected result for that input or state (예상되는 입력 혹은 상태 그리고 예상되는 입력과 상태에 따라 예상된 결과가 나올 수 있다.)
- Test name should be presented as a statement or fact of life that expresses workflows and outputs (출력과 어크플로우를 표현하는 진술이나 사실로 표현되어야 한다.)
- Test name could include the name of the tested method or class (테스트 되는 메소드나 함수의 이름이 포함될 수 있다.)
- 출처 : https://medium.com/@stefanovskyi/unit-test-naming-conventions-dd9208eadbea
https://github.com/philipplackner/CleanArchitectureNoteApp
GitHub - philipplackner/CleanArchitectureNoteApp
Contribute to philipplackner/CleanArchitectureNoteApp development by creating an account on GitHub.
github.com
https://yoon-dailylife.tistory.com/114
Android) 테스트 코드 왜 작성 해야 할까? 예제로 알아보자
안드로이드에서 테스트 코드 "왜" 작성해야 할까? 코드를 작성하여 기능을 구현하고, 그 기능이 제대로 작동하는지 에뮬레이터 혹은 디바이스에서 직접 결과를 정성스럽게 확인 -> 에러가 발생
yoon-dailylife.tistory.com