TDD With iOS - Demo TDD #2

sanghoon Ahn·2022년 3월 26일
0

iOS

목록 보기
15/19
post-thumbnail

이전 포스트에서는 UI가 없는 로직에 대한 간단한 TDD를 진행 해 보았습니다.

이번 포스트는 UI가 있는 로직에 대한 TDD를 진행 해 볼건데요~!!

이전 포스트 내용을 읽고 오시면 내용을 이해하는데 많은 도움이 됩니다. 😊

출발해 볼까요!?

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

iOS Test-Driven Development by Tutorials

👆 글에서 제공하는 프로젝트를 그대로 사용하겠습니다!!


Next set of tests

이제 프로젝트에는 테스트 케이스를 작성하면서 작성된 몇가지 로직이 있습니다만

아직 버튼에 대한 동작은 만들지 못했어요!

그러니까 이번에는 버튼에 대한 동작 코드를 TDD로 만들어볼게요!

TDD는 테스트를 먼저 작성해야 해요!!

그러니까 ViewController에 대한 테스트를 작성해볼까요?

먼저 StepCounterViewController는 main screen에 대한 로직을 가지고 있습니다. 따라서, 해당 ViewController에 대한 테스트를 작성할건데요,

StepCountController 니까, StepCountControllerTests 정도로 테스트파일을 생성하면 될 것 같네요~!!

Test target orgnization

테스트 파일을 만들었는데.. 요놈은 어디에 두어야 할까요? 🤔

이전에 생성한 FitNessTest 역시 FitNess에 대한 모든 테스트를 의미하니까 이름도 고쳐줄 필요가 있겠네요!

그래서 두가지 작업을 할건데요,

  1. FitNessTest를 AppModelTest로 고치기
  2. Test 그룹 구성하기

먼저 기존에 작성했던 테스트 파일(FitNessTest.swift)를 테스트 코드의 목적에 맞게 수정할게요!

(원래 이거는 맨 처음에 했어야 하는건데 말이죠 😭)

Use Rename

살짝 팁을 드리면 클래스 이름과 파일 이름이 같으니까!


클래스 이름을 드래그 > 우클릭 > Refactor > Rename을 하면!

다음과 같이 일치하는 모든 내용을 변경할 수 있어요!!

회색 음영 처리된 내용은 미선택 된 내용인데, 함께 변경할 필요가 있으니 선택해서 함께 변경해주세요~!!

Orgnaization

AppModelTests와 StepCountControllerTests가 있는데, 이걸 어떻게 구성해야 할까요?

우선 이러한 구성을 신경써야 하는 이유는, 나중에 테스트 케이스가 엄청 많아지면 ~

지금처럼 FitNessTests 그룹에 광활하게 펼쳐질 xxxTests.swift 파일을 생각해 보세요!!

그안에서 필요한 테스트 파일을 찾기란..? 한숨부터 나올지도 몰라요 😮‍💨

그러니까, 최대한 잘 구분 할 수 있도록 그룹을 나누어서 구성하는게 중요해요!

포스트에서는 프로젝트에 존재하는 코드와 같은 레벨로 구성하는 방법을 제시했어요.

Test TargetCasesGroup 1Tests 1Tests 2Group 2TestsMocksHelper ClassesHelper Extensions

위 방법은 실제 프로젝트에서 사용되는 코드와 같은 레벨로 구성되어 있기 때문에, 편리하게 구분 할 수 있겠네요!

그리고 여기서 못보던 그룹이 있는데, Mocks, Helper Classes, Helper Extensions 인데요,

설명을 조금 하자면!

  • Mocks : Mock들의 그룹입니다. Mock이란 기능 코드를 위해 존재하는 코드를 의미합니다. 좀 어렵다면 껍데기 코드라고 생각하시면 됩니다!
  • Helper Classes & Helper Extensions : 테스트 코드 작성을 더 편리하게 해주는 기타 코드들의 그룹이라고 생각하시면 됩니다! 일반적인 프로젝트에서도 중복되는 코드나, 코드 작성을 더 편리하게 하기 위해 class나 extension을 작성하잖아요! 비슷합니다~!!

이제 위 방법대로 구성을 할텐데!! 반드시 따라야 하는 내용은 아닙니다!!

여러분의 입맛에 맞게 규칙을 세우고, 규칙에 맞게 구성해주시면 됩니다~

참고해서 나름의 규칙을 세운다면 👍👍 위 규칙 따르기만 해도 👍

포스트의 구성 방법대로, 테스트 그룹을 구성하면~

짜잔 위와 같은 구성이 되었습니다-!

이제 구성은 마무리 되었으니! UI 에 대한 TDD를 해보러 출발할까요!

Using @testable import

먼저 StepCountControllerTests는 말 그대로 StepCountController를 테스트 하기 위한 파일입니다.

그렇다면 여기서 sut는 무엇이 될까요~!!!!! 잘 따라온 여러분들이라면 쉽게 작성했겠죠?!

class StepCountControllerTests: XCTestCase {
	var sut: StepCountController!
	...
	...
}

잘 작성했다면.. 에러가 납니다 ⁉️ (안나는데요?)

아 ㅋㅋㅋ 원래는 에러가 나는게 맞습니다!

왜냐하면 StepCountController는 FitNess의 class이고, StepCountController는 internal로 작성 되어있으니까요!

발생하는 에러를 해결하기 위한 방법은 두가지 있는데,

  1. StepCountController를 public으로 만든다!
  2. @testable import FitNess를 사용하여, FitNess를 import 한다!

의외의 부분에서 똑똑한 Xcode가 자동으로 @testable import FitNess 코드를 삽입합니다! (Xcode 13.2기준)

근데 import문이 기존과는 좀 다르죠? 🤔

일반적으로 import 를 해도, StepCountController는 internal로 선언되어있기 때문에 외부에서는 사용할 수 없습니다.

그래서 StepCountController를 public으로 만든다? 🤯 

UIViewController를 앱 전체에서 접근 가능하게 한다..? 상당히 위험해 보이는군요 😭

본문에서는 SOLID 원칙에 위배된다고 나와있습니다만, 어떤 원칙에 위배되는지 알아내지 못해 적지 못했습니다. 이후 SOLID 원칙에 대해 더 공부해서 내용 추가하겠습니다 !! 🧐

그럼 .. 어떡하죠..?

다행히 Xcode에서는 테스트를 위할 방법이 존재합니다!

@testable 코드는 일반적인 용도 대신, 테스트를 위해 데이터 타입을 노출 할 수 있습니다!

그래서 일반적인 import 대신 @testable import 를 사용하는 것 입니다!

자, 이제 import 까지 잘 마무리 됐으면, sut에 대한 설정을 해야겠죠?!

setUp()과 tearDown()에 알맞은 코드를 작성하시오. (5점)

잘 작성하셨죠? 하핫

func setUp() {
	super.setUp()
	sut = SetCountController()
}

func tearDown() {
	set = nil
	super.tearDown()
}

Testing UI updates

이제 진짜 테스트 코드를 작성해볼게요 ㅋㅋㅋㅋㅋㅋㅋ 길고 길었다....

자, 먼저 조건을 드려야겠죠?

조건은~! controller는 Start가 Tapped 되었을 때, appState를 InProgress로 업데이트한다 입니다.

참 ! 테스트 코드를 작성하기 전에, Start에 대한 Tap Action은 이미 StepCountController에 startStopPause() 메소드로 정의 되어 있습니다. 😉

또한 AppState는 AppModel의 instance로 프로젝트 내에서 모두 접근 가능합니다!

그러면 Red 테스트 코드를 작성해볼까요?

func testController_whenStartTapped_isInProgress() {
	// when
  sut.startStopPause(nil)

	// then
  let state = AppModel.instance.appState
  XCTAssertEqual(state, AppState.inProgress)
}

앞서 이미 많이 해보았으니, 식은죽 먹기라고 생각하겠습니다 ~ 🥳

테스트는 역시 실패하고, 실패하는 이유는 startStopPause에 appState를 변경하는 내용이 없어서 겠죠?

AppModel의 start()메소드는 appState를 .inProgress로 변경시킵니다.

따라서, startStopPause() 메소드 안에 다음 코드를 작성하고 다시 테스트를 해보죠!

@IBAction func startStopPause(_ sender: Any?) {
  AppModel.instance.start()
}

와 UI에 대한 테스트도 성공했어요!! 👏👏👏

하지만, 아직 끝나지 않았습니다!! Start의 역할은 한가지 더 있죠!

Start가 탭 됐을때, Start 의 타이틀을 변경한다 라는 조건이 또 있습니다!

StepCountController에는 AppState에 nextStateButtonLabel enum이 존재합니다.

이것을 이용해서 테스트 코드를 작성해볼게요!

다시 Red 테스트 부터~!!

func testController_whenStartTapped_buttonTitleIsPause() {
	// when
	sut.startStopPause(nil)

	// then
	let text = sut.startButton.title(for: .normal)
	XCTAssertEqual(text, AppState.inProgress.nextStateButtonLabel)
}

자, 역시 실패했겠죠?

startStopPause()에는 startButton의 타이틀을 변경하는 코드가 존재하지 않으니까요!

startStopPause() 함수를 다음과 같이 변경해볼까요?

@IBAction func startStopPause(_ sender: Any?) {
  AppModel.instance.start() // #1
  
  startButton.setTitle(
    AppModel.instance.appState.nextStateButtonLabel,
    for: .normal
  )
}

#1 라인은 이전에 작성했던 로직이고, 그 아래의 setTitle() 부분을 추가했습니다.

변경된 appState의 nextStatebuttonLabel을 가져오는 로직이죠!

테스트를 다시 해보면~!

깔끔하네요~! 👏

자, 이쯤에서 위에서 작성한 두가지 테스트를 한번 볼까요?

func testController_whenStartTapped_isInProgress() {
	// when
  sut.startStopPause(nil)

	// then
  let state = AppModel.instance.appState
  XCTAssertEqual(state, AppState.inProgress)
}

func testController_whenStartTapped_buttonTitleIsPause() {
	// when
	sut.startStopPause(nil)

	// then
	let text = sut.startButton.title(for: .normal)
	XCTAssertEqual(text, AppState.inProgress.nextStateButtonLabel)
}

두가지 테스트는 when도 똑같고, 내용도 비슷하죠? 그렇다면 하나에 작성해도 되지 않을까..? 라고 생각하실 수 있을텐데요 🤔

하나의 테스트에는 하나의 검증(Assert)을 위한 내용이 있어야 어떤 이유 때문에 테스트가 실패했는지 찾아내기 수월해집니다!!

그러므로 조금 비슷한 내용이라도 각각의 테스트 케이스를 만드는게 유지보수에 더 유리합니다. 😉

그리고, XCTAssertEqual의 마지막 매개변수는 하드코딩 된 값이 아닌, 변수를 사용했죠?

이는 기준값이 변경되어도 테스트 코드를 변경할 필요가 없기 때문에 마찬가지로 유지보수에서도 유리합니다! 😮

하드코딩 된 값보다는 변수를 사용하도록 연습해보자구요! 💪

Testing initial condition

위 두가지 테스트는 .notStarted 상태에서 .inProgress 상태가 되는지에 대한 테스트 입니다.

하지만 만약에 이미 .inProgress 상태라면 테스트는 무의미 하겠죠?

그래서 테스트 코드에 명시적인 조건을 나타내줄 필요가 있습니다.

어떻게 하느냐 ~!

버튼의 타이틀이 .notStarted 의 버튼 타이틀 값과 같은지 체크 해보면 되겠죠?

테스트 코드는 아래와 같아요!

// MARK: - Initial State

func testController_whenCreated_buttonLabelIsStart() {
  let text = sut.startButton.title(for: .normal)
  XCTAssertEqual(text, AppState.notStarted.nextStateButtonLabel)
}

// MARK: - 를 추가한 이유는 테스트 코드와 조건부를 나누어 파일내의 내용들을 구분하기 위함입니다!

요 코드를 testController_whenStartTapped_isInProgress() 테스트 위에 위치시켜주세요~

당연히 테스트를 돌려보면~!! 실패하겠고 ❌ 통과하도록 고쳐봐야겠죠?

whenCreated 라는 when은 viewController의 viewDidLoad에 해당 되겠네요.

따라서 viewDidLoad에 startButton에 텍스트를 설정해주는 부분을 넣으면 될것같아요!

// StepCountController.swift

override func viewDidLoad() {
  super.viewDidLoad()
  startButton.setTitle(
    AppState.notStarted.nextStateButtonLabel,
    for: .normal
  )
}

그리고 하나 더 !

테스트를 실행할때에는 실제로 .xib에서 로드하지 않기 때문에 viewDidLoad()가 호출되지 않습니다!

따라서 테스트 코드에 sut.viewDidLoad() 도 추가해주세요~!

깔끔하게 테스트 성공~!!

Refactoring

Green 단계가 끝났으니 리팩토링을 해야되는데...

// StepCountController.swift

override func viewDidLoad() {
  super.viewDidLoad()
  startButton.setTitle(
    AppState.notStarted.nextStateButtonLabel,
    for: .normal
  )
}

@IBAction func startStopPause(_ sender: Any?) {
  AppModel.instance.start()
  
  startButton.setTitle(
    AppModel.instance.appState.nextStateButtonLabel,
    for: .normal
  )
}

startButton.setTitle() 함수가 중복으로 사용되고있는데..!

viewDidLoad에서는 AppState가 .notStarted죠?

private func updateButton() {
  startButton.setTitle(
		AppModel.instance.appState.nextStateButtonLabel,
		for: .normal
	)
}

하지만 요렇게 분리된 updateButton() 함수를 적용해도 문제가 없습니다 !

왜냐면~! 어차피 메인 화면의 viewDidLoad() 시점에서는 AppModel이 당연히 .notStarted 일 테니까요!

깔끔하게 적용하고 다시 테스트를 해봅시다!

모든 테스트가 성공했군요~!!

추가로, AppModel의 access modifier가 public인데, AppModel은 직접 접근해서 사용하는것이 아니라

singleton으로 사용되기 때문에, class 자체가 public일 이유는 없습니다.

따라서, internal로 수정해도 되겠죠? 그러면 에러가 발생합니다 !! 🤯

하지만 걱정하지마세요~ 이 글을 쭉 따라왔던 여러분이라면 충분히 해결 할 수 있는 에러!!

Using @testable import 섹션을 참고해보아요 😉


마무리하며

이전 포스트와 이번 포스트를 통한 키포인트는 다음과 같습니다.

  • TDD란 앱 로직을 작성하기 전에 test를 먼저 작성하는 것 이다.
  • 각각의 테스트는 첫 실행에선 실패해야한다. 컴파일 단계에서의 실패는 실패가 아니다.
  • 테스트들을 성능과 가독성을 위해 리팩토링 지침으로 사용해라.
  • 좋은 네이밍 컨벤션은 이슈를 더 쉽게 찾아낼수 있게 한다.

과연.. 여러분도 각각의 항목에 대해 이해가 가시나요 ? ☺️

잘 모르더라도 괜찮습니다!! 언젠가는 몸으로 테스트환경을 겪어보고

저 의미들을 깨닫게 된다면 좋겠습니다. 🙏

다음 포스트부터는 테스트에서 검증할 때 사용하는 XCAssert의 여러 표현(Expression)을 함께 보겠습니다!

계속 함께해요~! 🏃

코드는 GitHub에 남겨두었습니다~!

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

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

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

profile
hello, iOS

0개의 댓글