요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
유닛테스트는 간단히 말하자면 어떤 기능이 잘 동작하는지 테스트하는 것입니다. 그러기 위해서 "독립적으로" 테스트 할 필요가 있고, 테스트가 잘 이루어졌다라는 것을 파악하기 위해 특정 변수의 값이 우리가 예측한 값과 같은지를 확인하는 과정을 거칩니다.
유닛테스트의 장점이라고 한다면 아래와 같습니다.
- 어떤 기능이 의도한대로 잘 동작하는지 확인 가능
- 코드를 안정적으로 수정 가능 (기존에 작성했던 유닛테스트가 통과되도록 유지하기 때문)
- 유닛테스트 자체가 일종의 코드에 대한 문서로 작용 가능
여기까지는 많은 분들이 아실 거라고 생각하고 저 또한 이렇게 개념적으로는 어느정도 이해하고 있었습니다. 다만 클린아키텍쳐 환경에서 이를 실제로 적용해보려니 상당히 고민을 해야할 요소들이 많았는데, 이를 공유해보고자 합니다.
우선 첫번째 고민은, "어떤 것을 테스트해야하는가?" 였습니다. 흔히들 유닛테스트는 하나의 기능을 테스트 하는 것이라고 합니다. 하지만 프로젝트를 진행하면서 사소하거나 복잡한 기능들을 Data, Domain, Presentation 영역에 다양하게 만들었기 때문에 어떤 것을 우선적으로 테스트 해야하는지도 잘 몰랐고, 애초에 기능이란 것도 isLoading = true 이런 단순한 한줄짜리 로직을 말하는건지 함수 전체를 말하는 건지도 헷갈렸습니다.
이에 대해 멘토님의 도움과 함께 자료를 찾아본 결과, 제가 내린 결론은 이렇습니다. 이는 저희 프로젝트에만 해당될 수도 있는 얘기입니다.
- 백엔드 측에서도 따로 유닛테스트를 통해 정확한 데이터를 내려줄 것이라고 가정함으로서 Data 영역은 우선은 제외하기
- 그렇다면 Domain, Presentation 영역이 테스트 대상이 되는데, (저희 프로젝트는) Domain 영역에 뭔가 로직이 있다기보다는 그대로 데이터를 전달해주는 역할을 할 때가 많아서, Presentation 영역을 우선적으로 테스트하기
- UITest를 하는 것은 아니기 때문에 ViewModel을 중점적으로 테스트하고, 테스트 단위는 함수 하나로 잡기
- 특정 함수가 실행되면 변하는 변수들이 여러개가 있을 수 있는데, 테스트 목적에 맞게 변수들을 골라서 테스트 성공 여부를 확인하기
다시 한번 말하지만, 저희 프로젝트의 특성에 맞게 유닛테스트 대상을 선정한 것이고, 프로젝트 특성마다 이러한 방식이 달라질 수 있다는 점 알아두시면 좋을 것 같습니다.
앞선 고민에서 ViewModel의 함수들을 테스트 해보기로 마음을 먹었습니다. 다만 여기서 또 문제가 발생했는데, "private 함수 또는 private 변수를 테스트하고싶다면 어떻게 할 것 인가?" 였습니다. (참고로 internal은 @testable 키워드로 접근 가능합니다.) 원래 의도는 ViewModel의 일부 함수 및 변수를 View에게는 공개하지 않으려고 private으로 선언한 것이었는데, 테스트 모듈에게까지 감춰버린 것입니다.
억지로라도 public으로 바꿔야하나 고민하고 있던 찰나에, 이러한 글을 발견했습니다. 출처
Unit testing should be considered black box testing, which means you don't care about the internals of the unit you test. You are mainly interested to see what's the unit output based on the inputs you give it in the unit test.
즉 유닛 테스트는 인풋과 아웃풋만 살펴보고 내부는 알 필요 없는 블랙박스 테스팅이기 때문에 private은 신경 쓸 필요가 없다는 것입니다. 예를 들자면 이런 느낌일 것입니다.
public someFunc(input: String) -> String {
privateFunc1()
privateFunc2()
let output = "result is " + input
return output
}
우리는 someFunc만 테스트하면서 input이 어떤 값일 때 output이 어떤 값인지만 확인하면 되고, 내부의 함수들은 굳이 생각 안해도 된다는 느낌인 것 같습니다.
세번째 고민은 Mock 객체란 것을 굳이 만들어야하나 싶은 고민이였습니다. Mock 객체를 만드는 것도 하나의 추가 업무기도 하고, 기존 객체를 그대로 써도 테스트는 진행할 수 있다고 생각했기 때문이었습니다.
다만 이거는 제가 좀 잘못 생각하고 있던 것이었는데, 기존 객체를 그대로 써도 테스트를 진행할 수 있는 것은 사실입니다. 하지만 유닛테스트의 정의 중 "독립적으로 테스트" 라는 것은 못지키게 됩니다. 만약 현재 테스트 대상인 ViewModel의 기능들은 완벽한데, 상위 객체에서 문제가 있다면 그 영향으로 제대로 테스트가 안될 것입니다. 따라서 이런 영향을 없애기 위해서라도 Mock 객체는 거의 필수적이라는 것을 알게 됐습니다.
지금까지 설명한 유닛테스트를 어떻게 구현했는지 코드와 함께 설명드리겠습니다
일단은 MockUseCase를 만들었습니다. 여기서 또 클린아키텍쳐 구조에 감탄했던게, 단순히 프로토콜을 따르도록 Mock 클래스를 만들어주기만 하면 하위(ViewModel)에서는 따로 코드를 수정하지 않아도 됩니다. 이런 것이 OCP 원칙이 지켜진 코드라는 것을 체감할 수 있었습니다.
MockUseCase는 기본적으로 가짜 값을 내려주도록 되어있습니다. 하지만 테스트 목적에 따라 뭔가 다른 값, 또는 실패 값을 내려주고 싶을 수도 있는데 이럴 경우 따로 세팅할 수 있도록 함수를 만들어 뒀습니다.
public final class MockProblemDetailUseCase: ProblemDetailUseCase {
// 테스트시 기본적으로 내려줄 가짜 값 정의
private var getProblemDetailResponse: AnyPublisher<ProblemDetailVO, Error> = Just(ProblemDetailVO(
problemId: 0,
problemQuestion: "문맥 전환이 무엇인가?",
problemAnswer: "CPU가 이전 상태의 프로세스를 PCB에 보관하고, 또 다른 프로세스를 PCB에서 읽어 레지스터에 적재하는 과정",
problemKeyword: "PCB",
problemStatus: .solved,
favorite: .favorite,
faqs: []
))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
public init() { }
// 테스트시 원하는 특정 값을 반환하도록 하고 싶다면 함수 호출해서 값 세팅해주기
func setGetProblemDetailResponse(_ response: AnyPublisher<ProblemDetailVO, Error>) {
self.getProblemDetailResponse = response
}
// ViewModel에서 호출할 예정인 함수
public func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailVO, Error> {
getProblemDetailResponse
}
}
이 ViewModel은 기존에 있던 것이지만 설명을 위해 첨부했습니다. 참고로 public으로 공개된 함수는 View의 상호작용에 대응하기 위한 것들뿐이고, 이외의 로직들은 private으로 정의되어있습니다.
public class ProblemDetailViewModel {
...
// 화면이 나타났을 때 (View의 상호작용)
public func onScreenAppeared() {
startSolvingProblem()
getProblemDetail()
}
// API 통신해서 문제 세부 정보 가져오기
private func getProblemDetail() {
useCase.getProblemDetail(id: problemId)
.sink {
self.splitSentence()
...
}
.store(in: cancelBag)
}
// 문장 쪼개기
private func splitSentence() {
...
}
유닛테스트 구조는 given, when, then을 각각 클로저로 받는 형태로 구현했는데, 사실 꼭 이 형태를 따를 필요는 없고 주석으로 구분하는 분들도 많이 봤습니다. 다만 저는 이게 가독성이 좋다고 생각하여 해당 형태를 채택했습니다.
import XCTest
import Combine
@testable import Presentation
@testable import Domain
// 굳이 이렇게 안해도 되긴 하지만 가독성을 위해 해당 구조 채택
class BaseTestCase: XCTestCase {
func given(_ task: () -> Void) {
task()
}
func when(_ task: () -> Void) {
task()
}
func then(_ task: () -> Void) {
task()
}
}
class ProblemDetailViewModel_Test: BaseTestCase {
var viewModel: ProblemDetailViewModel!
/*
<테스트 설명> 키워드와 띄어쓰기 기준으로 잘 분리되는지 테스트
<가정> 문장: "aaabbb aaa bbb", 키워드: "aaa"
<원하는 결과값> answerSplitedForTest == ["aaa", "bbb", "aaa", "bbb"]
*/
func testSplittingSentence() throws {
given {
viewModel = ProblemDetailViewModel(
problemId: 0,
useCase: MockProblemDetailUseCase(),
coordinator: MockCoordinator(),
toastHelper: MockToastHelper()
)
}
when {
viewModel.onScreenAppeared()
}
then {
XCTAssertEqual(viewModel.answerSplited, ["CPU가", "이전", "상태의", "프로세스를", "PCB", "에", "보관하고,", "또", "다른", "프로세스를", "PCB", "에서", "읽어", "레지스터에", "적재하는", "과정"])
}
}
}
앞서 말했듯이 유닛테스트 클래스에서 private 함수에는 접근할 수 없습니다. 하지만 public 함수 내부에서 private 함수도 실행될 것이기 때문에, 만약 private 함수로 인해 변화하는 값을 테스트해보고 싶다면 해당 함수가 포함된 public 함수를 실행하면 됩니다. 저 또한 코드의 answerSplited는 private 함수인 splitSentence로 인해 변화하는 값이긴 하지만, 이 함수를 포함하는 onScreenAppeared 함수를 실행했기 때문에 테스트가 가능했습니다.
추가로 다른 함수 (onClicked 이라고 가정) 를 테스트 해보고싶을 경우 when 클로저에 해당 함수만 정의해서는 안됩니다. 실상황에서는 무조건 화면이 켜지고 클릭이 이루어질 것이기 때문에, 유닛테스트에서도 아래와 같이 일련의 과정들을 거치도록 해야합니다.
when {
viewModel.onScrennAppeared()
viewModel.onClicked()
}
이상으로 제가 구현해본 유닛테스트를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊