SwiftUI

SwiftUI에서 testable 한 코드 만들기

Phililip
728x90

안녕하세요.

 

이번에는 SwiftUI에서 testable 한 코드를 만들기 위해 신경 써야 할 부분과, 테스트 코드도 작성해보는 시간을 가져보겠습니다.

 

 

이번 글은 아래 글을 토대로 작성되었습니다.

 

https://swiftbysundell.com/articles/writing-testable-code-when-using-swiftui/

 

Writing testable code when using SwiftUI | Swift by Sundell

A major part of the challenge of architecting UI-focused code bases tends to come down to deciding where to draw the line between the code that needs to interact with the platform’s various UI frameworks, versus code that’s completely within our own ap

swiftbysundell.com

 

 


 

 

"뷰는 비즈니스 로직을 가져가서는 안돼!"

 

라는 말은 왜 나왔을까요??

 

여러 가지 이유가 있겠지만, 그 중 하나는 뷰가 비즈니스 로직을 가져가는 순간 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