TDD With iOS - Demo TDD#1

sanghoon Ahn·2022년 3월 19일
1

iOS

목록 보기
14/20
post-thumbnail
post-custom-banner

안녕하세요~!! 🙂

이전 포스트에서는 TDD를 맛봤고.. 이제 진짜 Test-Driven-Develop을 시도해 볼 때가 되었습니다.

두둥🥁

이번 포스트의 목표는 Xcode에서 어떻게 테스팅이 이루어지는지, 실제 프로젝트를 생성하고 몇가지의 테스트를 진행 해 보는 것 입니다.

또한 다음의 항목들을 습득 할 수 있다고 합니다!! 💪

  • test target을 생성하고, Unit Test를 실행 할 수 있습니다.
  • data와 state를 검증하는 Unit Test를 작성 할 수 있습니다.
  • TDD methodology에 대해 거부감이 없어 질 수 있습니다.
    • Given, When, Then
    • System Under Test (sut)

이번 포스트 부터는

iOS Test-Driven Development by Tutorials 글에서 제공하는 프로젝트를 사용하겠습니다!!

Fitness App

들어가기 전에, 샘플로 제공되는 앱이 어떤 기능을 하는지 알아야겠죠? 😉

샘플 프로젝트는 fun step-tracking 앱으로써,

사용자는 자신의 아바타가 네시(몬스터)에게 잡아먹히지 않도록 사용자의 움직임의 동기를 부여한다고 하네요!!

약간 게임같은 느낌이라 재미있어 보이네요!! 🕹️

프로젝트는 깃 허브 링크 Chapter 3의 starter 폴더의 FitNess 프로젝트를 참고하시면 됩니다!

그럼 출발해볼까요!?


First Test

이제 여러분들은 실제 프로젝트에 TDD를 적용하기위한 첫 걸음을 떼었습니다. 👏

앱을 실행해보거나 프로젝트의 모든 파일을 분석해보면..

당연히 기능이 하나도 안붙어있습니다 푸항항 🤣

이제부터 하나씩 테스트 기반으로 기능을 만들어볼건데요!

먼저 프로젝트에 Test Target이 없으니, 추가해보겠습니다.

Xcode의 버전마다 Target의 이름, 이미지가 다를 수 있습니다 🤭

이때, UI Test Bundle이 아닌, Unit Test Bundle을 추가해주세요!!

UI Test는 나중에 다루겠습니다.. 총총..

Project와 identifier를 지정하고, Bundle을 생성하면, FitNessTest디렉토리가 생성됩니다.

이로써 사전 준비는 끝났고, 본격적으로 테스트를 작성해 보겠습니다.

TDD의 프로세스 중 첫번째는 RED, 실패하는 테스트를 작성하는것 입니다.

그렇다면 어떤것을 테스트 해야할까요? 🤔

정답은 없습니다.

먼저 비즈니스 로직을 작성하고 싶다면

해당 비즈니스 로직에 대한 실패 테스트를 작성(Red)하고,

테스트 코드를 성공하게 만들고(Green),

코드를 리팩터링(Refactor) 하는 작업이 이루어 지면 됩니다!

하지만.. 감이 잘 안잡히실것 같으니까, 우선 따라해 볼까요..? 🥲

App Model 디렉토리의 AppState Enum을 확인해주세요!

AppState는 말 그대로 App의 현재 실행 상태를 나타냅니다.

그리고 AppModel은 현재 App의 AppState를 가지고 있는 Singleton 객체입니다.

AppModel의 비즈니스 로직은 다음과 같습니다.

  1. 앱은 .notStarted 상태로 실행하게 된다.
  2. .notStarted 상태일 때는 welcome message를 화면에 표시한다.
  3. 사용자가 Start Button을 누르면, .inProgress 상태로 변경한다.
  4. .inProgress 상태일때는 사용자의 움직임을 추적하고, UI를 업데이트 한다.

Red-Green-Refactor

실제 포스트에서는 파일을 따로 분리하여(FitNessTests.swift가 아닌, AppModelTests.swift에서) 작성하지만, 여기에서는 하나의 파일에 작성해보겠습니다.

나중에 분리해도 상관없으니까요 ^&^

또한 조건을 먼저 보고 여러분들이 직접 테스트 코드를 먼저 작성해보세요!!

그리고 제가 작성한 내용과 어떻게 다른지 직접 확인해 보시는것도 충분히 도움이 될 것이라고 생각합니다. 💪

이때, 테스트 함수의 이름, 변수명 등은 달라도 무관합니다 !! 😆

로직의 흐름만 동일한지, 확인해 보시면 좋을것 같습니다. ⭐

자, 이제 RED 상태의 테스트 코드를 작성해보겠습니다.

첫번째 조건은 AppModel은 .notStarted 상태로 Initialize 된다 입니다!!

조건에 맞게 코드를 작성하고 제가 작성한 코드를 확인해볼까요~?

func test_AppModel_whenInitialized_isNotStartedAppState() {
	let sut = AppModel()
	let state = sut.appState
	XCTAssertEqual(state, AppState.notStarted)
}

참, 함수명과 변수명은 어떻게 지어도 상관없습니다!!

특히, sut에 대한 변수명을 많이 보실텐데요, 설명은 이전 포스트에 있습니다!

어떠신가요..? 완벽하게 똑같진 않더라도, 어떤것을 검증해야 하는지 감이오셨나요?

아니시라면.. 테스트 코드를 함께 보겠습니다. 🤣

func test_AppModel_whenInitialized_isNotStartedAppState() {
	// given
	// when
	let sut = AppModel() 
	let state = sut.appState

	// then
	XCTAssertEqual(state, AppState.notStarted)
}

여기에서는 저는 given이 없다고 생각합니다.

왜냐하면 테스트시 특별하게 주어져야 하는 조건이 없다고 판단했기 때문입니다.

그리고 when의 경우, AppModel이 처음 Initialize 될 때를 의미하며,

마지막으로 then은 앱 실행시 생성된 AppModel의 state가 .notStated인지 체크하는것 입니다.

설명을 보고나니 조금 이해가 되시나요?

코드를 작성하고 나니까.. 다음과 같은 에러가 보이네요

어..? 분명히 프로젝트에는 AppModel과, AppState는 잘 작성되어있는데, 왜 에러가 날까요?

작성자가 잘못올렸나? 생각하실 수 있지만,

Test는 Target이 다르게 잡힙니다.

실제 코드들이 동작하는 FitNess와는 다른 Target으로 분리되어, Test에서 FitNess의 코드를 사용하기 위해서는

import FitNess

FitNess Module을 import 하는 코드가 들어가야 합니다!

자, 위 코드를 추가하고 다시 코드를 보면?

아까와는 다른 에러가 났죠?

이번엔 AppModel에 appState가 없다는 에러입니다.

그러면 이 테스트 코드가 동작하도록 바꾸는 Green 단계로 넘어가서!!

AppModel을 가보면~?

// AppModel.swift

import Foundation

public class AppModel {

  static let instance = AppModel()

  public init() {}
}

음~ appState Property가 없군요!!

추가 해볼까요?!


여기서 잠깐!!! ✋

테스트 코드 43 Line과 46 Line을 보면 appState는

  1. AppState 타입이다
  2. 초기값은 notStarted

라는 두가지 힌트를 유추 할 수 있습니다.

또, 앞서서 AppModel의 비즈니스 로직에서 상태가 변경된다 고 했으니 let 이 아닌 var로 가지고 있겠네요!

다시 ~ 힌트를 가지고 추가를 해보면~?

// AppModel.swift

import Foundation

public class AppModel {

  static let instance = AppModel()

	var appState: AppState = .notStarted

  public init() {}
}

짠~

아, 물론 init에서 appState를 할당해도 상관없습니다!

이제 코드를 보면~~?

엥..???

아 ㅋㅋㅋㅋ 접근제한자를 안붙이면 기본은 internal인거 모두 알고있죠?!

다시, appState에 public을 붙여서,

public var appState: AppState = .notStarted

코드를 보면?

컴파일 에러가 모두 사라졌네요!!!

드디어 첫 테스트네요~!!

테스트를 실행(Command + U)하거나 코드 라인 쪽에 있는 🔷모양을 눌러볼까요~?

(테스트 하기 전에, 프로젝트 파일에서 빌드 할 수 있는 signing을 맞춰주세요!)

크으~ 테스트가 성공했네요!!

Red-Green 단계를 완수했어요 !! 박수~!! 👏👏👏 

이 단계에서는 Refactor 할만한 내용은.. // given 은 사용하지 않는 주석이니까 40Line 지우는것 정도?!

그러면 이제 Red-Green-Refactor의 3단계를 완수했고

이제 진짜 TDD의 첫 걸음을 뗐어요! 🎉🎉🎉 🥳

어때요? 조금 흥분되지 않아요??? 흥미가 느껴지지 않나요? 🤩🤩🤩

빨리 다음것도 해보고싶죠?? 몇가지만 같이 해볼게요!!

다음 조건은 AppModel은 start 버튼을 누르면 .inProgress로 변경한다 요겁니다!

조건을 만들기 전에 팁을 드리면 !! 지금 저희는 화면이 하나도 없잖아요?

버튼은 만들어져있다고 가정하고, 버튼을 눌렀을 때 호출되는 내용을 코드로 만든다고 생각하면 간단합니다!!

그러면 작성하고 코드를 함께 봐요!

func test_AppModel_whenStartButtonPressed_isInProgressAppState() {
	// given
	let sut = AppModel()

	// when
	sut.start()

	// then
	let observedState = sut.appState
	XCTAssertEqual(observedState, AppState.inProgress)
}

어떤가요? 이번에는 좀 비슷하게 작성했나요???!!

물론 저보다 잘 작성했을 거라고 믿고 넘어가겠..

같이 볼까요?

func test_AppModel_whenStartButtonPressed_isInProgressAppState() {
	// given
	let sut = AppModel()

	// when
	sut.start()
	
	// then
  let observedState = sut.appState
	XCTAssertEqual(observedState, AppState.inProgress)
}

이전과 다르게 이번에는 given이 존재합니다.

왜냐하면 AppModel이 있는 상태에서 start 버튼을 눌러야 appState가 .notStarted 에서 .inProgress로 변경되니까요!

다음은 when!

이게 버튼이 눌렸을 때 동작할 함수죠!

🤔  않이 근데,,, start()는 없잖아요!!!

맞아요 ! 없어요!! 지금 단계는 실패하는 코드를 만드는 겁니다!! 없는 내용을 테스트 코드로 만들고,

그 내용이 테스트가 통과하도록 실제 코드를 짜는게 TDD 입니다~!! 😉

그리고 마지막 then은!?

sut의 변경된 appState가 .inProgress인지 확인해 주면 됩니다!

자, Red 단계를 완성했으니 ~~ 이제 Green 상태로 만들어볼까요?!

import Foundation

public class AppModel {

  static let instance = AppModel()
  
  public var appState: AppState = .notStarted

  public init() {}
  
  public func start() {
    appState = .inProgress
  }
}

어때유? 참 쉽쥬?

마찬가지로 테스트를 실행해보면~

깔끔하게 성공!!

이번엔 Refactor 할 내용이 없으니 Red-Green 단계에서 마무리 할 수 있을 것 같아요!

축하드려요 벌써 두번째 TDD를 해내셨네요! 😮

  1. 구현할 내용을 생각하고, 문장으로 만들어서
  2. 그 내용을 테스트 코드로 만들고
  3. 테스트 코드가 동작 하도록 실제 코드를 적는다!

이렇게 제 나름대로 정리를 해보았답니다. 어때요 참 쉽죠?

Test nomenclature

자, 이제 테스트 코드를 작성하는 법은 조금 감이 오셨을 것이라고 생각하고,

작성한 테스트를 보면서 테스트 명명법에 대해 이야기를 하려고 해요~!

  • func testAppModel_whenStarted_isInProgressState()

먼저, 테스트 함수의 이름은 테스트가 어떤 테스트인지 표현해야 합니다.

테스트 이름은 테스트 로그나 테스트 네비게이터에서 보여지게되고, 이는 나중에 테스트 케이스가 많아지면 알아보기 힘들기 때문입니다.

(테스트1, 테스트2 처럼 작성하는건 지양해야겠죠?)

또한 테스트 함수 이름은 아래 네가지 부분으로 분리 할 수 있습니다.

  1. 모든 테스트는 test 로 시작한다.
  2. AppModel을 sut로 사용한다.
  3. whenStarted는 조건 혹은 테스트의 촉매이다.
  4. isInProgressState() 는 when이 일어난 후의 상태이다.

이제 왜 함수 이름이 저렇게 되었는지 알 수 있겠죠?

또, 함수의 내용도 위 규칙에 따라서 작성했다는거 ! 알고계셨나요?

먼저 let sut = AppModel() 코드는, 2. AppModel을 sut를 사용한다 의 내용을 따랐구요,

다음은 sut.start() 는 3. whenStarted의 start가 실행됐을 때의 내용입니다!

마지막으로 XCTAssertEqual(sut.appState, AppState.inProgress) 는 4. when이 일어난 이후의 상태를 비교했죠!

아무렇게나 쓴것 같지만 ~~ 나름의 규칙 아래에서 작성되었다는거 😉

참, 그리고 then 부분은

// then
XCTAssertEqual(sut.appState, AppState.inProgress)

위 처럼 작성해도 무방합니다.

다만 !!!!!!!!

다른 내용에 의해 sut.appState가 변경되는것을 방지하기 위해 when이 일어난 직후의 sut.appState를 저장하여 비교하기 위함이라는거!

// then
let observedState = sut.appState
XCTAssertEqual(observedState, AppState.inProgress)

알아주셨으면 해요~!! 😆

그리고 위 규칙은 반드시 따라야 하는게 아닌, 작성자의 입맛에 맞추어 규칙을 수정하고, 직접 정한 규칙 내에서 통일되게 코드를 작성하시면 됩니다!

일반적인 네이밍 컨벤션이라고 생각해주시면 됩니다!

다들 자신만의 네이밍 컨벤션이 있고, 그 기준에 맞춰서 코드를 작성하잖아요~ ☺️

일관된 규칙 아래에 작성된 테스트 코드면 저는 오케이라고 생각합니다~!!

(단, 일반적인 규칙에서 너무 벗어난 규칙은 노노 !! 돈두댓!)

Structure of XCTestCase subclass

각각의 test case class는 setUp() 메소드와 tearDown() 메소드를 가지고 있습니다.

각 메소드에 대한 설명은 이전 포스트에도 있습니다만,

위 메소드들이 왜 중요한지에 대한 조금 더 자세한 이야기를 하자면

  • XCTestCase의 subclass lifecycle들은 test실행 밖에서 관리됩니다. 그리고 class-level state는 테스트 메소드들 사이에서 지속됩니다.
  • 어떤 테스트 클래스 혹은 테스트 메소드가 실행될 지 명시되어 있지 않기 때문에 테스트 순서에 대한 신뢰를 할 수 없습니다.

위에서 아래로 순서대로 테스트 코드를 적어도, 테스트는 다른 순서로 진행 될 수 있으며

하나의 테스트는 같은 class에 작성된 다른 테스트 케이스에 영향을 줄 수 있다는 의미입니다.

따라서, 각 테스트를 실행하고, 테스트를 마쳤을 때에는 원래 상태로 되돌려 줄 필요가 있고, 그에 해당하는 메소드가

setUp(), tearDown()입니다.

그러면 저희가 작성했던 테스트에서 setUp()과, tearDown()을 사용해야 할 필요가 있을까요?

정답은 물론입니다. 😆

func test_AppModel_whenInitialized_isNotStartedAppState() {
  // when
  let sut = AppModel()
  let state = sut.appState

  // then
  XCTAssertEqual(state, AppState.notStarted)
}
  
func test_AppModel_whenStartButtonPressed_isInProgressAppState() {
  // given
  let sut = AppModel()

  // when
  sut.start()
  
  // then
  let observedState = sut.appState
  XCTAssertEqual(observedState, AppState.inProgress)
}

위 두가지 테스트를 작성했는데요,

우선, 해당 테스트 파일은 AppModel에 대한 테스트 입니다. 따라서 모든 테스트 코드가 AppModel을 sut로 사용합니다.

그러므로 sut를 함수 밖으로 빼내어 함수 내에서 선언하여 사용하는 중복을 제거 할 수 있습니다.

class FitNessTests: XCTestCase { 

	var sut: AppModel!

	...
	...
}

이때, sut는 초기값이 없으므로, AppModel? 이 되어야 하지만,

테스트 코드를 작성할때는 Optional로 인한 crash를 걱정하지 않아도 됩니다.

(테스트 코드에서 crash가 나도 큰 문제가 없어서 그런가? 라고 추측하고 있습니다. 😧)

그러니까 여기서라도 마음껏 !를 붙여줍시다 하하핳하하하ㅏㅎ

그러면 이제 sut에 AppModel을 할당해줘야 하는데, 이를 setUp()에 넣어서 모든 테스트 코드 실행 전에 실행되도록 하는것 입니다!

class FitNessTests: XCTestCase { 

	var sut: AppModel!

	...
	...

	override func setUp() {
	  super.setUp()
	  sut = AppModel()
	}
}

그러면 이제 테스트 코드의 중복 코드를 제거 할 수 있겠죠?

코드를 지우고, 테스트 까지 실행했습니다~! 완벽하군요 👏👏

자, 그러면 tearDown()에는 뭘 하냐..

두번째 테스트 코드를 보면, sut.start()메소드는 뭘 하는 녀석이였죠?

바로 ! AppModel의 appState를 변경하는 녀석입니다.

그런데 아까 위에서 테스트 코드는 실행 순서가 보장이 안된다 했잖아요..?

그래서 두번째 테스트 코드가 실행되고 첫번째 테스트 코드가 실행된다면..????

start()에 의해, appState는 .inProgress로 변경되어 있고, 첫번째 테스트 코드는 .inProgress와 .notStarted를 비교하니 당연히 실패가 되겠죠!!

(몇번 실행해봤지만 그런 결과는 없었습니다만, 만약이니까요 ^__^)

그러면 이러한 사태를 막기 위해 각 테스트가 끝났을때, AppModel을 초기화 해줘야겠죠?

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

후후.. 좋아요.. 이제 각 테스트가 끝날때마다 sut는 nil이 할당되고,

setUp()에 의해서 다시 할당되겠죠?

근데 왜 super가 마지막에 오나요? 🤔

자세한 내용은 .. 요기에서 확인하실 수 있어요 헤헤..🤗


마무리하며

이번 포스트가 진짜 프로젝트에서 TDD 기반으로 코드를 작성해보았는데요,

어떠신가요 조금 감이 오셨나요? 저는 이렇게 글을 정리하면서 조금 감이 왔다고 생각했는데

막상 실무에서는 Quick, Nimble 등, 여러가지 다른 환경이여서 적용이 조금 힘들더라구요 😵‍💫

그래도 Red-Green-Refactor 와 given, when, then 의 규칙은 벗어나지 않았어요!

적응하는데에도 많은 도움이 되었구요 🥰

포스트가 마무리 될 쯤에는 이전에 작성했던 내용을 Quick을 가지고 변경해볼까 해요 😚

여러분들도 이 글을 통해 TDD 마스터가 되겠어!! 가 아닌 TDD는 이렇게 하는거구나~ 하고 이해만 해도 정말 많은 도움이 될 것이라고 생각해요!!


다음 포스트에서는 같은 프로젝트에서 UI와 관련된 내용을 TDD로 짜는 법을 같이 해볼게요!

기대해주세요. 🤩

코드는 GitHub에 있습니다~!!

질문이나 지적은 언제든 환영입니다!!

오늘도 읽어주셔서 감사합니다 ! 🙇🏻‍♂️

해당 글은 RayWenderRich의 iOS Test-Driven Development by Tutorials 책을 참고하여 작성하였습니다.

profile
hello, iOS
post-custom-banner

0개의 댓글