iOS용 자동화 테스트를 작성하는 방법

게시 됨: 2022-03-11

훌륭한 개발자는 작성하는 소프트웨어의 모든 기능과 가능한 모든 코드 경로 및 결과를 테스트하기 위해 최선을 다합니다. 그러나 사용자가 취할 수 있는 모든 가능한 결과와 가능한 모든 경로를 수동으로 테스트할 수 있는 것은 극히 드물고 이례적인 일입니다.

애플리케이션이 커지고 복잡해짐에 따라 수동 테스트를 통해 무언가를 놓칠 가능성이 크게 높아집니다.

UI 및 백엔드 서비스 API 모두의 자동화된 테스트를 통해 모든 것이 의도한 대로 작동하고 개발, 리팩토링, 새로운 기능 추가 또는 기존 기능 변경 시 스트레스를 줄일 수 있다는 확신을 가질 수 있습니다.

자동화된 테스트를 통해 다음을 수행할 수 있습니다.

  • 버그 줄이기: 코드의 버그 가능성을 완전히 제거하는 방법은 없지만 자동화된 테스트는 버그 수를 크게 줄일 수 있습니다.
  • 자신 있게 변경: 새 기능을 추가할 때 버그를 방지합니다. 즉, 빠르고 쉽게 변경할 수 있습니다.
  • 코드 문서화: 테스트를 통해 볼 때 특정 기능에 대해 예상되는 사항, 조건 및 코너 케이스를 명확하게 볼 수 있습니다.
  • 수월하게 리팩토링: 개발자는 때때로 리팩토링을 두려워할 수 있습니다. 특히 많은 양의 코드를 리팩토링해야 하는 경우에 그렇습니다. 단위 테스트는 리팩토링된 코드가 여전히 의도한 대로 작동하는지 확인하기 위한 것입니다.

이 기사에서는 iOS 플랫폼에서 자동화된 테스트를 구성하고 실행하는 방법을 설명합니다.

단위 테스트와 UI 테스트

단위 테스트와 UI 테스트를 구별하는 것이 중요합니다.

단위 테스트특정 컨텍스트 에서 특정 기능 을 테스트합니다. 단위 테스트는 테스트된 코드 부분(일반적으로 단일 함수)이 수행해야 하는 작업을 수행하는지 확인합니다. 단위 테스트에 대한 많은 책과 기사가 있으므로 이 게시물에서는 다루지 않습니다.

UI 테스트 는 사용자 인터페이스를 테스트하기 위한 것입니다. 예를 들어 보기가 의도한 대로 업데이트되었는지 또는 사용자가 특정 UI 요소와 상호 작용할 때 해야 하는 대로 특정 작업이 트리거되는지 테스트할 수 있습니다.

각 UI 테스트는 애플리케이션 UI와의 특정 사용자 상호 작용 을 테스트합니다. 자동화된 테스트는 단위 테스트와 UI 테스트 수준 모두에서 수행될 수 있고 수행되어야 합니다.

자동화된 테스트 설정

XCode는 기본적으로 단위 및 UI 테스트를 지원하므로 프로젝트에 쉽고 간단하게 추가할 수 있습니다. 새 프로젝트를 생성할 때 "단위 테스트 포함" 및 "UI 테스트 포함"을 선택하기만 하면 됩니다.

프로젝트가 생성될 때 이 두 가지 옵션이 선택되면 두 개의 새 대상이 프로젝트에 추가됩니다. 새 대상 이름에는 이름 끝에 "Tests" 또는 "UITests"가 추가됩니다.

그게 다야 프로젝트에 대한 자동화된 테스트를 작성할 준비가 되었습니다.

이미지: XCode에서 자동화된 테스트 설정.

이미 기존 프로젝트가 있고 UI 및 단위 테스트 지원을 추가하려는 경우 작업을 조금 더 수행해야 하지만 매우 간단하고 간단합니다.

파일 → 새로 만들기 → 대상 으로 이동하여 단위 테스트의 경우 iOS 단위 테스트 번들 또는 UI 테스트의 경우 iOS UI 테스트 번들 을 선택합니다.

이미지: iOS 단위 테스트 번들 선택.

다음 을 누릅니다.

대상 옵션 화면에서 모든 것을 그대로 둘 수 있습니다(여러 대상이 있고 특정 대상만 테스트하려면 테스트할 대상 드롭다운에서 대상을 선택하십시오).

마침 을 누릅니다. UI 테스트에 대해 이 단계를 반복하면 기존 프로젝트에서 자동화된 테스트 작성을 시작할 수 있는 모든 준비가 완료됩니다.

단위 테스트 작성

단위 테스트 작성을 시작하기 전에 그 구조를 이해해야 합니다. 프로젝트에 단위 테스트를 포함하면 예제 테스트 클래스가 생성됩니다. 우리의 경우 다음과 같이 보일 것입니다.

 import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }

가장 중요한 이해 방법은 setUptearDown 입니다. setUp 메소드는 모든 테스트 메소드 전에 호출되는 반면, tearDown 메소드는 모든 테스트 메소드 후에 호출됩니다. 이 예제 테스트 클래스에 정의된 테스트를 실행하면 메서드는 다음과 같이 실행됩니다.

설정 → testExample → 해제 설정 → testPerformanceExample → 해제

팁: 테스트는 cmd + U를 누르거나 제품 → 테스트를 선택하거나 옵션 메뉴가 나타날 때까지 실행 버튼을 클릭한 상태로 유지한 다음 메뉴에서 테스트를 선택하여 실행됩니다.

하나의 특정 테스트 메서드만 실행하려면 메서드 이름 왼쪽에 있는 버튼을 누릅니다(아래 이미지 참조).

이미지: 하나의 특정 테스트 방법 선택.

이제 테스트를 작성할 모든 준비가 완료되면 테스트할 예제 클래스와 몇 가지 메서드를 추가할 수 있습니다.

사용자 등록을 담당할 클래스를 추가합니다. 사용자가 이메일 주소, 비밀번호 및 비밀번호 확인을 입력합니다. 예제 클래스는 입력을 확인하고 이메일 주소의 가용성을 확인하며 사용자 등록을 시도합니다.

참고: 이 예제는 MVVM(또는 Model-View-ViewModel) 아키텍처 패턴을 사용하고 있습니다.

MVVM은 애플리케이션의 아키텍처를 더 깔끔하고 테스트하기 쉽게 만들기 때문에 사용됩니다.

MVVM을 사용하면 프레젠테이션 로직에서 비즈니스 로직을 더 쉽게 분리할 수 있으므로 대규모 뷰 컨트롤러 문제를 피할 수 있습니다.

MVVM 아키텍처에 대한 세부 사항은 이 기사의 범위를 벗어납니다. 그러나 이 기사에서 이에 대한 자세한 내용을 읽을 수 있습니다.

사용자 등록을 담당하는 뷰 모델 클래스를 만들어 봅시다. .

 class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }

먼저 몇 가지 속성, 동적 속성 및 init 메서드를 추가했습니다.

Dynamic 유형에 대해 걱정하지 마십시오. MVVM 아키텍처의 일부입니다.

Dynamic<Bool> 값이 true로 설정되면 RegistrationViewModel 에 바인딩된(연결된) 뷰 컨트롤러가 등록 버튼을 활성화합니다. loginSuccessful 이 true로 설정되면 연결된 보기가 자체적으로 업데이트됩니다.

이제 비밀번호와 이메일 형식의 유효성을 확인하는 몇 가지 방법을 추가해 보겠습니다.

 func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }

사용자가 이메일 또는 비밀번호 필드에 무언가를 입력할 때마다 enableRegistrationAttempt 메소드는 이메일과 비밀번호가 올바른 형식인지 확인하고 registrationEnabled 동적 속성을 통해 등록 버튼을 활성화 또는 비활성화합니다.

예제를 단순하게 유지하려면 두 가지 간단한 방법을 추가하십시오. 하나는 이메일 가용성을 확인하는 것이고 다른 하나는 주어진 사용자 이름과 비밀번호로 등록을 시도하는 것입니다.

 func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }

이 두 가지 방법은 NetworkService를 사용하여 이메일 사용 가능 여부를 확인하고 등록을 시도하는 것입니다.

이 예제를 단순하게 유지하기 위해 NetworkService 구현은 백엔드 API를 사용하지 않고 결과를 위조하는 스텁일 뿐입니다. NetworkService는 프로토콜 및 해당 구현 클래스로 구현됩니다.

 typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }

NetworkService는 등록 시도 및 이메일 가용성 확인 방법의 두 가지 방법만 포함하는 매우 간단한 프로토콜입니다. 프로토콜 구현은 NetworkServiceImpl 클래스입니다.

 class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }

두 가지 방법 모두 일정 시간 동안 기다린 다음(네트워크 요청의 시간 지연을 속임) 적절한 콜백 방법을 호출합니다.

팁: 프로토콜(다른 프로그래밍 언어에서는 인터페이스라고도 함)을 사용하는 것이 좋습니다. '인터페이스 원리 프로그래밍'을 검색하면 이에 대한 자세한 내용을 읽을 수 있습니다. 또한 단위 테스트와 함께 잘 작동하는 방법을 볼 수 있습니다.

이제 예제가 설정되면 이 클래스의 메서드를 다루기 위해 단위 테스트를 작성할 수 있습니다.

  1. 뷰 모델에 대한 새 테스트 클래스를 만듭니다. 프로젝트 네비게이터 창에서 TestingIOSTests 폴더를 마우스 오른쪽 버튼으로 클릭하고 새 파일 → 단위 테스트 케이스 클래스를 선택하고 이름을 RegistrationViewModelTests 로 지정합니다.

  2. 우리 고유의 테스트 메소드를 생성하고 싶기 때문에 testExampletestPerformanceExample 메소드를 삭제하십시오.

  3. Swift는 모듈을 사용하고 테스트는 애플리케이션의 코드와 다른 모듈에 있으므로 애플리케이션의 모듈을 @testable 로 가져와야 합니다. import 문 및 클래스 정의 아래에 @testable import TestingIOS (또는 애플리케이션의 모듈 이름)를 추가합니다. 이것이 없으면 애플리케이션의 클래스나 메서드를 참조할 수 없습니다.

  4. registrationViewModel 변수를 추가하십시오.

이것이 우리의 빈 테스트 클래스가 지금 보이는 방식입니다:

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

emailValid 메소드에 대한 테스트를 작성해 봅시다. testEmailValid 라는 새로운 테스트 메소드를 생성할 것입니다. 이름의 시작 부분에 test 키워드를 추가하는 것이 중요합니다. 그렇지 않으면 메서드가 테스트 메서드로 인식되지 않습니다.

테스트 방법은 다음과 같습니다.

 func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }

우리의 테스트 방법은 주장 방법인 XCTAssert 를 사용하는데, 우리의 경우 조건이 참인지 거짓인지 확인합니다.

조건이 false이면 assert가 실패하고(테스트와 함께) 메시지가 작성됩니다.

테스트에서 사용할 수 있는 assert 메서드가 많이 있습니다. 각 assert 메소드를 설명하고 보여주면 쉽게 자체 기사가 될 수 있으므로 여기에서 자세히 설명하지 않겠습니다.

사용 가능한 어설션 메서드의 몇 가지 예는 XCTAssertEqualObjects , XCTAssertGreaterThan , XCTAssertNil , XCTAssertTrue 또는 XCTAssertThrows 입니다.

사용 가능한 assert 메서드에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

지금 테스트를 실행하면 테스트 메서드가 통과합니다. 첫 번째 테스트 방법을 성공적으로 생성했지만 아직 본격적인 준비가 되지 않았습니다. 이 테스트 방법에는 아래에 설명된 대로 여전히 세 가지 문제(큰 문제 하나와 작은 문제 두 개)가 있습니다.

문제 1: NetworkService 프로토콜의 실제 구현을 사용하고 있습니다.

단위 테스트의 핵심 원칙 중 하나는 모든 테스트가 외부 요인이나 종속성과 독립적이어야 한다는 것입니다. 단위 테스트는 원자적이어야 합니다.

어떤 시점에서 서버에서 API 메서드를 호출하는 메서드를 테스트하는 경우 테스트는 네트워킹 코드와 서버 가용성에 따라 달라집니다. 테스트 당시 서버가 작동하지 않으면 테스트가 실패하여 테스트한 방법이 작동하지 않는다고 잘못 비난합니다.

이 경우 RegistrationViewModel 의 메서드를 테스트하고 있습니다.

귀하가 테스트한 방법인 emailValidNetworkServiceImpl 에 직접적으로 의존하지 않는다는 것을 알고 있더라도 RegistrationViewModelNetworkServiceImpl 클래스에 의존합니다.

단위 테스트를 작성할 때 모든 외부 종속성을 제거해야 합니다. 그러나 RegistrationViewModel 클래스의 구현을 변경하지 않고 어떻게 NetworkService 종속성을 제거해야 합니까?

이 문제에 대한 쉬운 해결책이 있으며 이를 Object Mocking 이라고 합니다. RegistrationViewModel 을 자세히 살펴보면 실제로 NetworkService 프로토콜에 의존한다는 것을 알 수 있습니다.

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

RegistrationViewModel 이 초기화될 때 NetworkService 프로토콜의 구현이 RegistrationViewModel 개체에 제공(또는 주입)됩니다.

이 원칙 을 생성자를 통한 종속성 주입 이라고 합니다( 더 많은 유형의 종속성 주입이 있음).

objc.io의 이 기사와 같이 온라인 의존성 주입에 대한 흥미로운 기사가 ​​많이 있습니다.

여기에 간단하고 직접적인 방법으로 종속성 주입을 설명하는 짧지만 흥미로운 기사도 있습니다.

또한 단일 책임 원칙과 DI에 대한 훌륭한 기사는 Toptal 블로그에서 볼 수 있습니다.

RegistrationViewModel 이 인스턴스화되면 생성자에 NetworkService 프로토콜 구현을 주입합니다(따라서 종속성 주입 원칙의 이름).

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

보기 모델 클래스는 프로토콜에만 의존하기 때문에 사용자 정의(또는 모의) NetworkService 구현 클래스를 만들고 모의 클래스를 보기 모델 개체에 주입하는 것을 막을 수는 없습니다.

모의 NetworkService 프로토콜 구현을 만들어 보겠습니다.

프로젝트 네비게이터에서 TestingIOSTests 폴더를 마우스 오른쪽 버튼으로 클릭하여 테스트 대상에 새 Swift 파일을 추가하고 "새 파일"을 선택하고 "Swift 파일"을 선택하고 이름을 NetworkServiceMock 으로 지정합니다.

이것이 우리의 모의 클래스가 보여야 할 모습입니다:

 import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }

이 시점에서 실제 구현( NetworkServiceImpl )과 크게 다르지 않지만 실제 상황에서 실제 NetworkServiceImpl 에는 네트워킹 코드, 응답 처리 및 유사한 기능이 있습니다.

우리의 모의 클래스는 아무 것도 하지 않습니다. 이것이 모의 클래스의 핵심입니다. 아무것도 하지 않는다면 우리의 테스트를 방해하지 않을 것입니다.

테스트의 첫 번째 문제를 해결하기 위해 다음을 교체하여 테스트 방법을 업데이트하겠습니다.

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

와 함께:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

문제 2: 테스트 메서드 본문에서 registrationVM을 인스턴스화하고 있습니다.

이유가 있는 setUptearDown 메서드가 있습니다.

이러한 메서드는 테스트에 필요한 모든 필수 개체를 초기화하거나 설정하는 데 사용됩니다. 모든 테스트 방법에서 동일한 초기화 또는 설정 방법을 작성하여 코드 중복을 방지하려면 이러한 방법을 사용해야 합니다. 특히 특정 테스트 방법에 대해 특정 구성이 있는 경우 설정 및 해제 방법을 사용하지 않는 것이 항상 큰 문제는 아닙니다.

RegistrationViewModel 클래스의 초기화는 매우 간단하므로 setup 및 tearDown 메서드를 사용하도록 테스트 클래스를 리팩토링합니다.

RegistrationViewModelTests 는 다음과 같아야 합니다.

 class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }

문제 3: 하나의 테스트 메서드에 여러 어설션이 있습니다.

이것이 큰 문제는 아니지만 메서드당 하나의 assert를 사용하는 것을 지지하는 사람들이 있습니다.

이 원칙의 주된 이유는 오류 감지입니다.

한 테스트 방법에 여러 어설션이 있고 첫 번째 방법이 실패하면 전체 테스트 방법이 실패한 것으로 표시됩니다. 다른 주장은 테스트조차 하지 않습니다.

이렇게 하면 한 번에 하나의 오류만 발견할 수 있습니다. 다른 주장이 실패할지 성공할지 알 수 없습니다.

한 번에 하나의 오류만 수정할 수 있으므로 한 번에 하나의 오류를 감지하는 것이 그렇게 큰 문제가 아닐 수도 있기 때문에 하나의 메서드에 여러 어설션을 사용하는 것이 항상 나쁜 것은 아닙니다.

우리의 경우 이메일 형식의 유효성이 테스트됩니다. 이것은 하나의 기능일 뿐이므로 테스트를 더 쉽게 읽고 이해할 수 있도록 모든 어설션을 하나의 방법으로 그룹화하는 것이 더 논리적일 수 있습니다.

이 문제는 실제로 큰 문제가 아니며 일부에서는 전혀 문제가 되지 않는다고 주장할 수도 있으므로 테스트 방법을 있는 그대로 유지합니다.

고유한 단위 테스트를 작성할 때 각 테스트 방법에 대해 선택할 경로를 결정하는 것은 사용자의 몫입니다. 대부분의 경우 테스트 철학에 따라 주장하는 것이 타당한 곳이 있고 그렇지 않은 곳이 있음을 알게 될 것입니다.

비동기식 호출로 메서드 테스트하기

응용 프로그램이 아무리 단순하더라도 다른 스레드에서 비동기적으로 실행해야 하는 메서드가 있을 가능성이 높습니다. 특히 일반적으로 UI가 자체 스레드에서 실행되는 것을 선호하기 때문입니다.

단위 테스트 및 비동기식 호출의 주요 문제는 비동기식 호출이 완료되는 데 시간이 걸리지만 단위 테스트는 완료될 때까지 기다리지 않는다는 것입니다. 비동기 블록 내부의 코드가 실행되기 전에 단위 테스트가 완료되기 때문에 테스트는 항상 동일한 결과로 종료됩니다(비동기 블록에 무엇을 작성하든 상관없이).

이를 시연하기 위해 checkEmailAvailability 메소드에 대한 테스트를 작성해 보겠습니다.

 func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }

여기에서 우리의 메소드가 이메일을 사용할 수 없다고(다른 사용자가 이미 사용했음) 알리는 후 registrationEnabled 변수가 false로 설정되는지 테스트하려고 합니다.

이 테스트를 실행하면 통과합니다. 하지만 한 가지만 더 시도해 보세요. 주장을 다음과 같이 변경하십시오.

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

테스트를 다시 실행하면 다시 통과합니다.

우리의 주장이 주장되지 않았기 때문입니다. 단위 테스트는 콜백 블록이 실행되기 전에 종료되었습니다(모의 네트워크 서비스 구현에서는 반환되기 전에 1초 동안 기다리도록 설정되어 있음을 기억하십시오).

다행히 Xcode 6에서 Apple은 XCTest 프레임워크에 XCTestExpectation 클래스로 테스트 기대치를 추가했습니다. XCTestExpectation 클래스는 다음과 같이 작동합니다.

  1. 테스트를 시작할 때 테스트에서 예상한 것을 설명하는 간단한 텍스트로 테스트 기대치 를 설정합니다.
  2. 테스트 코드가 실행된 후 비동기 블록에서 기대치를 충족합니다.
  3. 테스트가 waitForExpectationWithTimer 블록을 설정해야 합니다. 예상이 충족되거나 타이머가 만료된 경우 중 먼저 발생하는 경우 실행됩니다.
  4. 이제 단위 테스트는 기대치가 충족되거나 기대 타이머가 다 될 때까지 끝나지 않습니다.

XCTestExpectation 클래스를 사용하도록 테스트를 다시 작성해 보겠습니다.

 func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }

지금 테스트를 실행하면 정상적으로 실패합니다. 테스트를 통과하도록 수정합시다. 어설션을 다음으로 변경합니다.

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

테스트를 다시 실행하여 통과하는지 확인하십시오. 네트워크 서비스 모의 구현에서 지연 시간을 변경하여 예상 타이머가 다 떨어지면 어떻게 되는지 확인할 수 있습니다.

콜백 없이 비동기식 호출로 메서드 테스트하기

예제 프로젝트 메서드 attemptUserRegistration 은 비동기적으로 실행되는 코드를 포함하는 NetworkService.attemptRegistration 메서드를 사용합니다. 이 메서드는 백엔드 서비스에 사용자를 등록하려고 시도합니다.

데모 응용 프로그램에서 메서드는 네트워크 호출을 시뮬레이션하고 성공적인 등록을 가짜로 만들기 위해 1초 동안 대기합니다. 등록에 성공하면 loginSuccessful 값이 true로 설정됩니다. 이 동작을 확인하기 위해 단위 테스트를 해보자.

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

실행하면 비동기 networkService.attemptRegistration 메서드가 완료될 때까지 loginSuccessful 값이 true로 설정되지 않기 때문에 이 테스트는 실패합니다.

시도 등록 메서드가 성공적인 등록을 반환하기 전에 1초 동안 대기하는 모의 NetworkServiceImpl 을 만들었으므로 GCD(Grand Central Dispatch)를 사용하고 attemptRegistration 메서드를 사용하여 1초 후에 어설 asyncAfter 을 확인할 수 있습니다. GCD의 asyncAfter 를 추가한 후 테스트 코드는 다음과 같습니다.

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }

주의를 기울였다면 테스트 메서드가 asyncAfter 블록이 실행 되기 전에 실행되고 메서드가 결과적으로 항상 성공적으로 전달되기 때문에 이것이 여전히 작동하지 않는다는 것을 알게 될 것입니다. 다행히 XCTestException 클래스가 있습니다.

XCTestException 클래스를 사용하도록 메서드를 다시 작성해 보겠습니다.

 func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }

RegistrationViewModel 을 다루는 단위 테스트를 통해 이제 새로운 기능을 추가하거나 기존 기능을 업데이트해도 문제가 발생하지 않는다는 확신을 가질 수 있습니다.

중요 참고 사항: 단위 테스트는 해당 메서드의 기능이 변경될 때 업데이트되지 않으면 값을 잃게 됩니다. 단위 테스트 작성은 애플리케이션의 나머지 부분을 따라가야 하는 프로세스입니다.

팁: 테스트 작성을 끝까지 미루지 마십시오. 개발하는 동안 테스트를 작성하십시오. 이렇게 하면 테스트해야 하는 것과 국경의 경우가 무엇인지 더 잘 이해할 수 있습니다.

UI 테스트 작성

모든 단위 테스트가 완전히 개발되고 성공적으로 실행된 후에는 각 코드 단위가 올바르게 작동하고 있다고 확신할 수 있지만 이것이 전체 애플리케이션이 의도한 대로 작동한다는 의미입니까?

UI 테스트가 필수 구성 요소인 통합 테스트가 필요한 곳입니다.

UI 테스트를 시작하기 전에 테스트할 몇 가지 UI 요소와 상호 작용(또는 사용자 스토리)이 필요합니다. 간단한 보기와 보기 컨트롤러를 만들어 보겠습니다.

  1. Main.storyboard 를 열고 아래 이미지와 같은 간단한 뷰 컨트롤러를 만듭니다.

이미지: 간단한 보기와 보기 컨트롤러 만들기.

이메일 텍스트 필드 태그를 100으로, 비밀번호 텍스트 필드 태그를 101로, 비밀번호 확인 태그를 102로 설정합니다.

  1. 새 뷰 컨트롤러 파일 RegistrationViewController.swift 를 추가하고 모든 콘센트를 스토리보드에 연결합니다.
 import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }

여기에서 IBOutletsTextFieldTags 구조체를 클래스에 추가합니다.

이렇게 하면 편집 중인 텍스트 필드를 식별할 수 있습니다. 뷰 모델에서 동적 속성을 사용하려면 뷰 컨트롤러에서 동적 속성을 '바인딩'해야 합니다. bindViewModel 메소드에서 그렇게 할 수 있습니다:

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

이제 텍스트 필드가 업데이트되는 시기를 추적하기 위해 텍스트 필드 대리자 메서드를 추가해 보겠습니다.

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
  1. 보기 컨트롤러를 적절한 보기 모델에 바인딩하도록 AppDelegate 를 업데이트합니다(이 단계는 MVVM 아키텍처의 요구 사항입니다). 업데이트된 AppDelegate 코드는 다음과 같아야 합니다.
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }

스토리보드 파일과 RegistrationViewController 는 정말 간단하지만 자동화된 UI 테스트가 작동하는 방식을 보여주기에는 충분합니다.

모든 것이 올바르게 설정되면 앱이 시작될 때 등록 버튼이 비활성화되어야 합니다. 모든 필드가 채워지고 유효한 경우에만 등록 버튼을 활성화해야 합니다.

이것이 설정되면 첫 번째 UI 테스트를 만들 수 있습니다.

UI 테스트는 유효한 이메일 주소, 유효한 암호 및 유효한 암호 확인이 모두 입력된 경우에만 등록 버튼이 활성화되는지 확인해야 합니다. 설정 방법은 다음과 같습니다.

  1. TestingIOSUITests.swift 파일을 엽니다.
  2. testExample() 메서드를 삭제하고 testExample() testRegistrationButtonEnabled() 를 추가합니다.
  3. 거기에 무언가를 쓰려는 것처럼 testRegistrationButtonEnabled 메서드에 커서를 놓습니다.
  4. UI 테스트 기록 버튼(화면 하단의 빨간색 원)을 누릅니다.

이미지: UI 테스트 기록 버튼을 보여주는 스크린샷.

  1. 녹음 버튼을 누르면 애플리케이션이 실행됩니다.
  2. 응용 프로그램이 시작된 후 이메일 텍스트 필드를 누르고 '[email protected]'을 작성합니다. 코드가 테스트 메소드 본문 내부에 자동으로 나타나는 것을 알 수 있습니다.

이 기능을 사용하여 모든 UI 지침을 기록할 수 있지만 간단한 지침을 수동으로 작성하는 것이 훨씬 빠릅니다.

이것은 비밀번호 텍스트 필드를 탭하고 이메일 주소 '[email protected]'을 입력하기 위한 녹음기 지침의 예입니다.

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. 테스트하려는 UI 상호 작용이 녹음된 후 중지 버튼을 다시 눌러(녹화를 시작할 때 녹음 버튼 레이블이 중지로 변경됨) 녹음을 중지합니다.
  2. UI 상호 작용 레코더가 있으면 이제 다양한 XCTAsserts 를 추가하여 애플리케이션 또는 UI 요소의 다양한 상태를 테스트할 수 있습니다.

이미지: 암호 필드를 탭하기 위한 레코더 지침을 보여주는 애니메이션.

기록된 지침이 항상 설명이 필요한 것은 아니며 전체 테스트 방법을 읽고 이해하기 어렵게 만들 수도 있습니다. 다행히 UI 지침을 수동으로 입력할 수 있습니다.

다음 UI 지침을 수동으로 생성해 보겠습니다.

  1. 사용자가 비밀번호 텍스트 필드를 탭합니다.
  2. 사용자가 '비밀번호'를 입력합니다.

To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.

The password UI interaction can now be written as:

 let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")

There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:

 let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")

Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert (the same as you did in unit testing) to verify if the Register button's isEnabled state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled property looks like this:

 let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

The whole UI test should now look like this:

 func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.

To improve the test, let's also test that the isEnabled property of the register button is false whenever any of the required fields have not been not entered correctly.

The complete test method should now look like this:

 func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.

The example UI test is very simple, but it demonstrates the power of automated UI testing.

The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.

Become a Better Developer by Writing Good Tests

From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.

To write good tests, you will have to learn how to better organize your code.

Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.

In some cases, it is even impossible to write tests when code is not organized well.

When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).

When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.

Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.

As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).

Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.