안녕하세요.
이번에는 SwiftUI에서 testable 한 코드를 만들기 위해 신경 써야 할 부분과, 테스트 코드도 작성해보는 시간을 가져보겠습니다.
이번 글은 아래 글을 토대로 작성되었습니다.
https://swiftbysundell.com/articles/writing-testable-code-when-using-swiftui/
"뷰는 비즈니스 로직을 가져가서는 안돼!"
라는 말은 왜 나왔을까요??
여러 가지 이유가 있겠지만, 그 중 하나는 뷰가 비즈니스 로직을 가져가는 순간 unit test를 하기 어렵기 때문이에요.
그런데 실제로 개발을 하다 보면, 간단한 처리 같은 경우에는 뷰 안에서 처리하는 것이 직관적일 수 있기 때문에 애매한 상황이 생길 수 있어요.
아래 예제 코드를 한번 살펴볼게요.
struct SendMessageView: View {
var sender: MessageSender
@State private var message = ""
@State private var isSending = false
@State private var sendingError: Error?
var body: some View {
VStack {
Text("Your message:")
TextEditor(text: $message) // 1
Button(isSending ? "Sending..." : "Send") { // 2
isSending = true
sendingError = nil
Task { // 3
do {
try await sender.sendMessage(message)
message = ""
} catch {
sendingError = error
}
isSending = false
}
}
.disabled(isSending || message.isEmpty) // 4
if let error = sendingError {
Text(error.localizedDescription)
.foregroundColor(.red)
}
}
}
}
SendMessageView라는 뷰가 있고 MessageSender라는 프로토콜을 이용하여 메시지를 전송합니다.
그리고 그 뷰 안에서는
1. 메시지를 입력할 수 있는 TextField가 있고,
2. 메시지를 전송할 수 있는 버튼이 있고,
3. 버튼이 클릭되었을 때 메시지를 전송하며,
4. 메시지가 전송 중이거나 메시지가 빈 값이면 버튼을 비활성화한다.
라는 비즈니스 로직을 가지고 있어요.
딱 봤을 땐, "지금 구조도 나쁘지 않은데????" 라고 생각이 들 수 있지만
버튼 쪽에 비즈니스 로직이 들어가 있기 때문에
버튼 타이틀, 버튼 활성화/비활성화, 메시지 전송 중 에러 발생 등 unit test를 하기 어려워졌어요...ㅠㅠ
unit test를 하기 위해선 무엇을 고려해야 하냐!!!
뻔한 말이지만 다시 한번 강조하자면, 비즈니스 로직을 별도의 공간에 분리해줘야 합니다.
이번 글에서는 비즈니스 로직을 분리하기 위해 ViewModel을 사용해볼게요.
## ViewModel로 분리
SendMessageView가 가지고 있는 비즈니스 로직을 SendMessageViewModel로 분리시켜줬습니다.
@MainActor class SendMessageViewModel: ObservableObject {
@Published var message = ""
@Published private(set) var errorText: String?
var buttonTitle: String { isSending ? "Sending..." : "Send" }
var isSendingDisabled: Bool { isSending || message.isEmpty }
private let sender: MessageSender
private var isSending = false
init(sender: MessageSender) {
self.sender = sender
}
func send() {
guard !message.isEmpty else { return }
guard !isSending else { return }
isSending = true
errorText = nil
Task {
do {
try await sender.sendMessage(message)
message = ""
} catch {
errorText = error.localizedDescription
}
isSending = false
}
}
}
SendMessageViewModel를 이용해서 SendMessageView를 리팩토링 해볼게요.
struct SendMessageView: View {
@ObservedObject var viewModel: SendMessageViewModel ✅
var body: some View {
VStack(alignment: .leading) {
Text("Your message:")
TextEditor(text: $viewModel.message)
Button(viewModel.buttonTitle) { ✅
viewModel.send() ✅
}
.disabled(viewModel.isSendingDisabled) ✅
if let errorText = viewModel.errorText {
Text(errorText).foregroundColor(.red)
}
}
}
}
어떤가요???
SendMessageView는 ViewModel의 정보를 화면에 뿌려주기만 하기 때문에
SendMessageViewModel에 대해서만 unit test를 해주면 되겠죠??? 👍
또한 뷰 내부 코드도 깔끔해진 장점도 있습니다ㅎㅎ
이제 테스트 코드를 짜 볼 건데, 그 전에 MessageSender 프로토콜을 구현한 Mock 클래스와, Combine을 이용한 비동기 처리 Test API를 추가해줄게요.
## MessageSender 프로토콜을 구현한 Mock 클래스
class MessageSenderMock: MessageSender {
@Published private(set) var pendingMessageCount = 0
private var pendingMessageContinuations = [CheckedContinuation<Void, Error>]()
func sendMessage(_ message: String) async throws {
return try await withCheckedThrowingContinuation { continuation in
pendingMessageContinuations.append(continuation)
pendingMessageCount += 1
}
}
func sendPendingMessages() {
let continuations = pendingMessageContinuations
pendingMessageContinuations = []
pendingMessageCount = 0
continuations.forEach { $0.resume() }
}
func triggerError(_ error: Error) {
let continuations = pendingMessageContinuations
pendingMessageContinuations = []
pendingMessageCount = 0
continuations.forEach { $0.resume(throwing: error) }
}
}
하나하나 살펴보면,
sendMessage(_:)는 메시지 전송을 하는 것처럼 보이도록 하는 API입니다.
그래서 sendMessage(_:) API가 1번 호출되면, pendingMessageCount가 1 올라가는 거죠.
(pendingMessageCount를 보면, 보류라고 표현되어 있지만 전송되고 있는 메시지 수라고 보면 이해가 편할 것 같아요)
sendPendingMessage()는 메시지 전송이 완료된 것처럼 보이도록 하는 API입니다.
triggerError(_:)는 메시지 전송 중 에러가 발생한 것처럼 보이도록 하는 API입니다.
그리고 최종으로 pendingMessageCount를 Publishing 해줬어요.
(전송 중 일 때는 1 전송 완료일 때는 0이기 때문에, 비동기 처리가 끝난 후에 데이터를 전달받기 위해서 @Published 선언을 해준 것이에요.)
## Combine을 이용한 비동기 처리 Test API
extension XCTestCase {
func waitUntil<T: Equatable>(
_ propertyPublisher: Published<T>.Publisher,
equals expectedValue: T,
timeout: TimeInterval = 10,
file: StaticString = #file,
line: UInt = #line
) {
let expectation = expectation(
description: "Awaiting value \(expectedValue)"
)
var cancellable: AnyCancellable?
cancellable = propertyPublisher
.dropFirst()
.first(where: { $0 == expectedValue })
.sink { value in
XCTAssertEqual(value, expectedValue, file: file, line: line)
cancellable?.cancel()
expectation.fulfill()
}
waitForExpectations(timeout: timeout, handler: nil)
}
}
watiUntil(_:equals:timeout:file:line:) API는 XCTestCase를 extension 한 것이에요.
맨 마지막 줄의 watiForExpectations를 우선 살펴볼게요.
let expectation = expectation(
description: "Awaiting value \(expectedValue)"
)
...
waitForExpectations(timeout: timeout, handler: nil)
watiForExpectations를 호출하면 비동기 처리를 위해 함수가 종료되지 않고 대기하게 됩니다.
언제까지???
timeout 만큼 기다리거나, 또는 expectation.fulfill()이 호출될 때까지!!
만약 timeout 전까지 fulfill이 안 불린다면???
테스트 실패!!!
중간 부분을 한번 볼까요?
var cancellable: AnyCancellable?
cancellable = propertyPublisher
.dropFirst()
.first(where: { $0 == expectedValue })
.sink { value in
XCTAssertEqual(value, expectedValue, file: file, line: line)
cancellable?.cancel()
expectation.fulfill()
}
dropFirst()는 첫 번째 데이터를 버립니다.
왜?? Mock 인스턴스 생성 시 default 값인 0이 전달되기 때문에, 이 값은 버리는 것이죠.
우리는 초기값 이후에 값이 뭔지 알고 싶으니까요ㅎㅎ
first(where: { $0 == expectedValue })를 통해서 기댓값이 올 때 그 값을 전달하고 stream을 종료시킵니다.
sink를 사용해서 Publisher를 구독하고 값이 전달된 뒤의 동작을 정의했습니다.
지금은 전달된 값과 기댓값이 다를 경우 테스트 실패를 주고,
그 다음엔 cancel을 통해서 Publisher가 데이터를 그만 전달하도록 하고
fulfil을 통해서 현재 대기하고 있는 method를 종료시키는 것이죠.
테스트 코드를 짜기 위한 사전 준비는 끝났습니다.
이제 진짜로 테스트 코드를 짜 볼게요ㅎㅎㅎ
## 테스트 코드 구현
@MainActor class SendMessageViewModelTests: XCTestCase {
private var sender: MessageSenderMock!
private var viewModel: SendMessageViewModel!
@MainActor override func setUp() {
super.setUp()
sender = MessageSenderMock()
viewModel = SendMessageViewModel(sender: sender)
}
}
테스트 전 setUp에서 sender에는 MessageSenderMock 인스턴스를 줬고, viewModel에도 MessageSenderMock 인스턴스를 넘겨줬습니다.
이로써, 메시지 전송의 비동기 처리 로직은 MessageSenderMock 규칙을 따라갈 것입니다.
우선 메시지 유무에 따른 버튼의 활성화/비활성화 상태 테스트를 해볼게요.
@MainActor class SendMessageViewModelTests: XCTestCase {
...
func testSendingDisabledWhileMessageIsEmpty() {
XCTAssertTrue(viewModel.isSendingDisabled)
viewModel.message = "Message"
XCTAssertFalse(viewModel.isSendingDisabled)
viewModel.message = ""
XCTAssertTrue(viewModel.isSendingDisabled)
}
}
비동기 처리 없이 단순히 viewModel의 isSendingDisabled를 보고 테스트를 해주는 것이라 딱히 어려움은 없습니다ㅎㅎ
사실 비동기 동작에 대한 테스트를 어떻게 할 것이냐?? 가 가장 관건인데 저희는 위에서 waitUntil이란 API를 뚫어줬죠??
이것을 사용할 것입니다ㅎㅎ
이번에는 메시지 전송을 하고 전송 완료되었을 때 상황에 대한 테스트 코드를 짜 볼게요.
@MainActor class SendMessageViewModelTests: XCTestCase {
...
func testSuccessfullySendingMessage() {
// 1. 메시지 전송 시작.
viewModel.message = "Message"
viewModel.send()
// 2. 메시지가 전송 중인지 확인.
waitUntil(sender.$pendingMessageCount, equals: 1)
// 3. 메시지 전송 중 상태에 대한 버튼 타이틀, 상태 검사
XCTAssertEqual(viewModel.buttonTitle, "Sending...")
XCTAssertTrue(viewModel.isSendingDisabled)
// 4. 메시지 전송 완료 처리
sender.sendPendingMessages()
waitUntil(viewModel.$message, equals: "")
XCTAssertEqual(viewModel.buttonTitle, "Send")
}
}
이번에는 메시지 전송 중 실패하는 상황에 대한 테스트 코드를 짜 볼게요.
@MainActor class SendMessageViewModelTests: XCTestCase {
...
func testHandlingMessageSendingError() {
// First, start sending a message:
viewModel.message = "Message"
viewModel.send()
waitUntil(sender.$pendingMessageCount, equals: 1)
// Then, make the sender throw an error and verify it:
let error = URLError(.badServerResponse)
sender.triggerError(error)
waitUntil(viewModel.$errorText, equals: error.localizedDescription)
XCTAssertEqual(viewModel.message, "Message")
XCTAssertEqual(viewModel.buttonTitle, "Send")
XCTAssertFalse(viewModel.isSendingDisabled)
}
}
👍 👍 👍
이번 글은 여기서 마무리.
'SwiftUI' 카테고리의 다른 글
Canvas 뷰를 통한 성능 향상 (0) | 2022.03.14 |
---|---|
cornerRadius (0) | 2022.03.13 |
animatableData (0) | 2022.03.03 |
renderingMode(_:) (0) | 2022.03.01 |
AlignmentGuide (0) | 2022.02.27 |