UITest) App Launch할 때 argument 사용하기

SteadySlower·2023년 6월 15일
0

iOS Development

목록 보기
21/38

이 포스팅은 사내에서 진행한 Tech Talk을 정리한 포스팅입니다.

이번에는 UITest를 할 때 도움이 되는 Launch Argument와 Launch Environment에 대해 알아보도록 하겠습니다.

Launch Argument와 Launch Environment

SwiftUI의 App 객체 위에는 아래처럼 “@main”로 entry point임을 표시되어 있습니다. 이게 launch argument와 launch environment는 쉽게 말하면 main 함수에 전달하는 parameter라고 이해하시면 될 것 같습니다.

Argument는 String으로 Environment는 [String:String]의 key-value로 이루어져 있습니다.

import SwiftUI

@main
struct SwiftUI_PracticeApp: App {}

Argument와 Environment 전달하기

Edit Scheme

첫 번째는 Edit Scheme를 통해서 전달하는 방법입니다. 직관적인 방법이고 여러개를 관리하기도 편리합니다. 미리 추가해놓고 지금 실행할 때 전달할 것만 체크 표시할 수도 있습니다.

다만 UITest를 실행할 때마다 다른 argument를 전달해야 할 때는 불편합니다. 매번 실행할 때마다 바꾸어야 하니까요. 그래서 보통 다음 방법을 많이 사용합니다.

코드로 전달하기

UITest 코드 내에서 전달하는 방법입니다. UITest에서는 App 객체에 직접 접근할 수 있습니다. 이 때 launch()를 하기 전에 각각 launchArguments에 String 타입으로 launchEnvironment에 key-value 방식으로 전달하면 됩니다.

final class UITests_Quiz_Launch: XCTestCase {
    
    let app = XCUIApplication()
    
    override func setUpWithError() throws {
        continueAfterFailure = false
        app.launchArguments = ["-UITesting"]
        app.launchEnvironment = ["-username":"Teddy"]
        app.launch()
    }

}

Argument와 Environment 사용하기

Argument와 Environment는 둘 다 App 객체의 initializer에서 접근할 수 있습니다.

Argument의 경우 CommandLine.arguments 혹은 ProcessInfo.processInfo.arguments로 Environment의 경우는 ProcessInfo.processInfo.environment[key]로 접근할 수 있습니다.

아래 코드의 예시를 보겠습니다. Argument를 통해 해당 App이 UITest를 위해서 launching이 되었다는 것을 알립니다. 그리고 environment에서 username 값을 가져와서 init을 합니다.

import SwiftUI

@main
struct SwiftUI_PracticeApp: App {

		@State var username: String
    private let service = NetworkService.shared

    init() {
//        let isUITesting: Bool = CommandLine.arguments.contains("-UITesting")
        let isUITesting: Bool = ProcessInfo.processInfo.arguments.contains("-UITesting")
        if isUITesting {
            let username = ProcessInfo.processInfo.environment["-username"]!
            self.username = username
        } else {
            self.username = ""
        }
    }
}

활용하기

NameView 넘기고 QuizView로 바로 가기

UITest를 위해서 만들었던 앱은 NameView에서 이름을 입력해야 QuizView로 넘어갈 수 있는 앱입니다. 따라서 QuizView를 테스트하기 위해서는 무조건 NameView를 거쳐야 합니다.

다만 아래 코드에서 볼 수 있듯이 App이 init될 때 username 값이 비어있지 않다면 바로 QuizView로 넘어갈 수 있습니다. UITest를 하는데 시간을 크게 절약할 수 있는 것이죠.

따라서 Argument와 Environment를 사용하면 아래처럼 initializer에서 UITest 환경인 경우 미리 username을 세팅할 수 있게 할 수 있습니다.

@main
struct SwiftUI_PracticeApp: App {
	@State var username: String

	init() {
        let isUITesting: Bool = ProcessInfo.processInfo.arguments.contains("-UITesting")
        if isUITesting {
            let username = ProcessInfo.processInfo.environment["-username"]!
            self.username = username
        } else {
            self.username = ""
        }
    }
	
	var body: some Scene {
	        WindowGroup {
	            if username.isEmpty {
	                NameView(name: $username)
	            } else {
	                NavigationView {
	                    QuizView(username: username)
	                }
	            }
	        }
	    }
}

Network Mocking 하기

Test할 때는 최대한 외부 의존성을 제거해야 합니다. UnitTest를 할 때도 테스트 대상이 되는 객체 이외의 다른 객체들은 mocking을 통해서 의존성을 제거하고 테스트를 합니다. UITest의 경우에는 app 전체를 실행하므로 앱 내의 의존성을 제거하는 것은 쉽지 않지만 앱 외부의 의존성 (네트워크 통신, 블루투스 등)은 제거하는 것이 좋습니다.

Unit Test를 할 때는 네크워크를 담당하는 클래스를 mocking 하듯이 UITest에서도 Argument를 활용해서 mocking 클래스를 사용해서 테스트를 해보겠습니다.

먼저 protocol을 정의하고 각각의 프로토콜을 따르는 Service를 각각 만듭니다. 하나는 실제 서비스 객체이고 두번째는 test에서 사용하는 객체입니다. RealService 클래스의 LoadQuiz는 실제로 네트워크를 실행해서 Quiz를 가져오고 MockService 클래스의 LoadQuiz는 mock data를 리턴합니다.

protocol NetworkService {
    func LoadQuiz() async throws -> Quiz
}

class RealService: NetworkService {
    func LoadQuiz() async throws -> Quiz {
        // 실제 네트워크 코드
    }
}

class MockService: NetworkService {
    func LoadQuiz() async throws -> Quiz {
        // mock data 리턴
        return .mock
    }
}

그리고 앱의 initializer에서 UITesting 환경의 경우 MockService를 전달하도록 하면 네크워크 통신에 의존하지 않고 테스트를 진행할 수 있습니다.

import SwiftUI

@main
struct SwiftUI_PracticeApp: App {

    @State var username: String
    private let service: NetworkService

    init() {
        let isUITesting: Bool = ProcessInfo.processInfo.arguments.contains("-UITesting")
        if isUITesting {
            let username = ProcessInfo.processInfo.environment["-username"]!
            self.username = username
            self.service = MockService()
        } else {
            self.username = ""
            self.service = RealService()
        }
    }

    var body: some Scene {
        WindowGroup {
            if username.isEmpty {
                NameView(name: $username)
            } else {
                NavigationView {
                    QuizView2(username: username, service: service)
                }
            }
        }
    }
}

마치며

토론

여기까지가 사내에서 진행한 Tech Talk의 내용입니다. Tech Talk을 마치고 아래 주제로 토론을 진행했는데요.

  1. 사내의 프로덕트에 UITest와 Accessibility를 도입하는 건 어떨까요?
  2. Mocking하는 코드가 Test 코드가 아닌 Product 코드에 들어있는 코드에 대해서 어떻게 생각하는지?

첫 번째 주제에 대해서는 UITest의 Cost를 생각하지 않을 수가 없었습니다. 저희 회사의 경우 스타트업이고 Unit Test를 도입하는데도 상당한 용기와 내부 설득과정을 거쳐야 했는데요. UITest의 경우 Unit Test 보다 효과는 한정적이고 Cost는 더 든다고 생각합니다. 따라서 도입의 Profit과 Cost를 냉정하게 분석할 필요가 있다는 결론을 도출했습니다.

두 번째 주제의 경우 처음에 저는 약간 막연한 거부감이 있었습니다. Product 코드에는 실제 사용자가 실행하는 코드가 들어가야지 개발자를 위한 코드가 있는 것은 좀 아니라고 생각을 했었습니다. 하지만 TCA의 경우 client 객체를 만들 때 test와 preview를 위한 코드를 작성할 수 있습니다. Test 역시 개발의 중요한 부분이고 결과적으로 앱의 품질과 생산성을 올릴 수 있는 중요한 요소라고 생각하기 때문에 Product 코드에 Test를 위한 코드가 들어 있는 것에 대해 큰 문제가 없다는 결론이 났습니다.

소감

지금 회사에서 신규 프로덕트를 혼자서 A-Z 개발하는 역할을 하고 있는데요. 그럴 때마다 Unit Test가 정말 든든한 버팀목이 되어주는 것을 느낍니다. 제가 작성한 코드의 사이드 이펙트를 최소화하고 조기에 발견할 수 있기 때문인데요.

UITest를 공부하면서 현재 실무에서 Unit Test를 통해 느끼는 것만큼의 유용함을 느끼지는 못했습니다. 다만 Unit Test가 해결하지 못하는 부분, 그리고 비개발자들이 원하는 테스트를 실행하고자 할 때 유용할 것 같다는 생각을 했습니다.

언젠가 실무에서 UITest를 활용하기를 바라면서 이 포스팅을 마칩니다. UITest에 대한 공부도 좀 더 해서 추가로 포스팅해보겠습니다!

profile
백과사전 보다 항해일지(혹은 표류일지)를 지향합니다.

0개의 댓글