안녕하세요 !!
평소에도 지겹게 들어보던 TDD.
개인이 시도해보기에는 어렵다는 편견을 가지고있었는데..
RayWenderRich의 TDD에 관련된 책을 읽으면서
직접 TDD에 적응했던 과정을 처음 접해보는 사람들에게 공유해주면 도움이 될것 같아 포스트를 작성하게 되었습니다.
자, 그러면 오늘도 함께 가볼까요?!💪
작성했던 모든 코드는 GitHub에 있습니다!🙇🏻
테스트 주도 개발,TDD은 반복적인 소프트웨어 개발 방법이며, 반복적으로 작고 많은 테스트를 생성이 뒷받침 됩니다.
TDD에서는 네가지 단계가 존재합니다.
위 네 단계를 준수하여 개발하면
테스트에 의해 개발이 이루어지기 때문에 코드를 철저하고 정확하게 테스트할 수 있습니다!
프로덕트 코드가 테스트를 통과 하도록 테스트를 작성함으로써 코드가 testable 해지고, 모든 요구사항을 충족하기에 유리합니다.
자, 그러면 왜 TDD를 사용해야 할까요?
TDD를 사용하는 이유는 소프트웨어가 지속적으로 잘 작동하게 하는 가장 좋은 방법이기 때문입니다!
좋은 테스트 코드는 소프트웨어가 정상적으로 작동하게 하지만 모든 테스트 코드가 좋은 것 은 아닙니다.
그렇다면 좋은 테스트 코드란 무엇이냐 ?!
테스트 코드를 작성하지 않고도 테스트를 진행할 수 있으며, 단기적으로 테스트 코드를 작성하지 않으면 빠르게 프로그램을 완성할 수 있을지 모릅니다.
하지만 장기적으로는 테스트 규율을 만들어 TDD와 비슷한 프로세스를 진행하게 될지도 모릅니다. 🤭
(그러니까 그냥 첨부터 TDD 하자!)
그렇다면 무엇을 테스트 해야하는지에 대한 의문이 생길텐데요,
테스트 범위가 넓은것이 무조건 잘 테스트 되었다는것을 의미하진 않습니다.
다음은 해야할것과 하지 말아야할 것입니다!
그치만.. 프로덕트 코드 작성할 시간도 부족한걸 ..!?😭
TDD의 가장 보편적인 컴플레인은 너무 오래걸린다는 점인데요.
(뒤에 이모지 같은거 쓰는것도 예상해뒀음 소오름)
하면할수록 늘기 마련 !! 우선 해보면 점점 더 빨라진다고 합니다.
그래도 초기에는 일반적으로 프로덕트 코드를 작성하는 것 보다 시간이 더 걸립니다.
테스트를 위해 이것저것 고민해야 할 일이 많기 때문이죠.
단기적인 관점에서 TDD는 일반적인 방법보다 시간이 더 오래 걸리지만,
장기적인 관점으로 보았을 때, TDD는 버그를 줄여주고, 유지보수에 필요한 자원을 줄여주기 때문에 더 적은 시간을 들여 코드를 생성합니다.
버그를 찾아내고, 해결하는 것 또한 많은 시간이 소요되기 때문에 결과적으로 버그를 줄이면 총 개발 소요 시간도 많은 단축을 이뤄낼 수 있을 것 입니다.
언제 TDD를 적용해야 하나요 🙋♂️
TDD는 프로덕트의 모든 라이프 사이클에서 적용할 수 있습니다.
하지만 어떻게 어디서부터 TDD를 적용해야할지는 프로젝트의 상태에 따라 달라집니다.
아래 내용을 통해 확인해보세요
궁극적으로 TDD는 툴이기 때문에 사용했을 때 좋은 효율을 낼 수 있도록 해야합니다.
그러면 이제 TDD를 본격적으로 시작하기 전에 앞서 잠깐 언급했던 TDD Cycle을 먼저 보죠!
TDD에는 4가지의 단계가 존재한다고 했습니다.
이를 조금 더 보기 쉽게 색으로 정리해보겠습니다.
자! 솔직히 처음과 똑같은 구조, 단계인데 왜 색으로 나타냈느냐!!
바로 실제 Xcode에서 테스트를 진행 하고 나서의 상태와 동일하기 때문입니다.
실패한 테스트는 Red, 성공한 테스트는 Green으로 나타납니다!
이제는 진짜 코드 단계로 넘어가보겠습니다!😆
모든 코드는 playground에서 작성하였습니다!
(XCTAssert, XCTest의 문법이나 자세한 내용은 다른 포스트에서 다루겠습니다!)
실패 테스트를 만드는 과정은 다음과 같습니다.
먼저 다음과 같은 테스트 케이스 class를 만듭니다.
class CashRegisterTests: XCTestCase {
}
Test Case를 작성할때는 대부분 XCTestCase class를 상속받아 작성합니다.
Project 생성 시 자동으로 만들어주는 Test파일을 열어봐도 XCTestCase class를 상속받습니다.
class CashRegisterTests: XCTestCase {
// 1
func testInit_createsCashRegister() {
// 2
XCTAssertNotNil(CashRegister())
}
}
init을 테스트 하기 위한 testInit_createsCashRegister() 함수를 작성하고,
XCTAssertNotNill() 함수를 이용하여 CashRegister의 인스턴스를 생성하고, 인스턴스가 not nil인 경우에 성공 처리를 해줍니다.
마지막으로 가장 마지막 라인에
CashRegisterTests.defaultTestSuite.run()
위 코드를 작성하고 run을 하면 테스트를 실행해 줍니다.
하지만 위 코드에서는 CashRegister에 대한 정의가 없으므로, XCTAssertNotNill 라인에서 에러가 날 것입니다.
개인적인 의견으로는 테스트를 진행하는데에는 에러가 없고, 로직 상에서 실패하는 테스트를 만들어야 하는 줄 알았는데, 이렇게 간단하게도 실패 테스트를 작성할 수 있네요!!
물론 실패 테스트는 이렇게 작성하는거란다... 미개한 개발자여.. 이런느낌이겠지만요!
이렇게 Red Step이 마무리 됩니다!! 다음 단계로 넘어가 볼까요 ?! 🏃♂️
이제 실패 하는 테스트를 성공 케이스로 만들어보겠습니다.
앞서 Green Step에서는 테스트를 통과하기 위한 최소 코드를 작성하는 단계 입니다.
위에서 작성했던 코드는 에러 때문에 성공하지 못하는 테스트 입니다.
따라서 우리는 테스트를 성공 시키기 위해서 에러를 해결하면 될 것 같습니다 !! 😄
class CashRegister {
}
// Regacy Code
class CashRegisterTests: XCTestCase {
func testInit_createsCashRegister() {
XCTAssertNotNil(CashRegister())
}
}
CashRegisterTests.defaultTestSuite.run()
Regacy Code 위 쪽에 CashRegister class에 대한 정의를 했습니다.
그리고 run하여 테스트를 진행하면,
다음과 같은 로그를 확인 할 수 있습니다.
자.. 그러면 이제 테스트를 통과 했죠?
이렇게 Green Step이 마무리 됩니다.👏👏 어때요 정말 쉽죠?
다음은 Refactor Step 입니다 !!
자, Refactor Step에 왔는데... 어떤것을 Refactor할지 고민이죠 🤔
고민을 해결하기 위해 몇가지 기준을 제시하고 있었습니다.
함께 볼까요?
위의 기준에 따라 product code와 test code를 Refactor Step에서 정리합니다.
이렇게 하면 코드를 지속적으로 유지하고, 관리 할 수 있습니다!!
그러면 이전 단계에서 작성했던 코드를 Refactor 해보아야겠죠?
ㅋㅋㅋㅋ근데 Refactor 하기에는 코드가 너무 없네요
패스. (아 진짜 넘어간다 했다고요)
마지막으로 Repeat 단계입니다.
TDD의 각 단계를 확인하고, 실습했으니, test를 기반으로 product code를 작성 할 수 있게 되었습니다!
그러므로 앱 개발 모든 단계에 TDD를 적용하여 test를 기반으로 app을 구성해보도록 합시다 ! 😤
첫 TDD Cycle을 경험했고, instance화 할 수 있는 CashRegister class를 얻게 되었습니다.
이제 CashRegister를 확장 해 나가면서 실제 product code에 쓰일 만한 class를 만들어 보겠습니다.
아래의 세가지 항목을 추가 할 예정인데요 !! 함께 보시죠!
availableFunds
를 포함한 initializer 작성addItem
메소드 작성acceptPayment
메소드 작성첫번째로 availableFunds
property를 포함한 initializer를 작성하는 과정을 TDD로 다시 진행해 보겠습니다.
TDD Cycle을 생각하면서 먼저 실패테스트를 작성합니다.
func testInitAvailableFunds_setsAvailableFunds() {
XCTAssertNotNil(CashRegister(availableFunds: 100))
}
이렇게 테스트 코드를 작성하면 initializer를 만들지 않았기 때문에 당연히 complie 에러가 나게 됩니다.
위와 같은 방식으로 테스트 코드를 마무리 지을 수 있지만,
Unit Test에 유용한 방식을 이용해서 테스트 코드를 조금 변경해보겠습니다.
바로 Given, When, Then 입니다.
아래는 위의 방식을 적용한 코드입니다.
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
여기에서 given은 availableFunds
가 될것이며, Decimal(100)으로 정의하였습니다.
when은 init(availableFunds:)
을 통해 sut
을 생성하는 단계입니다.
Then의 기대하는 결과는 CashRegister instance의 availableFunds와 given에서 주어진 avaliableFunds의 값이 같은 것 입니다.
여담으로, CashRegister의 instance 이름인 sut
는 system under test 라는 의미입니다.
TDD에서 사용되는 테스트를 의미하는 흔한 이름입니다. 🤓
실패 테스트를 작성했으니 이제 성공하도록 코드를 작성해볼까요?!
class CashRegister {
var availableFunds: Decimal
init(availableFunds: Decimal = 0) {
self.availableFunds = availableFunds
}
}
class CashRegisterTests: XCTestCase {
func testInit_createsCashRegister() {
XCTAssertNotNil(CashRegister())
}
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
}
CashRegisterTests.defaultTestSuite.run()
CashRegister class에 initializer를 추가해 주었습니다!
이제 run을 해보면!
다음과 같은 로그를 확인 할 수 있습니다!!
테스트를 통과했습니다 👏👏👏
다음 단계는 Refactor입니다. 코드를 수정해야겠죠 ?
먼저 앞서 작성했던 테스트 코드인 testInit_createsCashRegister
가 더 이상 필요 없습니다.
init()
대신, init(availableFunds:)
를 사용하기 때문입니다.
CashRegister()는 CashRegister(availableFunds: 0)과 같은 의미입니다.
따라서 testInit_createsCashRegister
메소드를 삭제하겠습니다! 🤨
다음으로는 availableFunds
의 defalut value인 0
입니다. 이녀석이 꼭 필요할까요? 🤔
availableFunds
가 default parameter로 기대한 값으로 생성 되었는지에 대한 testInit_setsDefaultAvailableFunds
테스트를 추가해야 합니다.여기에서는 필요없다고 가정하고 parameter를 삭제해보겠습니다!
class CashRegister {
var availableFunds: Decimal
init(availableFunds: Decimal) {
self.availableFunds = availableFunds
}
}
class CashRegisterTests: XCTestCase {
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
}
CashRegisterTests.defaultTestSuite.run()
run 해보면 테스트를 통과합니다.
진행했던 Refactor 단계가 기존 로직을 해치지 않는다는것을 보장한다는 의미겠죠?!
이렇게 기존 로직이 항상 보장되는것도 TDD의 이점 이라고 생각합니다.
Refctor 단계도 마무리 되었고, 다음 TDD 사이클을 진행할 준비가 완료됐습니다!! 🥳
다음 TDD 사이클은 트랜젝션에 한개의 아이템을 추가하는 addItem
메소드를 작성하는것 입니다.
항상 TDD는 실패 테스트를 작성하는 것으로부터 시작합니다.
given은 CashRegister를 생성하고, 저장될 item을 생성합니다.
when CashRegister instance에 item을 저장하는 것 이고,
then은 저장된 item과 저장했던 item의 값이 같아야 하는 조건입니다.
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let availableFunds = Decimal(100)
let sut = CashRegister(availableFunds: availableFunds)
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
위의 조건대로 코드를 작성했습니다.
run을 한다면 물론 실패하겠죠 CashRegister에는 addItem이라는 메소드가 없고,
transactionTotal이라는 property도 존재하지 않기 때문입니다.
그러면 바로 성공하도록 고쳐보도록 하겠습니다.
class CashRegister {
var availableFunds: Decimal
var transactionTotal: Decimal = 0
init(availableFunds: Decimal) {
self.availableFunds = availableFunds
}
func addItem(_ cost: Decimal) {
transactionTotal = cost
}
}
자, 먼저 위에서 addItem 메소드는 transaction에 아이템을 추가하는 것 이였습니다.
하지만 함수의 구현부를 보면, 그저 받은 값을 대치하고 있습니다.
여기서 중요한점이 하나 있습니다.
테스트를 통과하기 위해 최소 코드를 사용하여 작성합니다.
이때 물론 addItem의 구현부를 아래 코드로 대치하면 되는것을 우리는 알고있습니다.
transactionTotal += cost
하지만 이는 최소 코드가 아닐것입니다.
따라서 저희는 transactionTotal을 cost로 대치하는 코드를 작성하고, 테스트를 성공시킵니디.
여기까지가 테스트 성공 단계입니다.
물론 구현되지 않은 기능은 다른 TDD 사이클에서 구현 해야 합니다! 😏
그럼 Refcator 단계로 바로 넘어가보겠습니다 😃
class CashRegister {
var availableFunds: Decimal
var transactionTotal: Decimal = 0
init(availableFunds: Decimal) {
self.availableFunds = availableFunds
}
func addItem(_ cost: Decimal) {
transactionTotal = cost
}
}
class CashRegisterTests: XCTestCase {
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let availableFunds = Decimal(100)
let sut = CashRegister(availableFunds: availableFunds)
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
}
CashRegisterTests.defaultTestSuite.run()
여기까지가 현재까지 작성한 모든 코드입니다.
testInitAvailableFunds_setsAvailableFunds
메소드와 testAddItem_oneItem_addsCostToTransactionTotal
메소드의 given과 when을 보면 중복되는 코드들이 존재합니다 !!!
먼저 이녀석들을 해결해보겠습니다.
class CashRegisterTests: XCTestCase {
var availableFunds: Decimal!
var sut: CashRegister!
func testInitAvailableFunds_setsAvailableFunds() {
// given
availableFunds = Decimal(100)
// when
sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
availableFunds = Decimal(100)
sut = CashRegister(availableFunds: availableFunds)
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
}
공통으로 사용되는 녀석들이니 CashRegisterTests
클래스의 property로 만들어 보겠습니다.
CashRegisterTests
클래스는 인스턴스가 생성될 일이 없으므로 init을 지정할 이유가 없습니다.
따라서 property들은 초기화 되지 않으므로 optional 값으로 두어야 하지만, 여기서는 !마크로 optional을 강제로 해제하겠습니다.
이렇게 되면 property가 nil인 경우 접근 시 crash가 일어나게 되지만, 이렇게 해도 무방합니다 ㅎㅎㅎㅎ
이유는 바로 아래에서 설명드리겠습니다!! 😆
XCTest에 있는 메소드에 잠시 눈을 돌려 보겠습니다.
바로 setUp()와 tearDown() 인데요, 해당 메소드들은 각각의 테스트 메소드들이 호출되기 전 / 메소드가 종료 된 후에 호출 됩니다.
즉, init, deinit과 비슷한 역할을 합니다.
따라서 setUp()에서 optional로 두었던 property들을 초기화 해주면 각각의 테스트 메소드에서 사용할 때는 값이 항상 존재하고, 함수가 종료되면 nil로 설정하여 값을 비워두는겁니다!!
이렇게 하면 property에 접근할때에는 항상 값이 존재하는것이 보장됩니다 😘
코드로 볼까요 ?
class CashRegisterTests: XCTestCase {
var availableFunds: Decimal!
var sut: CashRegister!
override func setUp() {
super.setUp()
availableFunds = 100
sut = CashRegister(availableFunds: availableFunds)
}
override func tearDown() {
availableFunds = nil
sut = nil
super.tearDown()
}
func testInitAvailableFunds_setsAvailableFunds() {
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
}
tearDown() 메소드의 super.tearDown()은 항상 setUp() 메소드에서 설정되었던 모든 property들이 nil 상태로 되어야 합니다.
그렇지 않으면, 모든 테스트 케이스에서 사용되지 않는 property가 남아 있을 수 있기 때문에 성능상에 문제가 있을 수 있습니다.
setUp()메소드로 인해 testInitAvailableFunds_setsAvailableFunds
메소드의 내용이 한줄로 축약되었습니다.
이렇게 되면 given, when 조건이 사라지기 때문에 명확하게 어떤 테스트를 진행하는지 한눈에 알아볼 수 있습니다.
이제 run을 진행하면, 마찬가지로 문제 없이 테스트가 통과 됩니다 !!!
또 한번의 Refactor 단계를 마무리했습니다. 👏
이제 마무리하면 될까요?? 🤔
안되죠 !! 아까 실패 테스트 코드가 한개의 아이템에 대한 동작만 작성했으므로, 여러가지 아이템에 대한 테스트도 작성해야 합니다.
따라서 다시 TDD 사이클을 한번 더 실행 합니다.
먼저 실패테스트 입니다.
given은 트랜잭션에 추가할 item을 두개 만들어 보겠습니다.
when은 각각의 아이템을 더하는 것 일 겁니다.
그리고 then은 추가된 item들의 합과, CashRegister에 있는 item들의 합과 같아야 할 것 입니다.
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost = Decimal(42)
let itemCost2 = Decimal(20)
let expectedTotal = itemCost + itemCost2
// when
sut.addItem(itemCost)
sut.addItem(itemCost2)
// then
XCTAssertEqual(sut.transactionTotal, expectedTotal)
}
run을 하고 로그를 보면 1 failure 가 보입니다.
기존 실패테스트는 compile에러를 발생시켰지만, 여기에서는 then 라인에서 에러가 나게 됩니다.
저희가 작성했던 addItem은 전달받은 item을 대치시키는것 뿐이니까요. 🥲
그렇다면 실패테스트를 작성했으니 테스트를 통과하는 단계로 넘어가보면,
addItem를 다음과 같이 수정하면 됩니다.
func addItem(_ cost: Decimal) {
transactionTotal += cost
}
그리고 run을 해보면 ?!
모든 테스트 케이스가 성공했습니다 👏👏👏
자, 마지막 단계입니다. Refactor 단계!!!
여기에서는 어떤 Refactor를 할 수 있을까요? 두근두근
itemCost에 Decimal을 할당하는게 자주 보이네요!!
마찬가지로 CashRegisterTests의 property로 변경해보겠습니다.
먼저 setUp() 메소드에서 itemCost를 설정해주고, tearDown()에서 nil을 할당하는것을 잊지 말아주세요 !!
그리고, setUp()에서 itemCost를 설정했으니,
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let itemCost = Decimal(42)
}
위 코드와
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost = Decimal(42)
}
위 코드가 사라지고
아래처럼 정리가 됩니다!
final class CashRegisterTests: XCTestCase {
private var availableFunds: Decimal!
private var itemCost: Decimal!
private var sut: CashRegister!
override func setUp() {
super.setUp()
availableFunds = 100
itemCost = 42
sut = CashRegister(availableFunds: availableFunds)
}
override func tearDown() {
availableFunds = nil
itemCost = nil
sut = nil
super.tearDown()
}
func testInitAvailableFunds_setsAvailableFunds() {
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost2 = Decimal(20)
let expectedTotal = itemCost + itemCost2
// when
sut.addItem(itemCost)
sut.addItem(itemCost2)
// then
XCTAssertEqual(sut.transactionTotal, expectedTotal)
}
}
CashRegisterTests.defaultTestSuite.run()
자, 이렇게 Refactor 단계를 마무리 하겠습니다 👏👏👏👏
Refactor단계는 자신의 스타일에 따라 계속 진행해 나가면 될 것 같습니다.
물론 잘 모르겠다면 위의 기준을 참고해서 무엇을 해야할 지 고민 해보면 도움이 될 것 같습니다!!
마지막으로 acceptCashPayment(_ cash:)
메소드 작성 단계가 남아있는데요,
acceptPayment의 동작은 다음과 같습니다
또, acceptPayment의 테스트 메소드는 testAcceptCashPayment_subtractsPaymentFromTransactionTotal
이며, 동작은 다음과 같습니다.
TDD Cycle에 따라 직접 시도해보고 제 코드와 비교해 보면 더욱 좋을 것 같습니다!! 😙
이번 포스트는 TDD가 무엇이고, TDD Cycle은 무엇이며, 어떻게 진행되는지 알아보았습니다.
말로만 듣던 TDD.. 이제 조금 안다고 할 수 있을 것 같네요 😆
이 다음은 TDD를 적용하여 앱을 만들어보는데요.. 조금 길것 같아서 걱정되네요 ㅜ
그래도 시도해보고 꼭 포스트를 남겨보겠습니다!!
코드는 GitHub에 남겨두었습니다~!
질문이나 지적은 언제든 환영입니다!!
오늘도 읽어주셔서 감사합니다 ! 🙇🏻♂️