Flyleaf - 독서를 여행처럼/개발일지

[개발일지] 06. 단위 테스팅을 해봅시다! (feat. AI)

여성일 2026. 3. 20. 04:42
728x90

단위 테스팅을 하는 이유

Flyleaf를 개발하면서 단위 테스팅을 적극적으로 하고 있습니다. 

 

단위 테스트는 작은 단위의 로직이 의도한 대로 정확하게 동작하는지 검증하는 과정입니다.

 

테스트 코드를 작성하는 과정에서 코드 구조를 다시 고민하게 되거나, 의존성을 분리하는 방향으로 설계를 개선하게 되는 등 단위 테스팅을 하는 이유는 여러가지가 있습니다..만!

 

개인적으로 단위테스팅을 하면서 가장 와닿았던 부분은, 계산 로직이나 비즈니스 로직을 매번 실기기로 확인하는 번거로움을 줄여주는 것이 가장 와닿았습니다.

 

예를 들어 HomeViewModel에서는 독서 진행률을 계산하는 로직을 테스트했습니다. 이런 로직은 계산 자체는 단순하지만, 실제로는 값이nil인 경우, 전체 페이지 수가 0인 경우, 현재 페이지가 전체 페이지 수를 초과하는 경우처럼 같이 확인해야 할 케이스가 생각보다 많았습니다. 

 

이러한 케이스들을 실기기에서 하나씩 확인하려면, 매번 다른 값을 가진 데이터를 준비하고 화면에 진입해서 결과를 직접 확인해야 합니다. 한 두번 정도는 할 수 있지만, 로직을 수정할 때마다 이런 경우를 다시 전부 확인하는 건 꽤 번거로운 일이라고 생각합니다.

 

그래서 이런 부분은 단위 테스트로 옮겨서, 특정 입력값을 넣었을 때 어떤 결과가 나와야 하는지를 코드로 검증하도록 했습니다.

 


단위 테스팅 메소드 (feat. XCTest)

단위 테스팅을 작성하면서 XCTest에서 제공하는 여러 메소드들을 사용하게 되었습니다. 자주 사용하는 것들이 여러 개 있으니 한번 이야기 해보려고 합니다.

 

XCTAssertEqual

- 두 값이 서로 같은지 비교할 때 사용하는 메소드

- 계산 결과나 상태 값처럼 정확한 값이 나와야 하는 경우 가장 기본적으로 사용

 

가장 기본적으로 많이 사용한 것은 XCTAssertEqual 입니다. 예를 들어 진행률 계산처럼 입력값에 대해 정확한 결과가 나와야 하는 경우 사용했습니다.

XCTAssertEqual(progress, 200.0 / 584.0, accuracy: 0.001)

부동소수점 계산에서는 미세한 오차가 발생할 수 있기 때문에 accuracy를 같이 사용하였습니다.


XCTAssertNil

- 값이 nil인지 확인할 때 사용하는 메소드

- 특정 상황에서 값이 존재하면 안 되는 경우를 검증할 때 사용

 

값이 존재하지 않아야 하는 경우에는 XCTAssertNil을 사용했습니다. 예를 들어 업로드 실패 시 성공 콜백이 호출되지 않았는지를 확인할 때 사용했습니다.


XCTAssertNotNil

- 값이 nil이 아닌지 확인하는 메소드

- 값이 정상적으로 생성되었거나 할당되었는지를 검증할 때 사용

 

반대로 값이 반드시 존재해야 하는 경우에는 XCTAssertNotNil을 사용했습니다. 예를 들어 시작일을 선택하는 로직에서는, 날짜를 입력했을 때 실제로 값이 잘 저장되는지를 확인할 때 사용했습니다. 특히, 옵셔널 값을 다루는 경우에는, nil이 아닌지 자체를 확인하는 것이 더 중요한 경우가 많아서 자주 사용했습니다.


XCTAssertTrue, XCTAssertFalse

- Bool 값이 각각 true 또는 false인지 확인하는 메소드

- 조건의 결과나 상태 여부를 간단하게 검증할 때 사용

 

단순한 Bool 검증에서는 XCTAssertTure, XCTAssertFalse를 사용했습니다. 예를 들어 특정 조건에서 버튼 활성화 여부나 선택 가능 여부를 검증할 때 사용했습니다.


비동기 처리 테스트

비동기 로직을 테스트할 때는 expectation을 활용했습니다.  도서 검색 테스트 코드를 보면서 이야기 해보겠습니다. 

let exp = expectation(description: "onBooksChanged called")

var received: [BookSearchItem] = []

sut.onBooksChanged = { books in
  guard !books.isEmpty else { return }
  received = books
  exp.fulfill()
}

await sut.search(query: "소설")

await fulfillment(of: [exp], timeout: 1.0)

expectation

일반적인 동기 코드와 다르게 비동기 코드는 실행 시점과 결과가 언제 반환될지 알 수 없기 때문에, 기다렸다가 검증하는 구조가 필요합니다. 이때 사용하는 것이 expectation입니다. 

 

expectation은 작업이 완료되기를 기다리겠다는 약속을 만드는 객체입니다. 위 코드에서는 onBooksChanged 콜백이 호출되는 것을 기다리는 상황입니다.


fulfill()

expectation이 만족되었음을 알리는 메소드입니다. 즉, 기다리던 일이 실제로 발생했다는 신호입니다. 위 코드에서는 onBooksChanged가 호출 됐을 때, exp.fulfill()을 호출하고 있습니다. 


fulfillment(of:timeout:)

지정한 expectation이 fulfill될 때까지 기다리는 역할을 합니다. 만약 일정 시간(timeout)안에 fulfill되지 않으면 테스트는 실패합니다.


흐름으로 이해해봅시다.

let exp = expectation(description: "onBooksChanged called")

먼저 expectation으로 onBooksChanged가 호출되기를 기다린다는 기준을 하나 만듭니다.

 

sut.onBooksChanged = { books in
  guard !books.isEmpty else { return }
  received = books
  exp.fulfill()
}

그 다음 sut.onBooksChanged 클로저 안에서, 실제로 원하는 이벤트가 발생했을 때 exp.fulfill()을 호출합니다.

 

await sut.search(query: "소설")

그리고 search 메소드를 실행하면, 내부적으로 비동기 검색이 수행됩니다. 검색이 끝나고 onBooksChanged 콜백이 호출되면, 그 시점에 exp.fulfill()이 실행됩니다.

 

정리하면

1. expectation으로 기다릴 이벤트를 등록한다.

2. 비동기 작업이 끝났을 때 fulfill()로 완료 신호를 보낸다.

3. fulfillment()로 그 완료 신호가 올 때까지 기다린다.


setUP과 tearDown

setUp()

셋업은 각 테스트가 실행되기 전에 호출되는 메소드입니다. 테스트에 필요한 객체를 생성하거나, 초기 상태를 준비하는 역할을 합니다. 예를 들어 HomeViewModel 테스트에서 매 테스트마다 동일한 환경을 만들기 위해 아래처럼 사용했습니다.

override func setUp() {
  super.setUp()
  mockReadingJourneyService = MockReadingJourneyService()
  sut = HomeViewModel(readingJourneyService: mockReadingJourneyService)
}

이렇게 하면 각 테스트마다 새로운 뷰모델과 Mock 객체가 생성되기 때문에, 이전 테스트의 영향 없이 독립적으로 테스트를 실행할 수 있습니다.


tearDown()

반대로 tearDown은 각 테스트가 끝나고 호출되는 메소드입니다. 테스트에서 사용한 객체를 정리하거나 초기화하는 데 사용합니다.

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

이렇게 객체를 nil로 정리해두면, 테스트 간 상태가 섞이는 것을 방지하고 메모리도 깔꼼하게 관리할 수 있습니다. 

 

즉, setUp()과 tearDown()은 각 테스트를 서로 완전히 독립된 환경에서 실행하기 위한 일종의 장치라고 생각하면 좋을 것 같습니다!


Given - When - Then

테스트 코드를 작성할 때 가장 보편적으로 사용되는 방식 중 하나인 Given - When - Then을 사용하여 테스트를 작성했습니다. 이 패턴은 테스트를 세 단계로 나누어 작성하는 방식으로, 각각 다음과 같은 의미를 가집니다.

 

- Given: 테스트를 위한 상태와 조건을 준비 (어떤 상태가 주어졌을 때)

- When: 테스트 대상 동작을 실행 (어떤 행동을 하면)

- Then: 실행 결과를 검증 (어떤 결과가 나와야 한다)

 

/*
독서 진행률이 정상적으로 계산되는지 검증하는 테스트
- Given: currentPage와 전체 페이지 수(itemPage)가 있는 ReadingJourney
- When: calculateProgress(journey:) 호출
- Then: currentPage / itemPage 값으로 진행률이 정상 계산되는지 확인합니다.
*/

func test_calculateProgress_normal() {
  // Given
  let journey = makeReadingJourney(
    currentPage: 200,
    itemPage: 584
  )

  // When
  let progress = sut.calculateProgress(journey: journey)

  // Then
  XCTAssertEqual(progress, 200.0 / 584.0, accuracy: 0.001)
}

예를 들어 독서 진행률을 계산하는 테스트 코드를 보면 Given - When - Then 구조가 어떤 구조인지 확인할 수 있습니다. (아마도요?!)

 

먼저 Given 단계에서는 테스트를 위한 데이터를 준비합니다. 이 테스트에서는 currentPage가 200이고 전체 페이지 수가 584인 ReadingJourney를 생성하여, 진행률을 계산할 수 있는 상태를 만들어줍니다.

 

다음으로 When 단계에서는 실제로 테스트하고 싶은 동작을 실행합니다. 여기서는 calculateProgress(journey:) 메소드를 호출하여 진행률을 계산합니다.

 

마지막으로 Then 단계에서는 결과를 검증합니다. 계산된 진행률이 200.0 / 584.0과 동일한지 비교하면서, 해당 로직이 정상적으로 동작하는지를 확인합니다. 

 

이처럼 Give - When - Then 구조로 나누어 작성하면, 테스트 코드의 구조와 목적을 쉽게 파악할 수 있습니다.


테스트 케이스 (feat. AI)

테스트 코드를 작성하는 것은 크게 어렵지 않습니다. 오히려 더 어려웠던 부분은, 어떤 테스트 케이스를 설정해야하는지였습니다. 

 

처음에는 비교적 단순하게 접근했습니다.

예를 들어 calculateProgress(joureny:)와 같은 로직에서는, currentPage와 itemPage가 주어졌을 때, 진행률이 정확히 계산되는지와 같은 정상 케이스를 중심으로 테스트를 작성했습니다.

 

이런 케이스들은 비교적 직관적으로 떠올릴 수 있고, 실제로도 빠르게 테스트를 작성할 수 있었습니다.

 

문제는 정상 케이스가 아니라, 예외 상황과 엣지 케이스였습니다.

 

예를 들어 동일한 진행률 계산 로직에서도 아래와 같은 경우의 수가 존재했습니다

- currentPage가 nil인 경우

- 전체 페이지 수(itemPage)가 0인 경우

- 현재 페이지가 전체 페이지 수를 초과하는 경우

- 음수 값이 들어오는 경우

 

이런 케이스들은 하나씩 직접 떠올리려면 생각보다 놓치는 부분이 많았고, 테스트가 충분히 커버되지 않는다는 느낌을 받았습니다. 그래서 저는 테스트 케이스를 설계하는 과정에서 AI를 적극 활용했습니다.

- 이 로직에서 놓치기 쉬운 케이스는 뭐가 있을까?

- 경계값 테스트는 어떤 게 필요할까?

- 이 메소드에서 발생할 수 있는 예외 상황은?

과 같은 프롬프트를 던지면서 테스트 케이스를 확장해 나가는 방식으로 테스트를 진행했습니다.


Mock

Mock 객체

단위 테스트를 작성하면서 중요하게 느낀 것이 Mock 객체입니다.

 

ViewModel을 테스트하다 보면, 네트워크 요청이나 데이터 저장소처럼 외부 의존성이 포함되는 경우가 많습니다. 이런 의존성을 그대로 사용하면 테스트가 느려지거나, 테스트 결과가 외부 상태에 영향을 받게 되는 문제가 발생합니다.

 

그래서 실제 서비스 대신, 원하는 값을 직접 제어할 수 있는 Mock 객체를 만들어 테스트에 사용했습니다.

 

예를 들어 HomeViewModel에서는 ReadingJourneyService를 직접 사용하지 않고, 테스트용 MockReadingJourneyService를 주입해서 테스트를 진행했습니다. 

mockReadingJourneyService.stubbedFetchReadingJourneysResult = [
  makeReadingJourney(id: "journey-1"),
  makeReadingJourney(id: "journey-2")
]

await sut.loadReadingJourneys()

XCTAssertEqual(sut.tripCount, 2)

이렇게 하면 실제 API 호출 없이도, 여행이 2개 있을 때 tripCount가 2가 되는지를 정확히 검증할 수 있습니다. 

 

또한 Mock을 사용하면 단순히 결과 값뿐만 아니라, 어떤 메소드가 호출되었는지까지 검증할 수 있습니다. 

XCTAssertEqual(mockReadingJourneyService.createJourneyCallCount, 1)

이처럼 Mock 객체에 호출 횟수나 전달된 값을 기록해두면, 로직이 실제로 기대한 방식으로 동작했는지도 함께 검증할 수 있습니다. 


의존성 주입

단위 테스트를 하면서 가장 크게 체감한 부분 중 하나는, DI 구조가 테스트를 쉽게 만들어준다는 것이었습니다. 

 

예를 들어 HomeViewModel에서는 데이터를 가져오기 위해 구현체에 직접 의존하는 것이 아니라, 프로토콜을 통해 의존성을 주입받도록 설계되어 있습니다.

public init(
  readingJourneyService: ReadingJourneyServicing
) {
  self.readingJourneyService = readingJourneyService
}

이 구조 덕분에 테스트에서는 실제 서비스 대신 Mock 객체를 자유롭게 사용할 수 있었습니다. 

 

sut = HomeViewModel(readingJourneyService: mockReadingJourneyService)

즉, ViewModel은 어떤 구현체인지는 신경 쓰지 않고 단순히 프로토콜을 따르는 객체만 받으면 되기 때문에 테스트 상황에 맞는 객체를 외부에서 주입할 수 있게 됩니다. 

 

이렇게 의존성 주입이 가능해지면 테스트 작성이 훨씬 유연해집니다. 원하는 데이터를 직접 설정한 뒤, 그 결과가 ViewModel에 어떻게 반영되는지를 정확하게 검증할 수 있습니다. 

 

반대로 의존성 주입을 하지 않고, ViewModel 내부에서 직접 서비스 객체를 생성하는 구조라면 어떻게 될까요?

 

예를 들어 아래와 같은 코드가 있다고 가정해보겠습니다.

final class HomeViewModel {
  private let readingJourneyService = ReadingJourneyService()

  func loadReadingJourneys() async {
    let journeys = try? await readingJourneyService.fetchReadingJourneys()
    // ...
  }
}

이 경우 HomeViewModel은 ReadingJourneyService라는 구현체에 강하게 결합되어 있습니다.

 

이 구조에서는 테스트를 작성할 때 문제가 발생합니다.

let sut = HomeViewModel()

await sut.loadReadingJourneys()

원하는 테스트 상황을 만들 수 없는 문제가 발생합니다.

- 특정 데이터를 반환하도록 설정할 수 없음

- 에러 상황을 강제로 만들기 어려움

- 네트워크 요청이 실제로 발생할 수 있음

 

즉, 테스트가 외부 환경(네트워크, 서버 상태)에 의존하게 되어 느려지고, 불안정해지고, 여러 상황을 테스트할 수 없게 됩니다.

 

결국 의존성을 주입하지 않은 구조에서는 테스트가 제어 불가능한 상태가 되고, 의존성을 주입한 구조에서는 테스트를 통제 가능한 상태로 만들 수 있습니다. 


마무리

Flyleaf 개발하면서 단위 테스트를 꾸준히 작성해보니, 그동안 추상적이었던 개념들을 조금은 실제로 체감할 수 있었습니다.

 

특히 "의존성을 주입하면 테스팅이 쉬워진다." 라는 말을 많이 들어왔지만, 직접 적용해보니 왜 그런지 명확하게 이해할 수 있었습니다. 프로토콜 기반으로 의존성을 분리하고 Mock 객체를 주입하는 구조를 사용하면서, 테스트를 훨씬 유연하고 통제 가능한 상태에서 작성할 수 있다는 것을 경험하게 되었습니다.

 

또, 테스트 케이스를 고민하는 과정에서, 단순한 정상  케이스뿐만 아니라 다양한 예외 상황과 엣지 케이스까지 자연스럽게 고려하게 되었고, 이 과정에서 AI를 적극 활용해 테스트 범위를 확장해본 경험도 재밌는 경험이었습니다.

 

단위 테스팅.. 어렵지만? 여러분들도 도전해보십쇼!