iOS/Testing

iOS 유닛 테스트를 배워보자!(2)

HaningYa 2020. 4. 4. 15:56
728x90

[참고한 튜토리얼]

 

iOS Unit Testing and UI Testing Tutorial

Learn how to add unit tests and UI tests to your iOS apps, and how you can check on your code coverage.

www.raywenderlich.com

 

iOS 유닛 테스트를 배워보자!(1)

시작에 앞서 개인적으로 학과 수업을 통해 소프트웨어 개발에서 테스트의 중요성은 알고 있었지만 실제 개발에 어떻게 적용해야 하는지 모르는 상태였다. 마침 2019 GDG 부산 행사에서 DevOps 와 Testing 에 관한..

haningya.tistory.com

에 이어서 본격적으로 테스트에 필요한 코드를 작성해 보려 한다.

 

func testScoreIsComputed() {
  // 1. given
  let guess = sut.targetValue + 5

  // 2. when
  sut.check(guess: guess)

  // 3. then
  XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}

test 함수의 이름은 항상 'test'로 시작되어야 한다. 그리고 그 뒤에 어떤 테스트인지에 대한 설명을 적는다.

 

테스트 포멧을 Given, When, Then으로 나눠서 연습하는게 좋다고 한다.

  • Given : 필요한 value들을 세팅한다. 위 코드의 경우 guess라는 value 를 세팅하여 targetValue와 얼마나 차이가 나는지 확인할 수 있다.
  • When : 이 부분에는 테스트 되는 코드를 실행할 것이다. check(guess:)를 호출한다.
  • Then : 이 부분은 결과를 확인하는 부분이다. 결과가 틀릴 경우 결과를 출력한다. 위 코드의 경우 sut.ScoreRound는 95(100-5) 가 되어야 한다.

 

테스트 디버깅

예제에는 일부러 버그를 만들어 놨다고 한다. 

지금부터 그것을 찾는 연습을 하려고 한다.

targetValue에서 5를 빼는 테스트를 만든다.

  let guess = sut.targetValue - 5

그리고 BreakPoint navigator 에서 Test Failure BreakPoint를 만든다.

 

Break point가 제대로 걸렸다.

Value를 보면 guess는 targetValue -5 이여야 하는데 scoreRound는 105로 되어있다.

 

더 조사하기 위해선 늘 하던데로 보통의 디버깅 프로세스를 이용하면 된다.

테스트파일이 아닌 BullsEyeGame 코드에서 breakpoint를 잡고 difference value를 만들어 값이 어떻게 변화되는지 보면 된다.

 

여기서 알수있는 문제점은 difference가 음수라는 점이다. 그래서 score가 100-(-5) 가 되어 105가 되었던 것이다.

 

해결하기 위해서는 difference를 음수가 아닌 절대값으로 바꾸면 된다.(주석 처리된 코드로)

 

그럼 테스트가 통과하는걸 볼 수 있다.

 

 

비동기 함수 테스팅을 위한 XCTestExpectation 사용법

이전까지 어떻게 모델을 테스트 하고 디버그 하며 failture를 찾아내는지 알아보았다.

 

이번엔 HalfTunes예제 프로젝트를 통해서 배워보도록 하자.

 

HalfTunes 예제에는 URLSession을 통해 iTunes API 에서 샘플 노래를 다운받는 코드가 있다.

 

Alamofire와 같은 라이브러리로 network 부분을 처리한다고 하자.

 

테스트를 하기 위해선 먼저 network operations를 위한 테스트를 작성하고 코드를 작성하기 전과 후에 실행해본다.

 

URLSession은 비동기적이다. 바로 return 을 주지만 실행이 끝나진 않는다. 이러한 비동기 메소드를 테스트 하려면 XCTestExpectation을 사용해야 하는데 이것은 비동기 함수가 끝날 때 까지 test를 기다려 주는 역할을 한다.

 

(비동기 테스트는 대부분 느리기 때문에 다른 유닛 테스트들과는 항상 따로처리해 주어야 한다.)

 

먼저 이전과 같이 Unit test target 을 만들어 주고 테스트할 대상인 HalfTunes를 import 해준다.

(예제에서 URLSession 타입은 default 을 사용했다.)

 

setUp() 함수 안에 sut 객체를 선언하고 teardown부분에서 release 해준다.

(저번 포스팅때 이렇게 세팅하면 다음 테스트때 테스트를 초기화된 state로 다시 할 수 있어서 그렇다고 했다.)

 

var sut : URLSession!
  
    override func setUp() {
      super.setUp()
      sut = URLSession(configuration: .default)
    }

    override func tearDown() {
      sut = nil
      super.tearDown()
    }

 

그리고 비동기 테스트 부분을 추가해 준다.

 

// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
  // given
  let url = 
    URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Status code: 200")

  // when
  let dataTask = sut.dataTask(with: url!) { data, response, error in
    // then
    if let error = error {
      XCTFail("Error: \(error.localizedDescription)")
      return
    } else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
      if statusCode == 200 {
        // 2
        promise.fulfill()
      } else {
        XCTFail("Status code: \(statusCode)")
      }
    }
  }
  dataTask.resume()
  // 3
  wait(for: [promise], timeout: 5)
}

 

테스트는 iTunes에 valid 한 쿼리를 보내면 200 status 가 오는지 확인하는 코드이다.

 

테스트 코드는 보통의 URLSession을 구현할 때와 크게 다른점은 없지만 3가지 추가된 부분이 있다.

  • expectation(description:) : 'promise'에 저장된 XCTestExpectation 객체를 반환한다. 'description'파라미터는 정상동작 했을 때 기대하는 결과에 대한 설명 부분이다.
  • promise.fulfill(): 비동기 함수의 completion hanlder에서 호출되는 성공조건 클로저 이다. expectation이 맞을 때 flag 역할을 한다.
  • wait(for:timeout:) : 모든 expectation들이 충족되기 전이나 timeout interval이 끝날 때 까지 계속 테스트를 진행한다. 

테스트를 진행 해 보면 통과하는 걸 볼 수 있다.

 

빠르게 실패하기

 

실패는 쓰라리지만 한없이 걸리지는 않는다.

 

실패를 경험하기 이해 코드를 살짝 수정한다.

(URL 의 itunes 를 s를 뺀 iTune으로 수정해 본다)

 

테스트를 진행 해 보면 full timeout interval 만큼의 시간이 걸린다. 

 

왜냐하면 현재 테스트는 request에 대해서 항상 succeed한다고 간주하기 때문이다.

(promise.fulfill() 부분)

그래서 request는 timeout 이 expired 되었을 때 fail이 되는 것이다.

 

잘못된 부분을 빠르게 알기 위해서는 request가 성공할 때까지 기다리는게 아닌 비동기 메소드의 completion handler가 invoke 될때 까지만 기다리는 것이다. 앱이 서버로 부터 리스폰스를 받자마자 발생하는데 결과적으로 request가 성공했느지 확인 한 이후 테스트를 할 수 있게 된다.

 

테스트 코드를 수정한 뒤 하나씩 살펴보자

func testCallToiTunesCompletes() {
  // given
  let url = 
    URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
  let promise = expectation(description: "Completion handler invoked")
  var statusCode: Int?
  var responseError: Error?

  // when
  let dataTask = sut.dataTask(with: url!) { data, response, error in
    statusCode = (response as? HTTPURLResponse)?.statusCode
    responseError = error
    promise.fulfill()
  }
  dataTask.resume()
  wait(for: [promise], timeout: 5)

  // then
  XCTAssertNil(responseError)
  XCTAssertEqual(statusCode, 200)
}

 

중요한 차이점은 completion handler로 빠지는 것이 expectation을 만족하게 하고 짧은 시간만에 끝난 다는 점이다.

 

만약 request 가 실패한다면 'then' assertions 도 실패한다.

 

실행해보면 테스트 함과 (거의) 동시에 에러로 빠지는 것을 볼 수 있다.

 

 

가짜 객체를 만들고 상호작용하기

여태까지 실습한 비동기 테스트는 내 코드가 비동기 API에 정확한 input 해주는지 확인해 주었다.

그러면 내 코드가 API 로 부터 온 데이터에 대한 처리를 제대로 하고 있는지에 대한 테스트는 어떻게 할까

 

대부분의 앱들은 우리가 제어할 수 없는 다른 시스템이나 라이브러리 객체와 상호작용을 한다. 그래서 이런 테스트 들은 느리고 반복이 불가능하며 FIRST 원리를 어긴다. 

 

이를 해결하기 위해선 가짜 상호작용이 가능한 'stub'나 'mock object'가 필요하다.

 

Stub으로 가짜 입력 만들기

이번 테스트엔 updateSearchResults(_:) 가 제대로 다운로드된 데이터를 파싱하는지 확인해 본다.

 

확인을 위해서 searchResults.count 를 비교해 본다.

SUT는 ViewController 이고 stub를 통해 가짜 session과 다운로드된 데이터를 꾸며낼 것이다.

 

역시 동일한 방법으로 HalfTunesFakeTests 테스트를 만든다.

 

그리고 아래의 코드와 같이 세팅한다.

import XCTest
@testable import HalfTunes

class HalfTunesFakeTests: XCTestCase {
  
  var sut: SearchViewController!
  
  override func setUp() {
    sut = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? SearchViewController
  }
  
  override func tearDown() {
    sut = nil;
    super.tearDown()
  }
}

 

달라진 점은 sut 가 URLSession에서 ViewController 로 바뀐 것 뿐이다.

(여기서 URLSession 테스트 코드와 따로 만들어 주는 이유는 비동기 테스트 부분을 따로 처리해 주는 것 이 효과적이기 때문이다.)

 

그리고 가짜 session의 JSON data 샘플이 필요하다. 

{
	"resultCount": 3,
	"results": [{
			"wrapperType": "track",
			"kind": "song",
			"artistId": 372976,
			"collectionId": 1422648512,
			"trackId": 1422648513,
			"artistName": "ABBA",
			"collectionName": "Gold: Greatest Hits",
			"trackName": "Dancing Queen",
			"collectionCensoredName": "Gold: Greatest Hits",
			"trackCensoredName": "Dancing Queen",
			"artistViewUrl": "https://music.apple.com/us/artist/abba/372976?uo=4",
			"collectionViewUrl": "https://music.apple.com/us/album/dancing-queen/1422648512?i=1422648513&uo=4",
			"trackViewUrl": "https://music.apple.com/us/album/dancing-queen/1422648512?i=1422648513&uo=4",
			"previewUrl": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview118/v4/9a/ab/9f/9aab9f41-0821-de62-78cf-43e0c08add62/mzaf_6566316355195832.plus.aac.p.m4a",
			"artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/30x30bb.jpg",
			"artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/60x60bb.jpg",
			"artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/100x100bb.jpg",
			"collectionPrice": 7.99,
			"trackPrice": 1.29,
			"releaseDate": "1976-08-15T12:00:00Z",
			"collectionExplicitness": "notExplicit",
			"trackExplicitness": "notExplicit",
			"discCount": 1,
			"discNumber": 1,
			"trackCount": 19,
			"trackNumber": 1,
			"trackTimeMillis": 231844,
			"country": "USA",
			"currency": "USD",
			"primaryGenreName": "Pop",
			"isStreamable": true
		},
		{
			"wrapperType": "track",
			"kind": "song",
			"artistId": 372976,
			"collectionId": 1422648512,
			"trackId": 1422648520,
			"artistName": "ABBA",
			"collectionName": "Gold: Greatest Hits",
			"trackName": "Take a Chance On Me",
			"collectionCensoredName": "Gold: Greatest Hits",
			"trackCensoredName": "Take a Chance On Me",
			"artistViewUrl": "https://music.apple.com/us/artist/abba/372976?uo=4",
			"collectionViewUrl": "https://music.apple.com/us/album/take-a-chance-on-me/1422648512?i=1422648520&uo=4",
			"trackViewUrl": "https://music.apple.com/us/album/take-a-chance-on-me/1422648512?i=1422648520&uo=4",
			"previewUrl": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview128/v4/da/3c/31/da3c314e-1515-d7d9-b0f9-51fabf5b3777/mzaf_7511274970441566558.plus.aac.p.m4a",
			"artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/30x30bb.jpg",
			"artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/60x60bb.jpg",
			"artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/100x100bb.jpg",
			"collectionPrice": 7.99,
			"trackPrice": 1.29,
			"releaseDate": "1977-12-12T12:00:00Z",
			"collectionExplicitness": "notExplicit",
			"trackExplicitness": "notExplicit",
			"discCount": 1,
			"discNumber": 1,
			"trackCount": 19,
			"trackNumber": 3,
			"trackTimeMillis": 244954,
			"country": "USA",
			"currency": "USD",
			"primaryGenreName": "Pop",
			"isStreamable": true
		},
		{
			"wrapperType": "track",
			"kind": "song",
			"artistId": 372976,
			"collectionId": 1422648512,
			"trackId": 1422648821,
			"artistName": "ABBA",
			"collectionName": "Gold: Greatest Hits",
			"trackName": "Mamma Mia",
			"collectionCensoredName": "Gold: Greatest Hits",
			"trackCensoredName": "Mamma Mia",
			"artistViewUrl": "https://music.apple.com/us/artist/abba/372976?uo=4",
			"collectionViewUrl": "https://music.apple.com/us/album/mamma-mia/1422648512?i=1422648821&uo=4",
			"trackViewUrl": "https://music.apple.com/us/album/mamma-mia/1422648512?i=1422648821&uo=4",
			"previewUrl": "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview128/v4/78/df/a9/78dfa901-6177-ca57-6f4b-016737ecd8f9/mzaf_7404502379144753984.plus.aac.p.m4a",
			"artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/30x30bb.jpg",
			"artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/60x60bb.jpg",
			"artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/88/92/4c/88924c01-6fb3-8616-f0b3-881b1ed09e03/source/100x100bb.jpg",
			"collectionPrice": 7.99,
			"trackPrice": 1.29,
			"releaseDate": "1975-04-21T12:00:00Z",
			"collectionExplicitness": "notExplicit",
			"trackExplicitness": "notExplicit",
			"discCount": 1,
			"discNumber": 1,
			"trackCount": 19,
			"trackNumber": 4,
			"trackTimeMillis": 212304,
			"country": "USA",
			"currency": "USD",
			"primaryGenreName": "Pop",
			"isStreamable": true
		}
	]
}

 

이 파일을 테스트 그룹에 추가한다.

 

다시 HalfTunesFakeTest 코드로 가서 해당 json 파일을 가짜 리스폰스로 추가해 준다.

let testBundle = Bundle(for: type(of: self))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)

let url = 
  URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(
  url: url!, 
  statusCode: 200, 
  httpVersion: nil, 
  headerFields: nil)

let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)
sut.defaultSession = sessionMock

해당 코드를 setUp 함수에 구현한다. 이 코드는 가짜 session 객체에 대한 가짜 데이터와 reponse를 제공한다.

 

본격적으로 테스트 코드를 추가한다.

func test_UpdateSearchResults_ParsesData() {
  // given
  let promise = expectation(description: "Status code: 200")

  // when
  XCTAssertEqual(
    sut.searchResults.count, 
    0, 
    "searchResults should be empty before the data task runs")
  let url = 
    URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  let dataTask = sut.defaultSession.dataTask(with: url!) {
    data, response, error in
    // if HTTP request is successful, call updateSearchResults(_:) 
    // which parses the response data into Tracks
    if let error = error {
      print(error.localizedDescription)
    } else if let httpResponse = response as? HTTPURLResponse,
      httpResponse.statusCode == 200 {
      self.sut.updateSearchResults(data)
    }
    promise.fulfill()
  }
  dataTask.resume()
  wait(for: [promise], timeout: 5)

  // then
  XCTAssertEqual(sut.searchResults.count, 3, "Didn't parse 3 items from fake response")
}

 

비동기 테스트이기 때문에 stub도 비동기 함수처럼 작성해야 한다.

when에서 'searchResults'는 작업이 실행되기 전에 비어있다. 

3곡에 대한 정보를 담고있는 가짜 JSON 데이터는 viewController의 searchResults 배열에 세개의 항목으로 들어가게 된다.

 

테스트를 실행해 보면 실제로 네트워크 연결은 발생하지 않는 것을 볼 수 있다.

 

다음 포스팅에는 Mock Object를 이용한 테스트를 알아보겠다.

 

728x90