[Android] 단위 테스트와 오디오 재생

MVVM 아키텍처가 매력적인 이유 중 하나는 단위 테스트(unit testing)가 쉽기 때문이다. 단위 테스트는 앱의 각 단위가 제대로 작동하는지 검사하는 작은 프로그램들을 작성하는 것이다.

본문에서는 단위 테스트 및 안드로이드 오디오 API를 쉽게 사용하도록 해주는 도구인 SoundPool 클래스를 사용한다. SoundPool 클래스는 많은 음원 파일을 메모리로 로드할 수 있으며, 재생하려는 음원의 최대 개수를 언제든 제어할 수 있다. 따라서 사용자가 앱의 모든 버튼을 동시에 마구잡이로 누르더라도 앱의 실행이나 장치에는 영향을 주지 않는다.

SoundPool 생성하기

먼저 BeatBox 클래스 내부에 음원 재생 기능을 추가한다. 우선 SoundPool 객체를 생성하는 코드를 작성하자.

SoundPool 생성하기 (BeatBox.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private const val TAG = "BeatBox"
private const val SOUNDS_FOLDER = "sample_sounds"
private const val MAX_SOUNDS = 5

class BeatBox(private val assets: AssetManager) {

val sounds: List<Sound>
private val soundPool = SoundPool.Builder()
.setMaxStreams(MAX_SOUNDS)
.build()

init {
sounds = loadSounds()
}
...
}

SoundPool 인스턴스를 생성할 때는 SoundPool.Builder 클래스의 build() 함수를 사용한다. 따라서 여기서는 SoundPool.Builder 인스턴스를 생성한 후 build()를 호출한다.

setMaxStreams(Int) 함수에서는 현재 시점에 재생할 음원의 최대 개수를 인자로 전달하여 지정할 수 있다. 코드에서는 5를 전달하는데, 따라서 다섯 개의 음원이 재생 중일 때 여섯 번째 음원을 재생하려고 하면 SoundPool이 가장 오래된 음원의 재생을 중단한다.

또한, setAudioAttributes(AudioAttributes)를 사용하면 오디오 스티름의 다른 속성들을 지정할 수 있다. 자세한 내용은 안드로이드 문서에서!

에셋 사용하기

현재 음원 파일들은 앱의 애셋으로 저장되어 잇는데, 이 파일들을 사용해서 오디오를 재생하기에 앞서 애셋의 작동 원리를 알아보자.

Sound 객체는 애셋 파일 경로를 갖고 있다. 그런데 애셋 파일 경로의 파일을 열 때는 File 클래스를 사용할 수 없고 반드시 AssetManager를 사용해야 한다.

1
2
3
4
5
val assetPath = sound.assetPath

val assetManager = context.assets

val soundData = assetManager.open(assetPath)

이렇게 하면 코틀린의 다른 InputStream을 사용할 때처럼 표준 InputStream이 반환된다.

경우에 따라서는 InputStream 대신 FileDescriptor가 필요할 수 있다. SoundPool을 사용할 때가 그렇다. 이때는 AssetManager.openFd(String)을 호출하면 된다.

1
2
3
4
5
6
7
8
9
val assetPath = sound.assetPath

val assetManager = context.assets

// AssetFileDescriptor는 FileDescriptor와 다르다
val assetFileDescriptor = assetManager.openFd(assetPath)

// … 그러나 필요하다면 다음과 같이 쉽게 보통의 FileDescriptor를 얻을 수 있다
val fileDescriptor = assetFileDescriptor.fileDescriptor

음원 로드하기

SoundPool에 음원을 로드하는 것이 다음으로 할 일이다. 오디오를 재생하는 다른 방법과 달리 SoundPool을 사용하면 응답이 빠르다. 따라서 음원 재생을 요청하면 즉시 재생이 시작된다.

단, 재생에 앞서 SoundPool로 음원을 로드해야 한다. 이때 로드할 각 음원은 자신의 정수 ID를 갖는다. 이 ID를 유지하기 위한 soundId 속성을 Sound 클래스에 추가한다.

soundId 속성 추가하기 (Sound.kt)

1
2
3
class Sound(val assetPath: String, var soundId: Int? = null) {
val name = assetPath.split("/").last().removeSuffix(WAV)
}

여기서는 soundId 속성을 null이 가능한 Int? 타입으로 지정하였다. soundId에 null 값을 지정하여 Sound의 ID 값이 없음을 알려줄 수 있기 때문이다.

다음으로는 음원을 로드한다. SoundPool에 Sound 인스턴스를 로드하기 위해 BeatBox 클래스에 load(Sound) 함수를 추가한다.

SoundPool에 음원 로드하기 (BeatBox.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class BeatBox(private val assets: AssetManager) {
...
private fun loadSounds(): List<Sound> {
...
}

private fun load(sound: Sound) {
val afd: AssetFileDescriptor = assets.openFd(sound.assetPath)
val soundId = soundPool.load(afd, 1)
sound.soundId = soundId
}
}

여기서는 soundPool.load(AssetFileDescriptor, Int) 함수를 호출해 나중에 재생할 음원 파일을 SoundPool에 로드한다. 이 함수에서는 정수 ID를 반환하는데, 음원을 유지하고 다시 재생(또는 언로드)하기 위해서다. 그리고 이 값을 앞에서 정의했던 soundId 속성에 저장한다.

openFd(String)에서는 IOException을 발생시킬 수 있으므로 load(Sound)도 IOException을 발생시킬 수 있다. 따라서 load(Sound)가 호출될 때는 항상 IOException을 처리해야 한다.

다음으로 load(Sound)를 호출해 모든 음원을 로드하는 코드를 BeatBox.loadSounds() 함수 내부에 추가한다.

모든 음원을 로드하기 (BeatBox.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private fun loadSounds(): List<Sound> {
...
val sounds = mutableListOf<Sound>()
soundNames.forEach { fileName ->
val assetPath = "$SOUNDS_FOLDER/$fileName"
val sound = Sound(assetPath)
// sounds.add(sound)
try {
load(sound)
sounds.add(sound)
} catch (ioe: IOException) {
Log.e(TAG, "Could not load sound $fileName", ioe)
}
}
return sounds
}

BeatBox 앱을 실행해 에러 없이 모든 음원이 로드되는지 확인해본다. 만일 정상적으로 로드되지 않으면 로그캣 창에 붉은색의 예외 메시지가 나타난다(아직 음원은 재생되지 않으며 화면에도 아무 변화가 없다).

음원 재생하기

BeatBox 앱에서 음원 재생이 되어야 하니 음원을 재생하는 play(Sound) 함수를 BeatBox 클래스에 추가한다.

음원 재생하기 (BeatBox.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BeatBox(private val assets: AssetManager) {
...

init {
sounds = loadSounds()
}

fun play(sound: Sound) {
sound.soundId?.let {
soundPool.play(it, 1.0f, 1.0f, 1, 0, 1.0f)
}
}
...
}

play(Sound) 함수는 음원을 재생하기 전에 해당 음원의 soundId가 null이 아닌지 확인한다. 만일 음원 로드에 실패하면 null이 될 수 있다.

일단 null 값이 아니라고 확인되면 SoundPool.play(Int, Float, Float, Int, Int, Float)를 호출해 음원을 재생한다. 매개 변수들의 내역은 다음과 같다. 음원 ID, 왼쪽 볼륨(0.0 ~ 1.0), 오른쪽 볼륨, 스트림 우선순위(0이면 최저 우선순위), 반복 재생 여부(0이면 반복 안함, -1이면 무한 반복, 그 외의 숫자는 반복 횟수), 재생률(1이면 녹음된 속도 그대로, 2는 두 배 빠르게 재생, 0.5는 절반 느리게 재생)이다.

이제는 음원 재생을 SoundViewModel에 통합할 준비가 되었다. 그 전에 테스트에 실패하도록 단위 테스트를 작성한 후 문제점을 해결하자!

테스트 라이브러리 의존성 추가하기

테스트 코드를 작성하기 전에 테스팅 도구인 MockitoHamcrest를 추가한다.

Mockito는 간단한 모의 객체(mock object)를 쉽게 생성해주는 프레임워크다. 모의 객체는 테스트를 독립적으로 할 수 있게 도와주므로, 잘못해서 동시에 다른 객체를 테스트하지 않게 해준다.

Hamcrestmatcher 라이브러리다. Matcher는 코드에 ‘일치(match)’ 조건을 쉽게 만들어주고, 만일 코드가 우리 바람과 일치하지 않으면 실패로 처리하는 도구다.

Hamcrest는 JUnit 라이브러리에 자동으로 포함되며, JUnit은 새로운 안드로이드 스튜디오 프로젝트를 생성할 때 의존성에 자동으로 포함된다. 따라서 테스트 빌드에 Mockito 의존성만 추가하면 된다.

Mockito 의존성 추가하기 (app/build.gradle)

1
2
3
4
5
6
dependencies {
...
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.3.3'
testImplementation 'org.mockito:mockito-inline:3.3.3'
}

testImplementation은 이 라이브러리 의존성이 이 앱의 테스트 빌드에만 포함됨을 의미한다. 따라서 디버그나 릴리즈 빌드로 생성된 APK에는 포함되지 않는다.

mockito-core는 모의 객체를 생성하고 구성하는 데 사용하는 모든 함수를 포함한다. mockito-inlin은 Mockito를 코틀린에서 쉽게 사용하도록 해주는 의존성이다.

기본적으로 모든 코틀린 클래스는 final이다. 즉, 클래스에 open 키워드를 지정하지 않으면 상속받는 서브 클래스를 만들 수 없으며, 함수에 open 키워드를 지정하지 않으면 서브 클래스에서 오버라이드할 수 없다. 그런데 Mockito에서 모의 객체의 클래스를 생성할 때는 클래스 상속을 해야 한다. 이때 mockito-inline 의존성을 지정하면 Mockito가 final 클래스와 함수들의 모의 객체를 생성한다. 따라서 코틀린 클래스 소스 코드를 변경하지 않고 모의 객체를 생성할 수 있다.

테스트 클래스 생성하기

단위 테스트를 작성하는 가장 편리한 방법은 테스트 프레임워크(testing framwork)를 사용하는 것이다. 테스트 프레임워크를 사용하면 안드로이드 스튜디오에서 테스트 코드를 더 쉽게 작성하고 실행할 수 있으며 결과 출력도 볼 수 있다.

안드로이드의 테스트 프레임워크로는 JUnit이 사용되며, 안드로이드 스튜디오와 잘 통합되어 있다. 가장 먼저 할 일은 JUnit 테스트 클래스를 생성하는 것이다.

SoundViewModel.kt를 열어 SoundViewModel 클래스를 클릭한 후 안드로이드 스튜디오 메인 메뉴의 Navigate -> Test를 선택한다. 그러면 안드로이드 스튜디오가 SoundViewModel 클래스와 관련된 테스트 클래스로 이동시켜준다. 그러나 여기처럼 테스트 클래스가 없으면 아래와 같이 팝업으로 새로운 테스트 클래스 생성 옵션을 제공한다.

테스트 클래스 생성 팝업

'Create New Test…'를 선택하면 대화상자(좌)가 나타난다. 아래와 같이 테스트 라이브러리를 JUnit4로 선택하고 SetUp/@Before를 체크한 후 다른 필드는 그대로 두고 OK 버튼을 누른다.

그러면 생성하는 테스트 클래스의 종류를 선택하는 대화상자(우) 나타난다.

새로운 테스트 클래스 생성하기(좌), 테스트 클래스의 종류 선택하기(우)

장치 테스트(instrumentation test)

androidTest 폴더에 있는 테스트를 장치 테스트(instrumentation test)라고 한다. 장치 테스트는 안드로이드 장치나 에뮬레이터에서 실행된다. 앱이 배포된 후 APK가 실행될 시스템 프레임워크와 API를 대상으로 앱 전체를 테스트할 수 있다는 것이 장치 테스트의 장점이다. 그러나 장치 테스트는 해당 안드로이드 운영체제에서 실행되어서 설정과 실행에 시간이 더 걸린다는 단점이 있다.

단위 테스트(unit test)

이와는 달리 test 폴더에 있는 테스트는 단위 테스트(unit test)라고 한다. 단위 테스트는 안드로이드 런타임이 아닌 로컬 컴퓨터의 JVM(Java Virtual Machine)에서 실행되므로 빠르게 이루어진다.

안드로이드에서는 '단위 테스트’라는 용어가 폭넓게 사용된다. 즉, 하나의 클래스나 단위 기능을 별개로 검사함을 의미하며, 로컬 컴퓨터에서 실행되는 단위 테스트들은 test 폴더에 포함된다. 또한, 앱의 여러 클래스나 기능이 함께 작동하는 것을 테스트하는 통합 테스트(integreation test)를 의미하기도 한다. 통합 테스트는 궁금증 해소: 통합 테스트에서 자세히 알아보자.

본문의 나머지 부분에서는 test 폴더에 있으면서 JVM에서 실행되는 각 타입의 테스트를 JVM 테스트라 하고, 하나의 클래스나 단위 기능을 검사하는 테스트만 단위 테스트라고 칭한다.

단위 테스트는 하나의 컴포넌트(주로 클래스) 자체를 테스트하는 것이므로 작성할 수 있는 가장 작은 종류의 테스트다. 그리고 테스트를 실행하고자 전체 앱이나 장치를 사용할 필요가 없으며, 테스트를 여러 번 실행해도 충분할 만큼 빠르게 실행된다. 따라서 하나의 컴포넌트르 테스트할 때 장치 테스트로 실행하는 경우는 거의 없다. 이 점을 염두에 두고 위의 이미지의 우측 대화상자와 같이 androidTest 폴더가 아닌 test 폴더를 선택하고 OK 버튼을 누른다. 그러면 안드로이드 스튜디오가 SoundViewModelTest.kt를 생성하고 편집기 창에 열어준다.

Project 뷰로 보면 app/src 패키지 밑에 test와 androidTest 패키지가 생성되어 있다

테스트 설정하기

자동 생성된 SoundViewModelTest 클래스는 setUp() 함수만 갖고 있다.

1
2
3
4
5
6
class SoundViewModelTest {

@Before
fun setUp() {
}
}

테스트 클래스에서 특정 클래스를 테스트하는 데 필요한 작업은 대부분 같다. 즉, 테스트할 클래스의 인스턴스와 이 인스턴스가 필요로 하는 다른 객체들도 생성한다. 이에 따라 JUnit에서는 @Before라는 애노테이션을 제공한다. @Before가 지정된 함수 내부의 코드는 각 테스트가 실행되기 전에 한번만 실행되며, JUnit 테스트 클래스는 @Before가 지정된 setUp()이라는 이름의 함수를 갖는다.

테스트 대상 설정하기

setUp() 함수 내부에서는 테스트할 SoundViewModel의 인스턴스와 Sound의 인스턴스를 생성해야 한다. SoundViewModel이 음원 제목을 보여주는 방법을 알려면 Sound 인스턴스를 필요로 하기 때문이다.

SoundViewModel과 Sound의 인스턴스를 생성하자.

테스트 대상인 SoundViewModel 인스턴스 생성하기 (SSoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class SoundViewModelTest {

private lateinit var sound: Sound
private lateinit var subject: SoundViewModel

@Before
fun setUp() {
sound = Sound("assetPath")
subject = SoundViewModel()
subject.sound = sound
}
}

지금까지는 SoundViewModel 인스턴스를 참조하는 속성 이름을 soundViewModel로 사용했는데, 여기서는 subject라고 했다. 테스트의 대상이 되는 객체이므로 subject 라고 하는 것이 오히려 알기 쉽고, 테스트 함수를 다른 클래스로 옮기더라도 속성 이름을 변경할 필요가 없기 때문이다.

테스트 작성하기

setUp() 함수가 작성되었으니 이제는 테스트를 작성해본다. @Test 애노테이션이 지정된 테스트 클래스의 함수를 테스트라고 한다.

우선 SoundViewModel의 title 속성값이 Sound의 name 속성값과 일치하는지 검사하는 테스트 함수를 작성하자.

title 속성 테스트하기 (SoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
import org.junit.Assert.*
import org.hamcrest.core.Is.`is`
import org.hamcrest.MatcherAssert

class SoundViewModelTest {
...

@Before
fun setUp() {
...
}

@Test
fun exposesSoundNameAsTitle() {
MatcherAssert.assertThat(subject.title, `is`(sound.name))
}
}

(assertThat(…) 함수와 is(…) 함수는 위의 코드 대로 import해야 한다.)

이 테스트에서는 assertThat(…) 함수와 is(…) 함수를 같이 사용하며, '테스트 대상의 title 속성값이 Sound의 name 속성값과 같아야 함’을 나타낸다. 따라서 두 속성값이 다르면 테스트는 실패한다.

프로젝트 도구 창의 ‘app/java/com.june0122.beatbox (test) 밑에 있는 SoundViewModelTest에서 오른쪽 마우스 버튼을 클릭한 후 Run 'SoundViewModelTest’를 선택하면 단위 테스트가 실행되고 안드로이드 스튜디오에서 아래와 같은 실행 결과를 보여준다.

테스트과 통과됨

여기서는 한 개의 테스트가 실행되어 통과되었음을 보여준다(Tests passed: 1). 만일 테스트가 실패하면 이에 관한 자세한 내용도 보여준다.

객체의 상호작용 테스트하기

다음으로 SoundViewModel과 BeatBox.play(Sound) 함수가 잘 연동되는지 검사하는 테스트를 생성한다.

이때는 주로 연동을 테스트하는 함수를 테스트 클래스에 작성한다. 우선 onButtonClicked()를 호출하는 테스트 함수를 작성한다(onButtonClicked() 함수는 잠시 후에 SoundViewModel에 추가한다).

onButtonClicked()를 호출하는 테스트 함수 작성하기 (SoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class SoundViewModelTest {
...
@Test
fun exposesSoundNameAsTitle() {
MatcherAssert.assertThat(subject.title, `is`(sound.name))
}

@Test
fun callsBeatBoxPlayOnButtonClicked() {
subject.onButtonClicked()
}
}

여기서 onButtonClicked() 함수는 아직 작성되지 않았기에 붉은색의 에러로 표시된다. 이 함수를 클릭한 후 Alt+Enter [Option+Return] 키를 누르고 Create member function 'SoundViewModel.onButtonClicked’를 선택하면 이 함수가 SoundViewModel.kt에 자동 생성된다. 반드시 TODO를 주석으로 처리해주자!

자동 생성된 onButtonClicked() (SoundViewModel.kt)

1
2
3
4
5
6
class SoundViewModel : BaseObservable() {
fun onButtonClicked() {
// TODO("Not yet implemented")
}
...
}

지금은 onButtonClicked() 함수를 비어 있는 상태로 두고 SoundViewModelTest 클래스를 다시 본다.

테스트 함수인 callsBeatBoxPlayOnButtonClicked()에서는 SoundViewModel의 onButtonClicked() 함수를 호출한다. 그러나 이 함수에서 BeatBox.play(Sound)를 호출하는 것을 검사해야 한다. 이것을 구현하기 위해 맨 먼저 할 일은 SoundViewModel에 BeatBox 객체를 제공하는 것이다.

이때 테스트 함수에서 BeatBox 인스턴스를 생성하고 SoundViewModel 생성자에 전달할 수 있다. 그러나 단위 테스트에서 이렇게하면 문제가 생긴다. 만일 BeatBox에서 문제가 생기면 이것을 사용하는 SoundViewModel도 덩달아 문제가 생겨서 SoundViewModel의 단위 테스트가 실패할 수 있기 때문이다. 이것은 우리가 원하는 바가 아니다. SoundViewModel의 단위 테스트는 SoundViewModel에 국한된 문제가 있을 때만 실패해야 한다.

다시 말해서 SoundViewModel 자체의 작동과 다른 클래스와의 상호 작용은 별개로 테스트해야 한다. 이것이 단위 테스트에서 중요한 사항이다.

이런 문제를 해결하고자 BeatBox에 모의 객체(mock object)를 사용한다. 이때 모의 객체는 BeatBox의 서브 클래스가 되며, BeatBox와 같은 함수들을 갖는다. 단, 모든 함수가 아무 일도 하지 않으므로 BeatBox에서는 문제가 생기지 않는다. 따라서 SoundViewModel의 테스트에서는 BeatBox의 작동과는 무관하게 SoundViewModel이 BeatBox를 사용하는 것이 맞는지 검사할 수 있다.

Mockito를 사용해서 모의 객체를 생성할 때는 static 함수인 mock(Class)를 호출하며, 이때 모의 객체를 사용할 클래스를 인자로 전달한다. BeatBox의 모의 객체를 생성하고 이 객체의 참조를 갖는 속성을 SoundViewModelTest에 추가한다.

BeatBox의 모의 객체 생성하기 (SoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.mockito.Mockito.mock

class SoundViewModelTest {

private lateinit var beatBox: BeatBox
private lateinit var sound: Sound
private lateinit var subject: SoundViewModel

@Before
fun setUp() {
beatBox = mock(BeatBox::class.java)
sound = Sound("assetPath")
subject = SoundViewModel()
subject.sound = sound
}
...
}

mock(Class) 함수는 클래스 참조처럼 import되며, BeatBox의 모의 객체를 자동으로 생성한다.

BeatBox의 모의 객체가 준비되었으니 이제는 play(Sound) 함수가 호출되는지 검사하는 테스트 작성을 마무리한다. 모든 Mockito 모의 객체는 자신의 함수들이 호출된 기록은 물론이고, 각 호출에 전달된 매개변수 내역을 유지한다. 그리고 Mockito의 verify(Object) 함수를 사용하면 기대한 대로 모의 객체 함수들이 호출되었는지 확인할 수 있다.

SoundViewModel에 연결된 Sound 객체를 사용해서 onButtonClicked()BeatBox.play(Sound)를 호출하는지 확인하기 위해 verify(Object)를 호출한다(Sound는 문제가 될 함수가 없는 데이터 객체이므로 모의 객체를 생성할 필요가 없다).

BeatBox.play(Sound)가 호출되는지 검사하기 (SoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
class SoundViewModelTest {
...
@Test
fun callsBeatBoxPlayOnButtonClicked() {
subject.onButtonClicked()

verify(beatBox).play(sound) // verify(Object) 호출
}
}

여기서는 플루언트 인터페이스(fluent interface)를 사용한다(플루언트 인터페이슨느 코드를 알기 쉽게 해주며, 일반적으로 함수의 연쇄 호출 형태로 구현된다). 즉, verify(beatBox)에서 BeatBox 객체를 반환하므로 연속해서 이 객체의 play(sound) 함수를 호출할 수 있다. verify(beatBox).play(sound)는 다음과 같다.

1
2
verify(beatBox)
beatBox.play(sound)

여기서 verify(beatBox)는 'beatBox의 함수가 호출되었는지 검사하려고 함’이라는 의미이며, 그다음 함수 호출인 play(sound)는 ’play(sound) 함수가 이처럼 호출되었는지 검사하라’는 의미로 생각할 수 있다. 결국 verify(beatBox).play(sound)는 sound를 인자로 받는 beatBox의 play(sound) 함수가 호출되었는지 확인하라는 의미다.

물론, 지금은 이런 일이 생기지 않는다. SoundViewModel.onButtonClicked() 함수의 실행 코드가 아직 없어서 beatBox.play(sound)가 호출되지 않았기 때문이다. 또한, SoundViewModel은 beatBox 참조를 갖고 있지 않아서 beatBox의 어떤 함수도 호출할 수 없다. 따라서 테스트는 실패한다. 현재는 테스트를 먼저 작성했으니 이렇게 되는 것이 정상이다. 처음부터 테스트가 실패하지 않는다면 어떤 것도 테스트할 필요가 없다.

테스트를 실행해 아래와 같이 테스트가 실패하는 것을 확인해보자.

테스트 실패 내역 출력

출력 메시지는 다음과 같다.

1
2
3
4
5
6
Wanted but not invoked:
beatBox.play(
com.june0122.beatbox.Sound@1af146
);
-> at com.june0122.beatbox.BeatBox.play(BeatBox.kt:26)
Actually, there were zero interactions with this mock.

beatBox.play(sound)의 호출을 기대했지만 호출되지 않았다.

assertThat(…)과 마찬가지로 verify(Object)은 내부적으로 어서션(assertion)을 생성한다. 그리고 어서션에 어긋나면 테스트를 실패로 처리하고, 로그에 그 이유를 설명하는 출력을 남긴다. 그리고 어서션에 어긋나면 테스트를 실패로 처리하고, 로그에 그 이유를 설명하는 출력을 남긴다.

이제는 테스트의 결함을 수정할 때가 되었다. 우선 SoundViewModel의 생성자에서 BeatBox 인스턴스를 받도록 속성을 추가한다(여기서 기본 생성자에 선언된 beatBox는 매개변수이면서 속성으로도 생성된다).

BeatBox를 SoundViewModel에 제공하기 (SoundViewModel.kt)

1
2
3
class SoundViewModel(private val beatBox: BeatBox) : BaseObservable() {
...
}

이렇게 변경하면 SoundHolder 클래스와 SoundViewModelTest 클래스에서 에러가 발생한다.

  • SoundHolder에서 SoundViewModel 인스턴스를 생성할 때 beatBox 객체를 생성자에 전달하도록 변경
  • BeatBox의 모의 객체를 SoundViewModel 생성자에 전달

SoundHolder의 에러 수정 (MainActivity.kt)

1
2
3
4
5
6
7
8
private inner class SoundHolder(private val binding: ListItemSoundBinding) : RecyclerView.ViewHolder(binding.root) {
init {
binding.viewModel = SoundViewModel(beatBox)
}
fun bind(sound: Sound) {
...
}
}

테스트에 BeatBox 모의 객체 제공 (SoundViewModelTest.kt)

1
2
3
4
5
6
7
8
9
10
11
class SoundViewModelTest {
...
@Before
fun setUp() {
beatBox = mock(BeatBox::class.java)
sound = Sound("assetPath")
subject = SoundViewModel(beatBox)
subject.sound = sound
}
...
}

다음으로 테스트에서 기대하는 것을 수행하도록 onButtonClicked()를 구현한다.

onButtonClicked() 구현하기 (SoundViewModel.kt)

1
2
3
4
5
6
7
class SoundViewModel(private val beatBox: BeatBox) : BaseObservable() {
...
fun onButtonClicked() {
sound?.let {
beatBox.play(it)
}
}

테스트를 다시 실행하면 이번에는 테스트과 통과되었음을 Run 도구 창에서 확인할 수 있다.

데이터 바인딩 콜백

이제는 버튼들이 제대로 작동하는지 테스트하는 것만 남았다. 따라서 onButtonClicked()를 버튼과 연결해야 한다.

사용자 인터페이스인 레이아웃에 데이터를 넣을 때 데이터 바인딩을 사용할 수 있듯이, 클릭 리스너를 연결할 때도 람다식으로 데이터 바인딩을 할 수 있다.

버튼 클릭을 SoundViewModel.onButtonClicked()에 연결하기 위해 데이터 바인딩으로 호출되는 콜백 표현식을 추가한다.

버튼을 코드와 연결하기 (list_item_sound.xml)

1
2
3
4
5
6
7
<Button
android:layout_width="match_parent"
android:layout_height="120dp"
...
android:onClick="@{() -> viewModel.onButtonClicked()}"
android:text="@{viewModel.title}"
tools:text="Sound name" />

이제는 BeatBox 앱을 실행하고 음원 제목을 보여주는 버튼을 누르면 음원이 재생되어야 한다. 테스트를 실행한 뒤에는 실행 구성(run configuration)이 변경되므로 실행 구성 드롭다운을 클릭해 app으로 변경한다.

실행 구성을 변경하기

음원 내리기

음원 재생이 잘 작동하지만 아직 마무리해야 할 것이 있다. 음원 재생이 끝나면 SoundPool.release()를 호출해 SoundPool을 클린업(리소스 해제)해야 한다. 이 일을 하는 BeatBox.release() 함수를 추가한다.

SoundPool 클린업하기 (BeatBox.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BeatBox(private val assets: AssetManager) {
...
fun play(sound: Sound) {
...
}

fun release() {
soundPool.release()
}

private fun loadSounds(): List<Sound> {
...
}
...
}

그다음에 BeatBox.release() 함수를 호출하는 onDestroy() 함수를 MainActivity에 추가한다. 액티비티가 소멸하면 SoundPool도 클린업해야 하기 때문이다.

onDestroy() 함수 추가하기 (MainActivity.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : AppCompatActivity() {

private lateinit var beatBox: BeatBox

override fun onCreate(savedInstanceState: Bundle?) {
...
}

override fun onDestroy() {
super.onDestroy()
beatBox.release()
}
...
}

BeatBox 앱을 다시 실행해 release() 함수가 제대로 작동하는지 확인해보자. 대부분 짧은 소리만 나지만, 조금 긴 소리의 음원이 재생되는 동안에 장치를 회전하거나 백 버튼을 누르면 재생이 중단된다.

궁금증 해소 💁🏻‍♂️ : 통합 테스트

앞의 SoundViewModelTest는 단위 테스트였지만, 통합 테스트(integration test)를 생성할 수도 있다. 통합 테스트가 무엇일까?

단위 테스트에서는 테스트 항목이 개별 클래스이지만, 통합 테스트는 여러 클래스나 컴포넌트가 함께 작동하는 앱의 일부가 테스트 대상이다. 단위 테스트와 통합 테스트 모두 중요하지만, 서로 다른 목적을 갖는다.

  • 단위 테스트에서는 각 단위 클래스가 올바르게 작동하는지, 기대한 대로 다른 단위와 제대로 상호 작용하는지 확인한다.
  • 반면에 통합 테스트에서는 개별적으로 테스트된 단위들과 기능이 올바르게 통합되어 작동하는지 검사한다.

통합 테스트는 데이터베이스 사용과 같은 UI가 아닌 부분을 검사하기 위해 작성한다. 그런데 안드로이드에서는 UI와 상호 작용하면서 기대한 대로 잘 되는지 검사하기 때문에 UI 수준에서 앱을 테스트하고자 이러한 테스트를 작성하는 경우가 많다. 따라서 대개는 화면별로 통합 테스트를 작성한다. 예를 들어, MainActivity 화면이 나타날 때 첫 번째 버튼의 제목이 sample_sounds의 첫 번째 파일 이름(예를 들어, MainActivity) 화면이 나타날 때 첫 번째 버튼의 제목이 sample_sounds의 첫 번째 파일 이름(예를 들어, 65_cjipie)을 보여주는지 테스트할 수 있다.

UI 수준의 통합 테스트는 액티비티나 프래그먼트와 같은 프레임워크 클래스가 필요하며, JVM 단위 테스트에서 사용할 수 없는 시스템 서비스, 파일 시스템 등도 필요할 수 있다. 이런 이유로 안드로이드에서는 통합 테스트는 주로 장치 테스트로 구현된다.

통합 테스트는 기대한 대로 앱이 작동하면 통과된다. 구현될 때 통과되는 것이 아니다. 버튼 ID의 이름을 변경해도 앱의 작동에는 영향을 주지 않는다. 그런데 findViewById(R.id.button)을 호출해 해당 버튼이 올바른 텍스트를 보여주는지 확인하는 것은 통합 테스트로 작성할 수 있다. 이때 안드로이드에서는 findViewById(R.id.button) 대신 UI 테스트 프레임워크를 사용해서 통합 테스트를 작성한다. 이렇게 하면 기대하는 텍스트를 갖는 버튼이 화면에 있는지 쉽게 확인할 수 있다.

Espresso는 안드로이드 앱을 테스트하는 구글의 UI 테스트 프레임워크다. 안드로이드 스튜디오의 프로젝트 도구 창에서 Gradle Scripts 밑의 build.gradle (Module: BeatBox.app) 파일을 보면 다음과 같이 기본적으로 라이브러리 의존성에 추가되어 있다(맨 끝의 버전 번호는 변경될 수 있다).

1
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

이처럼 Espresso가 의존성에 포함되면 통합 테스트를 하기 위해 시작될 액티비티에 관한 어서션을 만들 수 있다. 여기서는 첫 번째 sample_sounds 테스트 파일 이름을 사용하는 뷰(버튼)가 화면에 있어야 한다는 어서션을 만드는 방법을 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
@RunWith(AnbdroidJUnit4::class)
class MainActivityTest {

@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)

@Test
fun showsFirstFileName() {
onView(withText("65_cjipie"))
.check(matches(isDisplayed()))
}
}

여기서는 두 개의 애노테이션이 코드를 실행한다. @RunWith(AnbdroidJUnit4::class)는 MainActivityTest가 안드로이드 장치 테스트이며, 액티비티 및 다른 안드로이드 런타임 도구와 함께 작동함을 나타낸다. 그다음에 있는 activityRule의 @get:Rule은 각 테스트를 실행하기 전에 MainActivity의 인스턴스를 시작시켜야 함을 JUnit에게 알린다.

테스트가 설정되었으니 이제는 테스트할 MainActivity에 관한 어서션을 만들 수 있다. showsFirstFileName()onView(withText("65_cjipie"))에서는 테스트를 수행하기 위해 *“65_cjipie”*라는 텍스트를 갖는 뷰(버튼)을 찾는다. 그다음에 check(matches(isDisplayed()))를 호출해 해당 뷰가 화면에 보이는지 확인한다. 만일 그런 텍스트를 갖는 뷰가 없다면 check(…)는 실패한다. check(…) 함수는 뷰에 관한 assertThat(…) 형태의 어서션을 만드는 Espresso의 방법이다.

버튼처럼 뷰를 클릭해야 할 때는 클릭한 결과를 검사하는 어서션을 만들면 된다. 이때도 다음과 같이 Espresso를 사용할 수 있다.

1
2
onView(withText("65_cjipie"))
.perform(click())

이처럼 뷰와 상호 작용할 때는 Espresso가 테스트를 멈추고 기다리며, UI의 변경이 끝났을 때를 감지한다. 그런데 Espresso를 더 오래 기다리게 할 때는 IdlingResource의 서브 클래스를 사용해 Espresso에게 앱의 작업이 아직 끝나지 않았음을 알린다.

Espresso로 UI를 테스트하는 방법에 관한 자세한 정보는 Espresso 문서를 참고하자.

다시 말하지만 통합 테스트와 단위 테스트는 그 목적이 다르다. 대부분의 사람은 단위 테스트를 먼저 시작한다. 앱의 개별적인 부분들의 작동을 정의하고 검사하는데 도움이 되기 때문이다. 통합 테스트는 그런 개별적인 부분들에 의존해 여러 부분이 하나로 함께 잘 작동하는지 검사한다. 두 테스트는 각각 앱의 건강에 관한 서로 다른 중요한 관점을 제공하므로 테스트를 같이 하는 것이 가장 좋다.

궁금증 해소 💁🏻‍♂️ : 모의 객체와 테스트

통합 테스트에서는 모의 객체가 단위 테스트 때와는 다른 역할을 담당한다. 모의 객체는 다른 컴포넌트를 테스트와 관계없는 것처럼 만들어서 테스트할 컴포넌트를 격리하기 위해 존재한다. 단위 테스트는 클래스 단위로 테스트한다. 그런데 각 클래스는 다른 클래스들에 대해 의존성을 가질 수 있으므로 테스트 클래스들은 서로 다른 모의 객체들을 가지며, 모의 객체가 어떻게 작동하는가는 중요하지 않다. 따라서 간단한 모의 객체를 쉽게 생성해주는 모의 프레임워크(예를 들어, Mockito)가 단위 테스트에는 안성맞춤이다.

이와는 달리 통합 테스트는 앱 전체를 한 덩어리로 테스트한다. 따라서 앱의 각 부분을 격리하는 대신에 앱이 상호 작용하는 외부의 것과 격리하기 위한 모의 객체를 사용한다. 예를 들어, 모의 데이터와 응답을 반호나하는 웹 서비스를 제공하는 경우다. BeatBox 앱에서는 특정 음원 파일이 재생되었음을 알려주는 모의 SoundPool을 제공할 수 있을 것이다. 모의 객체는 점점 더 많아지고 여러 테스트에서 공유되며 모의 행동을 구현하므로, 통합 테스트에서는 자동화된 모의 프레임워크를 사용하지 말고 모의 객체를 직접 작성하는 것이 좋다.

어떤 경우든 다음 규칙이 적용된다. 즉, 테스트 중인 컴포넌트의 경계에 있는 개체들을 모의 객체로 만든다. 이렇게 하면 테스트하려는 범위에만 집중할 수 있다. 또한, 테스트 컴포넌트 외의 다른 컴포넌트와는 무관하게 테스트 컴포넌트에 문제가 있을 때만 테스트가 실패하므로 정확하게 테스트할 수 있다.

댓글