오랜만에 벨로그 글을 써보려고 한다. ㅎㅎ
벨로그 글을 쓴지 좀 오래 되었는데, 좀 뜸해지기도 했고 회사가 바빠지기도 해서 은근히 글을 쓸 시간이 없었다. (핑계..)
1년동안 제로부터 쌓아올린 열심히 만든 앱이 실제 사용자에게 전달해가고 있는 지금, 사실 부담감도 엄청나고, 압박감도 엄청났다.
발생하는 모든 책임을 내가 져야만 하니까..
그 책임을 져야 하는 나에게 그나마 그 부담과 압박을 덜어 낼 수 있다면 얼마나 좋을까. 코드를 변경했을때 기존 동작이 아무런 문제 없이 동작해낸다면, BLE 관련 로직을 변경해도 이전 펌웨어에서 주는 값들을 파싱했을때 아무 문제 없이 잘 동작한다면 부담감은 좀 줄지 않을까.
근데 이걸 어느 세월에 하나 하나 다 하고 있겠는가..?
개발자면 개발자 답게 자동화 할건 자동화 해야 한다고 본다. 세상에 앱 릴리즈 할때마다 그 많은 테스트들을 어느세월에 하고 있는가..? 나는 그러고 싶지 않다.
첫 도입의 이유는 펌웨어 버전이 변경되면서 주는 값들이 변경되고, 프로토콜은 변경이 되게 되는데, 이때 펌웨어 지원을 쉽게 하기 위해 Unit Test를 도입하기 시작했다.
사실 Unit Test를 도입하려면 꽤나 많은 작업이 필요하다. 로컬이던 시뮬레이터던 CI환경이던 항상 같은 테스트 결과값이 나오려면 변수를 제거해야만 한다. 그 변수란 네트워크 요청이나 BLE기기의 부재등을 의미한다.
이미 짜여져 있는 프로덕션 코드에서 테스트를 위해
#if DEBUG
if isTesting {
// 테스트용 로직
} else {
// 디버그 실제 앱 로직
}
#else
// 프로덕션 로직
#endif
모든 코드에 이런식으로 처리할수는 없다.
이 상황을 타개 하기 위해선 OOP의 원칙 중 하나인 DI / DIP를 도입해야 쉽게 처리할 수있다.
회사 코드에선 이미 Dependencies 라이브러리를 통해 DI를 처리하고 있었지만 DIP를 적용하기 위해 프로토콜로 한단계 추상화가 필요했다.
DIP를 적용하고 있지 않았지만, 이미 internal, private으로 감춰놓을 함수, 열어야할 함수를 이미 분리를 해놓았기에 작업은 어렵지 않게 해낼 수 있었음. 분리하고 난 다음에는 swift-Dependencies라이브러리를 통해 쉽게 Unit-Test를 작성 할 수 있었다.
final class AService: AServiceUseCases {
@Dependency(\.bService) bService: BServiceUseCases
}
이런식으로 작성해놓았다면
// BService
final class BServiceMock: BServiceUseCases {
func request() async -> Bool {
try await Task.sleep(for: .second(1))
return true
}
}
// Unit Test
@Test
func AService에서_테스트하는_경우() async throws {
let aService = withDependencies {
$0.bService = BServiceMock()
} operation: {
AService()
}
// Test로직
#expect(bService.isTrue)
}
이런식으로 BService에 네트워크 요청이 있다고 했을때 테스트할때마다 서버의 환경등이 일정하지 않을 수 있으므로 해당 방식을 통해서 만약 성공한다고 가정했을때, 만약 실패한다고 가정했을때를 같은 조건으로 테스트 할 수 있었다.
이를 통해서 우리 회사에서는 블루투스 로직을 건들때마다 테스트를 실행시켜 코드의 품질을 빠르게 확인할 수 있게 되었다.
사실 UI Test보다 CI/CD를 먼저 도입하긴 했는데, 글의 순서상 이게 맞는거 같음 ㅎㅎ
개인적으로 UI Test가 Unit Test보다 더 까다롭게 느껴졌다. Unit Test는 @testable import가 가능해서 원하는 상황을 마음대로 만들 수 있는 반면, UI Test는 불가능 하기 때문이다.
테스트 할때 크게 두가지의 테스트가 있는데, 그것이 블랙박스 테스트, 화이트박스 테스트이다. 나는 Unit Test를 화이트박스 테스트, UI Test를 블랙박스 테스트로 이해하고 그렇게 테스트케이스를 설계했다.
가장 테스트하기 까다로운 부분이 어디일까 생각해보니, 로그인 부분이 가장 까다롭다고 느꼈다.
앱 로직상 로그인 하고나서 프로필 설정을 하지 않으면, 다른 계정으로 로그인 하고 싶을때 프로필 설정을 끝내거나, 앱을 삭제하거나 둘 중 하나의 액션을 해야만 했는데 이는 너무 귀찮은 작업이다.
사실 UI Test도 마찬가지다. UI Test는 실제로 앱을 사용한다고 생각해서 동작하기 때문에 앱을 지웠다 깔고 같은 옵션은 있는지는 모르겠지만 꽤나 귀찮은 작업이 된다.
그리고 특정 화면을 테스트 하고 싶을 때 이미 내가 로그인이 되어있는 여부, 자동 로그인 여부, 회원가입 여부에 따라 성공 여부가 크게 달라진다.
그래서 화면 만큼은 Unit Test단계에서 어디로 진입할지 정해줘야 할 것 같다고 느꼈고, 해당 방식으로 진행했음.
UI Test를 진행할때 Launch arguments를 주입 할 수 있다!
// UI Test
func setUITestView(_ destination: UITestDestination) {
launchArguments += [destination.rawValue]
launchArguments += ["-UITest"]
}
그러면 SceneDelegate에서 해당 방식으로 받을 수 있다!
let args = ProcessInfo.processInfo.arguments
if let destination: UITestDestination = UITestDestination.allCases.first(where: { args.contains($0.rawValue) }) {
window?.rootViewController = UIHostingController(rootView: destination.view)
}
물론 이걸 하기 위해 UITestDestination의 Target Membership을 실제 Target과 UI Test Target을 두개 동시에 지정하는 중범죄를 저지르긴 했지만,,, 마땅하게 좋은 방법이 생각나지 않았음,,, 이거 할까말까 1시간 고민한듯함..
아무튼 해당 방식대로 UI Test에서 원하는 화면으로 이동해서 테스트를 진행할 수 있게 되었다.
사실 Xcode Cloud CI설정은 진짜 별거 없다. 이미 Xcode 내부에서 다 지원해주기 때문에 별로 여기에 적을 건 없다.
하지만 [iOS] 유니티 숏치러 갑니다 해당 게시물에서 알 수 있듯 우리 회사는 Unity as a library라는 기술을 사용중이고, Unity는 시뮬레이터를 지원하지 않는다.
게다가 Xcode Cloud는 CI환경이 인텔 맥(...!!)이기 때문에 시뮬레이터가 arm이 아닌 x86_64로 돌아가기에 더욱 세팅이 힘들었음.
거진 30번 정도의 트라이 후 빌드에 성공해서 계속 테스트를 돌리고 있다.
브랜치에 Pull Request, Push할때마다 테스트를 진행하고 있다. 체크표시가 날때마다 안도의 한숨을 내쉬며 개발을 진행하고 있다.
나는 1년전만 해도 Test코드는 정말 의미 없다고 생각한 사람이였는데, 책임을 져야하니 테스트가 절실해졌다.
앞으로도 테스트를 계속 추가해나가면서 더욱 더 단단한 코드를 작성할 수 있도록 해야겠다.