안녕하세요!! 요즘 에어컨 빵빵한 곳에 있으니 공부가 잘 되네요. 오늘 "테스트 코드"를 일부 주제로 열정 빵빵한 부트캠프 팀원들과 의논하며 테스트 코드에 대해 의견을 나누며 공부 했습니다!!! 제가 테스트 코드를 작성하며 알게된 뜻밖의 장점?과 프로토콜을 사용하는 이유와 프로토콜의 장 단점, 의존성 주입과 역전 원칙에 대해서 소개하려고 합니다. 추가로 제가 이해한 Stub과 Mock에 대해서 차이도 소개할 것입니다.
소프티어 부스트캠프.. JK님 덕에 정말 좋은 개념들을 알아가게됬습니다...
Unit Test. 누구세요? 뭘 테스트하는 거죠?
Unit test를 소개하기 전에, 우선 테스트 코드!는 내가 또는 다른 개발자가 구현한 일부 기능이 잘 동작되는지!! sut(system under test)의 특정 메서드가 어떻게 동작되어 의도된 값을 반환하는지 알 수 있습니다. 특정 테스트 코드 test...() 함수를 보며 "아!! 이 기능은 이렇게 진행되는구나", "sut(객체)의 주요 흐름은 이런 흐름이고 어떤 것을 제어하고 실행하는 객체구나!!" 라는 것을 알 수 있습니다.
함수 명을 길고 자세하게 지었다면(엇,, 위에는 너무 긴가요?),, 어떤 sut를 테스트 중이고, 어느 상황에서 어느 결과를 반환할 것이다!! 함수명을 통해 유추도 할 수 있습니다. 테스트 코드를 작성한 함수는 한 번만 작성 후 초록불 뜨고 끝나는게 아닙니다.
테스트를 하는 이유! 가장 쉬운 예!! + 의존성 주입과 역전 원칙
예를들어 날씨 데이터(습도, 온도) 두 개의 데이터를 받아서 습도!와 온도!를 보여주는 특정 뷰의 UI는 지속적으로 습도, 온도 데이터를 받아서 그 값을 뷰에 render(업데이트, 새로 반영) 합니다. "더움", "온도"라는 두 데이터를 받는 결과!는 어디에서 가져오던지 꼭 필요한 두 개의 데이터입니다. View의 역할은 어디선지 모를 두 개의 데이터를 화면에 보여주는것!!!
데이터를 제공해주는 open api로 "Weather API"를 사용하다가 무료 평가판이 3일 남았고 이제 요금제로 전환되서 돈을 내야하는데 하루에 내야할 요금이 천만원이라고 한다면, 지금 당장 화면에 보여줄 수 있는, 습도와 온도 두 데이터를 무료로 제공해 줄 수 있는 새로운 rest api를 찾아야 합니다.
습도, 온도를 보여주는 View는 정말 고생해서 10일 밤낮으로 이쁘게 구현해서 완벽한 상태거든요. 찾아보니,, 기상철날씨 API가 공짜네요!! 이 api를 사용하게 됬을 때 이전과 마찬가지로 더움, 온도라는 두 데이터를 뷰한테 건내주어야 합니다. 이렇게 기존에 서비스 중인 weather api를 더 이상 못 쓰게되어 새로운 api로 교체했을 때, 사전에 테스트코드를 작성했다면 뷰가 정말 특정 두 타입의 데이터를 보여주기 위해 반환하는 함수가 Success 되는지 실행하면됩니다.
즉 새로운 API가 교체되도 화면은 지속적으로 같은 데이터 양식, Model이 필요로 한다면? 네이버, 다음, 구글 등 어느 서비스를 사용하던 특정한 형식의 동일한 데이터를 반환하는 코드를 테스트 해놓고 예상되는, 동일한 데이터를 반환하는 테스트가 성공한다면!! 뷰 입장에서는 이전에 받은 동일한 형식을 계속 받게되는 것입니다. ( 뷰 입장에서는 서비스가 바뀐지도 모르겠네요? 계속해서 동일한 데이터를 받게되니까요. ) 굳이 실행해서 화면으로 보지 않아도 검증이 되는 것입니다!! 테스트 코드가 초록불을 띄게 된다면요!!
근데 어떻게 서비스가 교체되도 날씨 View의 내부 인스턴스나 함수의 형태를 바꾸지 않고 뷰는 계속해서 같은 데이터를 제공할 수 있을까요? 지금 고민해봤는데 protocl말고도 방법이 존재하네요. 대표적으로 떠오른 것은 날씨, 습도 properties를 가진 struct를 사용하는 것입니다.
단순히 properties만 있는 경우에는 struct도 나쁘지 않은 것 같은데 사실 struct는 상속이 안 된답니다.... test를 위한 Mock, Stub(뒤에 개념 정리!!) 객체를 만든다면, 날씨 View에 필요한 프로퍼티, 함수들을 struct에서 일일이 찾아서 써줘야하는 불편한 .. + 빼먹을 수도 있다는 사실이.. 즉 struct는 유연하지 못합니다. 상속은 아예 안됩니다.
반면 protocol은 추상화가 가능합니다. 여러 곳에서 다중 채택이 가능합니다. associatedType을 통해 특정 타입만 사용할 수 있도록 제약을 걸 수도 있고 공통된 기능을 직접 정의할 수 있습니다.
프로토콜의 장점은 의존성 역전 원칙에 의한 의존성 주입이 가능하다는 것입니다.
날씨 View는 WeatherDataprovider protocol 타입인 객체를 갖고 있다면 프로토콜 안에 정의된 두 함수만 사용할 수 있습니다. 해당 프로토콜을 준수하는 어느 객체가 주입을 하던.. 말입니다!! 함수 내부가 어떻게 구현된지 상관안하고 Weather, Humidity 두 개의 데이터만 관심 있습니다.
WeatherDataProvider protocol을 준수하는 t, WeatherAPI, 기상청 날씨API 객체들 전부 날씨가 갖고 있는 WeatherDataProvider타입의 인스턴스에 주입될 수 있습니다. protocol은 유연해서 위 언급한 객체 뿐 아니라 test를 위한 Mock, Stub객체들도 만들 수 있습니다.
날씨의 입장에서는 WeatherDataProvider 인스턴스의 두 함수를 사용하면 됩니다. WeatherAPI, 기상청 날씨 API, t 객체 중 어느게 오든 상관없이 Weather, Humidility를 반환하는 protocol 내 두 메서드만 사용하면 되는 것입니다.
두 객체는 어느 한 객체에 의존하는게 아니라 추상적인 프로토콜에 의존하게 되는 것입니다.
의존성 주입
class BaseCard {
var number: Int
}
class LuckyCard {
// LuckyCard는 반드시 baseCard를 인스턴스로 갖어야 한다!!! LuckyCard는 baseCard에 의존적이다
// (baseCard 초기화가 안되면 LuckyCard인스턴스 생성도 못하고 사용도 못한다!!)
var baseCard: BaseCard
init(baseCard: BaseCard) { self.baseCard = baseCard }
}
//의존성 주입(Dependency Injection)
let luckyCard = LuckyCard(card: BaseCard())
LuckyCard는 반드시 card클래스 인스턴스가 필요합니다 card 인스턴스에 대한 의존성이 생긴 것입니다. luckyCard인스턴스를 만들 때, 이 인스턴스는 외부에서 주입됩니다.
여기서 의존성 역전 원칙의 개념이 들어가야 두 객체는 프로토콜을 통해 교류하게 됩니다. 지금 같은 경우에는 LuckyCard는 Card에 의존적입니다. 의존성 역전 원칙은 A객체가 B객체에 의존하는게 아니라 서로가 프로토콜에 의존함을 뜻합니다.
protocol Card {
var num: Int { get set }
}
// Card protocol 준수
class BaseCard: Card {
var num: Int
init(num: Int) { self.num = num }
}
// Card 프로토콜 준수
class LuckyCard {
// 객체(구조체 or 클래스)가 아닌 프로토콜(추상적인)에 의존!!
var card: Card
init(card: Card) { self.card = card)
}
let baseCard = .init(num: 2)
// 의존성 주입
let luckyCard = LuckyCard(card: baseCard)
다시말해 LuckyCard(상위 객체)도 Card protocol에 의존합니다. 하위 객체(BaseCard)에 직접 의존하는게 이나라, 인스턴스로 BaseCard타입을 갖는게 아니라 추상적인 protocol의 인스턴스에 의존하는 것을 의미합니다.
Unit Test 경험,, in Swift!!
유닛 테스트는 에서 Unit은 함수가 될수도있고 클래스가 될 수도 있고 module이 될 수 있습니다. 내가 작성한 프로그램이 "내가 설계한 로직은 내가 설계할 때 의도한 대로 결과가 반환될까?" 에 대한 검증 과정입니다. 또한 구현한 메서드는 잘 동작되는지 등.. unit Test를 하지 않는다면 디버깅이나, print(), lldb를 활용해 계속해서 체크할 것입니다.
객체는 여러 가지 다양한 기능이 아니라 해당 객체가 수행해야 할 최소한의 기능을 갖고 있도록 설계해야 유닛(클래스)을 테스트 할 때(안에 정의된 메서드) 유지보수가 간편해지고 테스트 코드를 통해 해당 객체의 핵심 로직들을 알아챌 수 있습니다. SPR. 단일 책임의 원칙을 갖도록 클래스나 모듈은 하나의 책임을 갖기 위해 설계되면 좋습니다.
Mock, Stub 등의 Test Double 만들기 이전에 protocol을 선언했다면 편합니다. Test Double은 쉽게 표현하자면 실제 클래스로 정의한 객체의 인스턴스가 아니라, 훨씬 가벼운, 실제로 사용되는 객체 대신으로 만든 모조품? 이라 생각하시면 편합니다. 실제처럼 동작하지만 알고보면 모조품이라는.. 실제 인스턴스 구현처럼 무거운 구현이 아니라 속은 짝퉁이지만 겉.. 결과를 봤을 때는 진품과 같은 이런 모조품들을 사용해서 의존성이 요구되는 sut(system under test) 안에 있는 프로퍼티들의 초기화를 대체할 수 있습니다.
Test Double 중 Stub, Mock가 있는데 Stub은 주로 메서드의 특정 반환 값을 하드코딩으로 반환합니다. (데이터를 반환해주는데 Just fake data!!)
Mock은 실제 처럼 동작하는 인스턴스와 가장 유사한 객체라고 볼 수 있습니다. 행위 기반입니다. 주어진 상황에 대해서 어느 Mock 객체 안 메서드를 호출했을 때 예상되는 결과가 일치하는가? 이게 가장 큰 목표인 것 같습니다. 이떄 Mock 객체 안 메서드는 일반적으로 실제 인스턴스가 수행하는 메서드들을 구현한 것이라 생각하면 됩니다 protocol에 정의를 잘 해뒀다면 Mock객체를 만들 때 용이합니다.
이전에 Unit test 공부를 할 때 잘 와닿지 않았는데.. 이번에 제가 짠 코드에 대해서 테스트 코드를 작성하면서 완벽하게 동작될 줄 제 코드에 이상이 있음을 test를 통해 처음으로 알게 되었습니다.
protocol Deck: AnyObject {
associatedtype Card: Comparable, Equatable
var cards: [Card] { get set }
var description: String { get }
}
// MARK: - Helper
extension Deck {
func insert(_ card: Card, at index: Int) -> Card? {
guard (0...cards.count).contains(index) else {
return nil
}
cards.insert(card, at: index)
return card
}
func shuffle() {
_=(0..<cards.count-1).map {
let randInt = Int.random(in: $0..<cards.count)
if $0 != randInt { cards.swapAt($0, randInt) }
}
}
func remove(at index: Int) -> Card? {
guard (0..<cards.count).contains(index) else {
return nil
}
return cards.remove(at: index)
}
func contain(of card: Card) -> Bool {
return cards.contains(card)
}
func contains(of cards: [Card]) -> Bool {
return self.cards.contains(cards)
}
...
}
카드를 관리할 수 있는 cardDeck의 공통적인 기능들을 정해두고.. Deck에 필요한 데이터를 채워줄 StubLcukyCardGameManager를 추가했습니다!!
그리고 주어진 Deck의 기능들을 준수한 LuckyCard를 갖고 있는 Deck에 대한 테스트를 진행햇습니다.......
원래대로라면 제가 설계했던 Deck 함수 안 메서드들은 완벽하다고 생각했지만(테스트 나 실행 x) 그냥 제 생각이었단느 것을 알게되었습니다.ㅋㅋ/..
제가 작성했던 Deck안에 contains(of:) 메서드가 잘못됨을 알게됬고 코드를보니 실제로 단일 카드 검색하는 로직과 같은 함수를 사용했다는 것을 알게 되었습니다.
만약 테스트 하지 않았으면??!! 모델을 보여줄 UI Component 만들고 실제로 터치 입력에 따라 데이터의 변화를 반복적으로 수행되면서 혹시 발생할 수 있는 오류를 찾았을 것입니다. 어디가 틀렸는지 매의 눈으로 지켜봐야 했었을 텐데.. 이렇게 사전에 객체, 클래스에 대한 기능을 단위로 테스트를 하면서 로직에 대한 오류를 찾는것도 좋은 방법임을 알게 되었습니다.
저는 원래 이런 protocol과 클래스를 구현하고 실행하면서 print나 디버깅으로 중간 중간에 잘 동작되나 체크를 하면서 개발합니다. 또는 UI까지 다 구현하고 사용자 관점에서 잘 동작되나? 내가 정의한 객체 안 메서드들이 속성들과 잘 어우러져 flow가 진행되는지, 내가 구현한 오류는 없는지 파악 하며 예상치 못한 오류를 찾았던 것 같습니다.
이런 방법보다 테스트 환경에서 어차피 만들어야할거.. 설계할 때 테스트하며 만든 후 실제 인스턴스 구현! 의 과정도 정말 좋겠다는 인식이 잡혔습니다. 테스트 코드가 쓸모 있다는 경험을 했습니다. ( 예상치 못한 곳에서 제 로직의 문제점을 찾았기 때문입니다..)