나의 기록, 현진록

iOS에서의 유닛 테스트(Unit Test)에 대해 알아보자 본문

iOS

iOS에서의 유닛 테스트(Unit Test)에 대해 알아보자

guswlsdk 2023. 3. 15. 09:46
반응형

🙋‍♂️ 유닛 테스트가 무엇인가?

- 유닛 테스트는 다른 말로 단위 테스트라고도 한다.

- 하나의 함수, 메서드 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트입니다.

 

🙋‍♂️ 유닛 테스트, 어떤 점이 좋은건가요?

- 해당 메서드에 대해서 독립적(모듈)으로 테스트하기 때문에 빠른 리팩토링 반영과 테스트를 진행할 수 있다.

- 코드의 확장이나 리팩토링 시에도 안정성을 확보한 채로 신속한 대응이 가능하다.

 

읽기 어렵고 불안정한 유닛 테스트는 코드 기반을 파괴할 수도...(= 무조건 좋은 것은 아니다.)

 

🙋‍♂️ 테스트는 어떻게 이루어지나요?

- 예상값과 결과값의 비교로 이루어진다

func testArraySorting() {
    let input = [1, 7, 6, 3, 10]
    let expectation = [1, 3, 6, 7, 10]

    let result = input.sorted()

    XCTAssertEqual(result, expectation)
}

 

 

 

🙋‍♂️ 유닛 테스트 시 지켜야할 FIRST 원칙

- 좋은 유닛 테스트를 위한 원칙이다.

- 실제 5가지 원칙이 모두 지켜지지 않는 경우가 많다. (Timely는 개발 시간이 오래걸린다는 이유로 특히..)

- 이러한 원칙을 염두하고 코딩을 하도록 노력해보자.

 

[Fast]

- 테스트는 빠르게 동작할 수 있어야 한다. -> 프로젝트 내에 수 많은 테스트 코드가 느리게 동작한다면 비효율적이다.

- 테스트 코드는 빠르게 확인하고, 수정하고, 반영하는 데에 큰 의미가 있기 떄문에 속도가 느린 테스트는 좋지 않다.

[Independent/Isolated]

- 각각의 테스트는 서로 독립적이며 서로 의존해서는 안 된다.

- 의존성이 높은 코드는 완전한 테스트를 진행하기 어려울 수 있고, 테스트가 실패할 경우 오류를 찾기 힘들다.

 

[Repeatable]

- 어떤 환경에서든 언제 어디서나 테스트는 같은 결과가 반복되어야 한다.

- 통제가 어려운 부분에 대해서는 테스트를 위한 객체를 만들어주는 방법을 선택하기도 한다.

 

[Self-Validating]

- 테스트는 Bool을 이용하여 성공/실패에 대해서 스스로 검증이 가능해야 한다.

 

[Timely]

- 이상적인 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.

- 테스트 코드를 작성하기 전에 실제 코드를 구현한다면 테스트하기 까다롭거나 불가능하도록 코딩하게 될 수도 있다.


<실습>

🙋‍♂️ XCTest

import XCTest

- XCTest는 유닛(단위) 테스트, 퍼포먼스(성능) 테스트, UI 테스트를 만들고 실행하는 프레임워크이다.

- 테스트를 하기 위해서 XCTest가 임포트 되어야 한다.

 

XCTest | Apple Developer Documentation

Create and run unit tests, performance tests, and UI tests for your Xcode project.

developer.apple.com

🙋‍♂️ XCTestCase

class StrangeCalculatorTests: XCTestCase {
    ...
}

- XCTestCase는 추상 클래스인 XCTest의 하위 클래스로, 테스트를 하기 위해서 상속해야하는 가장 기본적인 클래스이다.

- XCTestCase를 상속받은 클래스에서는 test에서 사용되는 다양한 프로퍼티와 메서드를 사용할 수 있다.

 

 

🙋‍♂️ setUpWithError()

override func setUpWithError() throws {
	// Put setup code here. This method is called before the invocation of each test method in the class.
}

- 각각의 test case가 실행되기 전마다 호출되어 각 테스트가 모두 같은 상태와 조건에서 실행될 수 있도록 만들어 줄 수 있는 메서드이다.

- 만약 어떤 케이스에 의해서 테스트에 사용되는 값이 변경된다면 다음 테스트에서는 정상적인 테스트가 이루어지기 어렵기 때문에 테스트가 이루어질 때마다 테스트의 상태를 reset 해야 한다.

 

🙋‍♂️ tearDownWIthError()

override func tearDownWithError() throws {
    // Put teardown code here. This method is called after the invocation of each test method in the class.
}

- 각각의 test 실행이 끝난 후마다 호출되는 메서드이다. 

- 보통 setUpWithError()에서 설정한 값들을 해제할 때 사용된다.

 

여러 개의 테스트 케이스가 실행되는 경우 아래와 같은 순서로 호출된다.

 

setUpWithError()

[Test Case1]

tearDownWithError()

setUpWIthError()

[Test Case2]

tearDownWithError()

.

.

.

🙋‍♂️ setUp()과 tearDown() 메서드와의 차이

- setUp(), setUpWithError(), tearDown() / tearDownWithError()의 차이는 에러를 throw할 수 있느냐에 대한 차이이다.

- xcode 11.4 버전 전후로 제공되는 기본 메소드가 다르다.(현재는 ____WithError()가 기본 제공 메소드)

 

 

🙋‍♂️ testExample()

func testExample() throws {
    // This is an example of a functional test case.
    // Use XCTAssert and related functions to verify your tests produce the correct results.
    // Any test you write for XCTest can be annotated as throws and async.
    // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
    // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
}

- test로 시작하는 메서드들은 우리가 작성해야 할 테스트 케이스가 되는 메서드이다.

- 메서드 네이밍의 시작은 무조건 test로 시작되어야 한다.

- 한국인끼리 작업하는 경우에는 영어보다 한글로 네이밍할 수도 있다.(테스트 코드가 기능을 명세화, 문서화하는 역할도 하기 때문)

 

🙋‍♂️ testPerformanceExample()

func testPerformanceExample() throws {
    // This is an example of a performance test case.
    measure {
        // Put the code you want to measure the time of here.
    }
}

- 성능 테스트를 위한 메서드이다. 

- measure(block:) 메서드를 통해 성능을 측정하게 된다.

 

 

 

<StrangeCalculator> - 실습할 타겟이다.

struct StrangeCalculator {
    func addNumbers(of numbers: [Int]) -> Int {
        return numbers.reduce(0, +)
    }
    
    // for TDD
    func addOddNumbers(of numbers: [Int]) -> Int {
        return 0
    }
    
    func addEvenNumbers(of numbers: [Int]) -> Int {
        return 0
    }
}

 

🙋‍♂️ @testable

import XCTest
@testable import UnitTestSample

class StrangeCalculatorTests: XCTestCase {
    ...
}

- 먼저 테스트 메서드를 작성하기 전에 @testable import (Target)이라는 코드를 작성해 주어야 한다.

- @testable은 유닛 테스트에서 실제 앱 타깃에 있는 코드들에 접근하기 위한 키워드입니다.

- 테스트하는 동안에 접근 수준 제한이 있는 코드에 접근할 수 있도록 해준다.

 

🙋‍♂️ SUT(System Under Test)

final class StangeCalculatorTests: XCTestCase {
    var sut: StrangeCalculator!
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = StrangeCalculator()
    }

    override func tearDownWithError() throws {
        try super.tearDownWithError()
        sut = nil
    }
}

- SUT는 테스트를 진행할 타입이다.(네이밍을 SUT라고 하지 않아도 된다.)

- setUpWithError에서 sut를 초기화 해주고, tearDownWithError()에서 sut에 nil을 할당해 주고 있다. 즉, 테스트 케이스가 시작되는 처음과 끝에 호출되면서 의도된 테스트 환경을 만들어준다.

- super를 호출하는 이유는 XCTestCase를 상속 받았고, override 해서 사용하기 때문이다.

 

🙋‍♂️ 테스트 코드 작성하기

- 테스트는 예상값과 결과값을 비교한다.

  func test_addNumber호출시_3_7_23을전달했을때_33을_반환하는가(){
        // given
        let input = [3,7,23]
        
        // when
        let result = sut.addNumbers(of: input)
        
        // then
        XCTAssertEqual(result, 33)
    }

- XCTAssertEqual은 테스트 결과를 확인하는 함수로서 두 값을 비교한 결과에 따라 테스트 통과/실패가 결정된다.

 

 

🙋‍♂️ given / when / then으로 나누어 작성하는 이유?

- BDD(Behavior Driven Development)라는 테스트 방식이다.

- BDD는 시나리오를 설정하여 예상대로 결과가 나타나는지를 확인한다.

- given : 어떤 상황이 주어진다. (조건설정)

- when : 어떤 코드를 실행한다. 

- then : 테스트 결과를 확인한다.

 

- 꼭 이렇게 해야만 하는 것은 아니다, input/output이나 expectation/result로 작성하기도 한다.

 

 

거터(테스트 코드 왼쪽 다이아몬드 버튼)를 눌러 테스트 결과를 확인해보자

 

 

🙋‍♂️ 추가 테스트 작성하기

- 메서드의 동작이 아주 단순하기 때문에 문제가 없음을 쉽게 알 수 있다. 하지만 테스트 코드를 작성할 때 예외 케이스는 없는지 확인해 보아야 한다. ex) 음수, 빈 배열, 아주 많은 혹은 큰 수의 경우

- 어느 정도, 어떤 것을 테스트 해야 하는지는 테스트의 목적이나 사람, 팀에 따라 다르다.

 

 

 

🙋‍♂️ Code Coverage

- 테스트의 가치를 측정해주는 툴이다. 실제 코드에서 어느 정도의 테스트가 진행되었는지 알 수 있다.

- 아래 3가지에 대해서 확인할 수 있다.

- 실제 테스트에서 어떤 코드가 실행되었는지

- 정확성, 성능에 대해 얼마나 충분히 테스트가 이루어졌는지

- 테스트가 포함하고 있지 않은 코드는 무엇인지

 

사용법

Product - Scheme - Edit Scheme

Close 버튼 클릭 후 테스트 시작해준다.

Command + U 단축키로 전체 테스트를 실행할 수 있다.

이처럼 테스트 진행도를 확인할 수 있다.

 

LottoMachine.swift의 진행도는 69.7%이고 뭐가 부족한지 확인할 수 있다.

LottoMachine.swift에 마우스를 대면 우측에 버튼이 생기는데 클릭한다.

 

코드를 내려보면 우측에 빨간 바가 보이는데 마우스를 대면

코드 블럭에 배경이 씌워진다.

 

 

🙋‍♂️ 비동기 메서드 테스트하기

  • makeRandomValue(completionHandler:): 네트워크 통신을 통해 random 값을 얻어오는 메서드입니다.
func makeRandomValue(completionHandler: @escaping () -> Void) {
    let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=0&max=30&count=1"
    guard let url = URL(string: urlString) else {
        return
    }

    let task = urlSession.dataTask(with: url) { data, response, error in
        guard let response = response as? HTTPURLResponse, (200...399).contains(response.statusCode) else {
            return
        }

        guard let data = data, error == nil else {
            return
        }

        do {
            guard let newValue = try JSONDecoder().decode([Int].self, from: data).first else {
                return
            }

            self.randomValue = newValue
            completionHandler()
        } catch {
            return
        }
    }

    task.resume()
}

 

다음과 같이 테스트 코드를 작성한다.

func test_makeRandomValue호출시_randomValue를_0에서30까지숫자로설정해주는지() {
    // given
    sut.randomValue = 50 // 기본값이 0~30에 포함되면 무조건 테스트에 통과하므로 범위에서 벗어난 값을 할당

    // when
    sut.makeRandomValue {
        // then
        XCTAssertGreaterThanOrEqual(self.sut.randomValue, 0)
        XCTAssertLessThanOrEqual(self.sut.randomValue, 30)
    }
}

좌측에 테스트가 성공했다는 표시가 보인다.

하지만 makeRandomValue는 비동기 메소드로 사실 테스트 코드는 제대로 동작하고 있지 않다.

 

expectation(description:), fulfill(), wait(for:timeout:) 세가지의 메서드로 비동기적으로 처리되는 동작이 끝날 때까지 기다릴 수 있습니다.

 

func test_makeRandomValue호출시_randomValue를_잘설정해주는지() {
    // given
    let promise = expectation(description: "It makes random value") // expectation
    sut.randomValue = 50 // 기본값이 0~30에 포함되면 무조건 테스트에 통과하므로 범위에서 벗어난 값을 할당

    // when
    sut.makeRandomValue {
        // then
        XCTAssertGreaterThanOrEqual(self.sut.randomValue, 0)
        XCTAssertLessThanOrEqual(self.sut.randomValue, 30)
        promise.fulfill() // fulfill
    }

    wait(for: [promise], timeout: 10) // wait
}

 

  • expectation(description:): 어떤 것이 수행되어야 하는지를 description으로 정해줍니다.
  • fulfill(): 정의해둔 expectation이 충족되는 시점에 호출하여 동작을 수행했음을 알립니다.
  • wait(for:timeout:): expectation을 배열로 담아 전달하여 배열 속의 expectation이 모두 fulfill 될 때까지 기다립니다. timeout을 설정하여 시간을 제한할 수 있습니다.

 


🙋‍♂️ Test Double란?

- 테스트를 진행하기 어려운 경우 이를 대신하여 테스트를 진행할 수 있도록 만들어주는 객체

 

- 테스트 하는 동안에는 테스트 더블과 실제 객체를 잠깐 바꿔서 테스트를 진행한다.

 

🙋‍♂️ Test Double의 역할

- 테스트 대상 코드를 격리한다.

- 테스트 속도를 개선한다.

- 예측 불가능한 실행 요소를 제거한다.

- 특수한 상황을 시뮬레이션한다.

- 감춰진 정보를 얻어낸다.

 

🙋‍♂️ Test Double의 종류

- Dummy, Stub, Fake, Spy, Mock 등이 있다.

- 테스트 더블마다 역할이 다르지만 사실 명확한 기준으로 구분해서 사용하진 않는다.

- 명확한 구분선을 찾기보다는 흐름에 따라 이해하는 편이 좋다.

 

Dummy

- 가장 기본적인 테스트 더블이다.

- 어떤 기능이 구현되어 있지 않은, 단지 인스턴스화된 객체로 사용되기 때문에 Dummy의 메서드는 정삭적으로 동작하지 않는다.

- 객체를 전달하기 위한 목적으로 주로 사용된다.

 

Stub

- Dummy가 실제로 동작하는 것처럼 만들어 실제 코드를 대신해서 동작해주는 객체이다.

- 테스트가 곤란한 부분의 객체를 도려내어 그 역할을 최소한으로 대신해 줄 만큼만 간단하게 구현되어 있다.

 

Fake

- Stub보다 구체적으로 동작해서 실제 로직처럼 보이지만 실제 앱의 동작에서는 적합하지 않은 객체를 말한다.

- 로직 자체는 실제 앱의 코드와 비슷하지만 그 동작을 단순화하여 구현한 객체이다.

 

Spy

- Stub의 역할을 가지면서 호출된 내용에 대한 방법 혹은 과정 등 약간의 정보를 기록하는 객체이다.

- 호출이 되었는지, 몇 번 호출되었는지 등에 대한 정보를 기록한다.

 

Mock

- 실제 객체와 가장 비슷하게 구현된 수준의 객체라고 할 수 있다. Stub이 상태 기반 테스트(State Base Test)라면 Mock은 행위 기반 테스트(Behavior Base Test)라고 이야기하기도 한다.

- 상태 기반 테스트: 메서드를 호출하고 그 결괏값과 예상 값을 비교하는 식으로 동작하는 테스트

- 행위 기반 테스트: 예상되는 행위들에 대한 시나리오를 만들어 놓고, 시나링오대로 동작했는지에 대한 여부를 확인하는 테스트

 


🙋‍♂️ 의존성 주입 (Dependency Injection)

- 하나의 객체가 다른 객체의 의존성을 제공하는 기술이다. DI라고 줄여서 부르기도 한다.

 

🙋‍♂️ 의존성이란?

class Car {
    var wheel: Wheel = Wheel()
}

class Wheel {
    var weight = 10
}

- 어떤 객체가 내부에서 생성하여 가지고 있는 객체를 말한다.

- Car 클래스 내부에서 사용된 Wheel이 의존성이다.

- Car 클래스는 Wheel 클래스에 의존하고 있다.

 

🙋‍♂️ 의존성 주입이란?

class Car {
    var wheel: Wheel

    init(wheel: Wheel) {
        self.wheel = wheel
    }
}

class Wheel {
    var weight = 10
}

let myWheel = Wheel()
let myCar = Car(wheel: myWheel)

- 의존성을 주입해준다는 의미는 내부에서 초기화가 이루어지는 것이 아닌, 외부에서 객체를 생성하여 내부에 주입하는 것이다.

- myWheel을 외부에서 생성하여 Car를 초기화할 때 myWheel을 주입시켜주고 이싿.

- 결합도를 낮추기 위해서 사용된다. -> 리팩토링과 테스트 코드 작성이 쉬워진다.

 

 

 

class UpDownGame {
    var randomValue: Int = 0
    var tryCount: Int = 0
    var urlSession: URLSessionProtocol

    init(urlSession: URLSessionProtocol = URLSession.shared) {
        self.urlSession = urlSession
    }

    ...
}

- 위 코드에서도 URLSession 타입의 의존성을 주입할 수 있도록 되어 있다.

- 테스트를 진행할 때 Test Double 객체를 셀제 URLSession 자리에 바꿔치기 시키면서 테스트를 진행시키기 위함이다.

 

 

 

 

 


다음 주소에서 참고하였습니다.

 

 

Unit Test 작성하기 - 야곰닷넷

Unit Test 작성하기 테스트를 작성하면 좋다는 말을 많이 들어보셨을 것 같습니다.  하지만 막상 테스트를 작성해보려고 […]

yagom.net

 

반응형