← TCA 14일 마스터 7 / 14 · 2부

Day 7 · 2부 · 의존성 — 설계·오버라이드·수명

테스트하기 쉬운 의존성 모양을 고르고, 어디서 바꾸며, 바꾼 값이 자식 리듀서와 이펙트까지 어떻게 따라가는지 본다.
진행률
0 / 0 완료
1부에서 @Dependency로 꺼내 쓰고, DependencyKey로 등록하고, 실행 컨텍스트마다 다른 값이 쓰이는 규칙을 봤다. 2부는 세 가지를 묻는다. 의존성을 어떤 모양으로 설계해야 테스트에서 다루기 쉬운가? 앱·Preview·테스트에서 어디서 바꾸는가? 바꾼 값은 얼마나 오래, 어디까지 따라가는가? 답의 뿌리는 하나다. TCA는 단일 진입점이라, 한 번 바꾼 의존성이 자식 리듀서와 이펙트까지 자동으로 흐른다.

2부 목표 · 24분

  • 같은 오디오 플레이어를 protocol과 struct of closures로 각각 설계하고, 테스트에서 엔드포인트 하나만 덮어쓸 때의 차이를 설명한다.
  • testValue를 "미구현"(unimplemented, @DependencyClient)으로 만들어 테스트가 쓰지 않기로 한 엔드포인트를 부르면 실패하게 한다.
  • 앱 루트(Store), 리듀서 서브트리(.dependency), Preview, 테스트(TestStore) 중 알맞은 곳에서 의존성을 바꾼다.
  • 이펙트 안의 DispatchQueue.asyncAfter, 직접 만든 자식 @Observable 모델처럼 오버라이드가 끊기는 자리를 찾아 고친다.

1. 설계 — struct of closures vs protocol 🔥 핵심

SwiftUI 코스 Day 13 2부는 프로토콜로 의존성을 끊었다. TripSearching 프로토콜을 두고 LiveTripSearch·MockTripSearch·FailingSearch 타입을 만들었다. swift-dependencies도 이 방식을 그대로 받는다. 문서는 "이 스타일이 편하면 바꿀 필요 없다"고 적는다(swift-dependencies · Designing dependencies).

프로토콜 방식 — 구현마다 타입을 하나씩 만든다
protocol AudioPlayer: Sendable {
  func play(url: URL) async throws
  func stop() async
}

struct LiveAudioPlayer: AudioPlayer {
  func play(url: URL) async throws { try await Task.sleep(for: .seconds(1)) }  // 실제로는 AVFoundation
  func stop() async {}
}

struct UnimplementedAudioPlayer: AudioPlayer {         // 테스트용: 부르면 실패
  func play(url: URL) async throws { reportIssue("AudioPlayer.play is unimplemented") }
  func stop() async { reportIssue("AudioPlayer.stop is unimplemented") }
}

private enum AudioPlayerKey: DependencyKey {
  static let liveValue: any AudioPlayer = LiveAudioPlayer()
  static let testValue: any AudioPlayer = UnimplementedAudioPlayer()
}

extension DependencyValues {
  var audioPlayer: any AudioPlayer {
    get { self[AudioPlayerKey.self] }
    set { self[AudioPlayerKey.self] = newValue }
  }
}

문제는 테스트에서 드러난다. "재생 버튼을 누르면 play가 이 URL로 한 번 불린다"를 확인하려면, play를 기록하는 새 타입을 또 만들어야 한다. stop도 함께 구현해야 한다. Point-Free 예제들이 쓰는 방식은 인터페이스를 클로저를 담은 struct로 만드는 것이다.

✅ struct of closures + @DependencyClient — 구현은 타입이 아니라 값이다
@DependencyClient
struct AudioPlayerClient: Sendable {
  var play: @Sendable (_ url: URL) async throws -> Void
  var stop: @Sendable () async -> Void
}

extension AudioPlayerClient: DependencyKey {
  static let liveValue = AudioPlayerClient(
    play: { _ in try await Task.sleep(for: .seconds(1)) },   // 실제로는 AVFoundation
    stop: {}
  )
  static let testValue = AudioPlayerClient()     // 모든 엔드포인트가 미구현 (1부)
}

extension DependencyValues {
  var audioPlayerClient: AudioPlayerClient {
    get { self[AudioPlayerClient.self] }
    set { self[AudioPlayerClient.self] = newValue }
  }
}

@Reducer
struct Player {
  @ObservableState
  struct State: Equatable { var isPlaying = false }
  enum Action { case playButtonTapped, playbackFinished }

  @Dependency(\.audioPlayerClient) var audioPlayer
  let url = URL(string: "https://example.com/intro.m4a")!

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .playButtonTapped:
        state.isPlaying = true
        return .run { [url] send in
          try? await audioPlayer.play(url: url)
          await send(.playbackFinished)
        }
      case .playbackFinished:
        state.isPlaying = false
        return .none
      }
    }
  }
}
✅ 테스트 — play 하나만 덮어쓴다. stop이 불리면 그 자체로 실패
import Testing

@MainActor
@Test func 재생버튼은_play를_한번_부른다() async {
  let played = LockIsolated<[URL]>([])            // 이펙트에서 안전하게 기록
  let store = TestStore(initialState: Player.State()) {
    Player()
  } withDependencies: {
    $0.audioPlayerClient.play = { url in played.withValue { $0.append(url) } }
  }

  await store.send(.playButtonTapped) { $0.isPlaying = true }
  await store.receive(\.playbackFinished) { $0.isPlaying = false }
  #expect(played.value == [URL(string: "https://example.com/intro.m4a")!])
}

이 테스트가 통과하면 두 가지가 증명된다. play가 이 URL로 한 번 불렸고, 다른 엔드포인트는 하나도 불리지 않았다. 나머지는 여전히 미구현이라, 나중에 누군가 이 흐름에 stop 호출을 넣으면 테스트가 바로 실패해 "단언할 동작이 늘었다"고 알려 준다. 문서도 같은 설명을 한다.

protocol AudioPlayer Live Mock Unimplemented 구현마다 타입 하나 (conformance) 메서드 하나만 바꾸려 해도 새 타입 struct AudioPlayerClient liveValue previewValue testValue $0.audioPlayerClient.play = { … } 구현은 값 · 엔드포인트 하나만 덮어쓴다
protocol은 "타입"을 갈아 끼우고, struct of closures는 "필드 하나"를 갈아 끼운다. 테스트가 좁고 정확해진다.
비교protocol (SwiftUI Day 13 방식)struct of closures
구현 추가타입을 새로 만든다값을 하나 더 만든다
테스트에서 한 메서드만 바꾸기그 메서드를 가진 Mock 타입을 새로 만든다$0.client.play = { … } 한 줄
필요한 부분만 의존프로토콜 전체를 받는다@Dependency(\.audioPlayerClient.play)처럼 엔드포인트 하나만
미구현 testValueUnimplemented 타입을 손으로 쓴다@DependencyClient면 Self()
잃는 것—인자 라벨(→ @DependencyClient가 메서드로 되살림), 익숙함

미구현 testValue — "쓰기로 한 것만" 덮어쓰게 만든다

📝 swift-dependencies 문서 원문

"we highly encourage users of our library to provide what is known as "unimplemented" versions of their dependencies for their testValue. These are implementations that cause a test failure if any of its endpoints are invoked." — swift-dependencies · Live, preview, and test dependencies

풀이: testValue는 "아무것도 안 하는 가짜"보다 "불리면 테스트를 실패시키는 가짜"가 낫다. 테스트가 덮어쓰지 않은 엔드포인트가 불리는 순간, 단언하지 않은 동작이 있다는 뜻이기 때문이다.

매크로 없이도 만들 수 있다. Issue Reporting 라이브러리(TCA를 통해 이미 들어와 있다)의 unimplemented는 거의 모든 시그니처의 "부르면 실패하는 함수"를 돌려준다. 던지지 않고 값을 돌려주는 엔드포인트에는 placeholder:로 돌려줄 값을 준다.

✅ 손으로 만드는 미구현 — 분석 이벤트를 몰래 보내면 테스트가 잡는다
struct AnalyticsClient: Sendable {
  var track: @Sendable (_ event: String, _ properties: [String: String]) async throws -> Void
  var isEnabled: @Sendable () -> Bool
}

extension AnalyticsClient: TestDependencyKey {
  static let testValue = AnalyticsClient(
    track: unimplemented("AnalyticsClient.track"),
    isEnabled: unimplemented("AnalyticsClient.isEnabled", placeholder: false)
  )
}

문서가 드는 이유가 설득력 있다. 새 분석 이벤트를 추가하고 테스트를 안 쓰면 바로 실패가 난다. 테스트가 실제 분석 서버에 가짜 이벤트를 보내 데이터를 더럽히는 일도 막는다.

2. 오버라이드 — 어디서 바꾸나 🔥 핵심

바꾸는 자리는 네 곳이다. SwiftUI에서 .environment(_:_:)를 어디에 붙이느냐와 같은 질문이다.

어디서SwiftUI (@Environment)TCA (@Dependency)
앱 전체루트 뷰에 .environment(\.x, live)Store(initialState:reducer:withDependencies:) — 보통은 liveValue 그대로
화면 일부그 서브뷰에 .environment(…)그 자식 리듀서에 .dependency(\.x, value)
PreviewPreview마다 .environment(…)previewValue 자동 + 필요할 때만 Store(… withDependencies:)
테스트모델 생성자에 가짜를 넘긴다TestStore(… withDependencies:), 도중에 store.dependencies.x = …

리듀서 서브트리만 바꾸기 — .dependency

TCA 문서의 예는 온보딩이다. 사용법을 가르치는 동안 사용자의 조작이 진짜 디스크나 UserDefaults에 기록되면 안 된다. 앱 전체 설정은 그대로 두고 온보딩 안의 기능만 가짜로 돌린다(TCA 문서 · Dependencies — Overriding dependencies).

✅ 온보딩 안의 Settings만 메모리 저장소로 돌린다
@DependencyClient
struct SettingsClient: Sendable {
  var save: @Sendable (_ key: String, _ value: Bool) async throws -> Void
}
extension SettingsClient: DependencyKey {
  static let liveValue = SettingsClient(save: { key, value in
    UserDefaults.standard.set(value, forKey: key)
  })
  static let inMemory = SettingsClient(save: { _, _ in })   // 아무 데도 쓰지 않는다
}
extension DependencyValues {
  var settingsClient: SettingsClient {
    get { self[SettingsClient.self] }
    set { self[SettingsClient.self] = newValue }
  }
}

@Reducer
struct Settings {
  @ObservableState
  struct State: Equatable { var notificationsOn = false }
  enum Action { case notificationsToggled }
  @Dependency(\.settingsClient) var settingsClient

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      state.notificationsOn.toggle()
      return .run { [on = state.notificationsOn] _ in
        try await settingsClient.save(key: "notifications", value: on)
      } catch: { _, _ in }
    }
  }
}

@Reducer
struct Onboarding {
  @ObservableState
  struct State: Equatable { var settings = Settings.State() }
  enum Action { case settings(Settings.Action) }

  var body: some ReducerOf<Self> {
    Scope(\.settings, action: \.settings) {
      Settings()
        .dependency(\.settingsClient, .inMemory)   // 이 서브트리와 그 이펙트에만 적용
    }
  }
}
📝 TCA 문서 원문

"This will cause the Feature reducer to use a mock user defaults and database dependency, as well as any reducer Feature uses under the hood, and any effects produced by Feature." — TCA 문서 · Dependencies

풀이: .dependency는 그 리듀서, 그 안에서 쓰는 모든 하위 리듀서, 그리고 그들이 만든 이펙트까지 바꾼다.

App (Store 루트) Onboarding → Settings() .dependency(\.settingsClient, .inMemory) Settings 리듀서 그 이펙트 .run Main → Settings() 수정자 없음 Settings 리듀서 · 이펙트 inMemory가 적용되는 범위 앱 기본값(liveValue) 그대로
.dependency는 SwiftUI의 .environment처럼 "그 가지"에만 걸린다. 형제 가지는 영향을 받지 않는다.
⚠️ transformDependency는 액션마다 불린다

현재 값을 받아 조금 고치는 .transformDependency(\.x) { $0.endpoint = … }도 있다. 소스의 경고대로 이 클로저는 리듀서에 액션이 올 때마다 실행된다. 그 안에서 의존성을 새로 만들면 액션마다 새로 만들어진다(TCA 소스 · DependencyKeyWritingReducer.swift). 로그를 끼워 넣는 정도로만 쓴다.

Preview에서 바꾸기

대부분의 Preview는 아무것도 안 해도 된다. 1부의 규칙대로 previewValue가 자동으로 쓰인다. 에러 화면처럼 특정 상황을 보고 싶을 때만 스토어를 만들면서 덮어쓴다. 스토어 생성자의 withDependencies:는 iOS 17에서도 쓸 수 있다.

✅ Preview — 재생 실패 상황만 덮어쓴다
struct PlayerView: View {
  let store: StoreOf<Player>
  var body: some View {
    Button(store.isPlaying ? "재생 중" : "재생") { store.send(.playButtonTapped) }
  }
}

#Preview("재생 실패") {
  PlayerView(
    store: Store(initialState: Player.State()) {
      Player()
    } withDependencies: {
      $0.audioPlayerClient.play = { _ in
        struct Boom: Error {}
        throw Boom()
      }
    }
  )
}
🆕 Preview 트레이트 iOS 18+

스토어를 만들지 않는 화면(예: @Observable 모델)이라면 #Preview(traits: .dependencies { $0.audioPlayerClient.play = … }) 트레이트를 쓸 수 있다. 문서에 따르면 이 트레이트는 iOS 18 이상이 필요하다. 배포 타깃이 iOS 17이면 Preview 본문에서 let _ = prepareDependencies { … }를 먼저 부른다(swift-dependencies · Live, preview, and test dependencies).

3. 수명 — task local과 단일 진입점 🔥 핵심

바꾼 값은 언제까지 살아 있나? 답은 swift-dependencies의 뼈대에 있다. 의존성은 @TaskLocal에 담긴다. task local은 정해진 범위(withValue의 클로저) 안에서만 바뀌고, 그 안에서 만든 Task·async let·TaskGroup에는 상속된다. 하지만 DispatchQueue.asyncAfter 같은 일반 탈출 클로저에서는 원래 값으로 돌아간다(swift-dependencies · Dependency lifetimes).

withValue(42) · withDependencies 범위 Task { … } 생성 DispatchQueue.asyncAfter 등록 나중에 읽으면 42 — 상속 나중에 읽으면 1 — 잃음 시간 → 범위가 끝난다
Task는 task local을 복사해 가져가고, 일반 탈출 클로저는 그러지 않는다. 의존성도 똑같다.

TCA 이펙트는 만들 때의 의존성을 들고 간다

TCA의 .run은 만들어지는 순간 withEscapedDependencies로 현재 의존성을 붙잡고, 실제 작업을 그 값 안에서 돌린다(소스 기준: Effect.swift의 run). 그래서 .dependency로 바꾼 리듀서가 이미 값을 돌려준 뒤에도, 그 리듀서가 만든 이펙트는 바뀐 값을 계속 쓴다. 끊기는 곳은 이펙트 안에서 다시 task local을 상속하지 않는 탈출 클로저를 만들 때다.

❌ 이펙트 안에서 asyncAfter — 클로저 안의 의존성은 앱 기본값으로 돌아간다
return .run { send in
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    @Dependency(\.settingsClient) var settingsClient   // .inMemory가 아니라 liveValue
    Task { try await settingsClient.save(key: "seen", value: true) }
  }
}
✅ 가능하면 시계 의존성으로, 콜백 API가 꼭 필요하면 withEscapedDependencies
@Reducer
struct Reminder {
  struct State: Equatable {}
  enum Action { case appeared, delayedCallbackFired }
  @Dependency(\.continuousClock) var clock

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .appeared:
        return .run { send in
          try await clock.sleep(for: .seconds(2))           // ① 구조적 동시성 — 그대로 상속된다
          await send(.delayedCallbackFired)
        }
      case .delayedCallbackFired:
        return .run { _ in
          withEscapedDependencies { dependencies in          // ② 콜백 API를 써야 할 때
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
              dependencies.yield {
                // 이 안에서는 withEscapedDependencies를 부른 시점의 의존성이 쓰인다
              }
            }
          }
        }
      }
    }
  }
}

단일 진입점 — TCA가 전파를 공짜로 해 주는 이유

swift-dependencies 문서는 TCA를 SwiftUI 뷰와 나란히 "단일 진입점(single entry point)" 시스템으로 분류한다. 모든 로직이 reduce 한 곳으로 들어오므로, 거기서 의존성을 바꾸면 그 아래 전부가 바뀐 값 안에서 돈다. 반대로 @Observable 모델과 UIKit은 단일 진입점이 아니다. 모델이 다른 모델을 직접 만들면 부모가 받은 오버라이드가 자식에게 가지 않는다(swift-dependencies · Single entry point systems).

SwiftUI(@Observable)에서 @Dependency를 쓸 때 — 자식 모델은 withDependencies(from: self)로 만든다
@Observable
final class EditModel {
  @ObservationIgnored @Dependency(\.audioPlayerClient) var audioPlayer
}

@Observable
final class LibraryModel {
  var edit: EditModel?
  @ObservationIgnored @Dependency(\.audioPlayerClient) var audioPlayer

  func editTapped() {
    // edit = EditModel()   ← 이렇게 만들면 앱 기본값(liveValue)으로 돌아간다
    edit = withDependencies(from: self) {   // 부모가 쓰는 의존성을 그대로 물려준다
      EditModel()
    }
  }
}
전파 방식SwiftUI 환경@Observable + @DependencyTCA 리듀서
부모 → 자식뷰 트리를 따라 자동자식을 만들 때마다 withDependencies(from: self)Scope·ifLet·forEach를 따라 자동
비동기 작업모델에는 닿지 않는다(생성자로 넘김)Task면 상속, 콜백이면 끊김.run이 만들 때 붙잡아 간다
빠뜨리면—조용히 liveValue로 돌아간다탈출 클로저를 직접 만들 때만 문제
🔑 오늘의 결론

의존성을 struct of closures로 설계하고, testValue는 미구현으로 둔다. 바꾸는 자리는 TestStore·Store의 withDependencies:와 리듀서 .dependency다. 이펙트 안에서는 Task·clock 같은 구조적 도구를 써서 그 값이 끝까지 따라가게 한다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
테스트에서 "재생 버튼은 play만 부르고 stop은 안 부른다"를 확인할 때, struct of closures + 미구현 testValue가 protocol보다 나은 이유는?
struct 방식은 $0.audioPlayerClient.play = { … } 한 줄로 play만 덮어쓰고, stop은 미구현으로 남는다. stop이 불리면 그 자체로 테스트가 실패하므로 "안 부른다"가 자동으로 증명된다. protocol 방식은 play를 기록하고 stop에서 실패를 내는 Mock 타입을 따로 만들어야 한다.
쉽게 말하면 리모컨 버튼 하나만 진짜로 바꿔 끼우고, 나머지 버튼은 누르면 삐 소리 나게 막아 두는 것이다.
@Observable 모델에서 자식 모델을 withDependencies(from: self) { … }로 만들어야 하는 이유는? TCA 리듀서는 왜 그럴 필요가 없나?
@Dependency는 만들어지는 순간의 의존성을 붙잡는다. 부모 모델 메서드 안에서 자식을 그냥 만들면 그 시점엔 오버라이드 범위 밖이라 기본값을 붙잡는다. withDependencies(from: self)가 부모가 붙잡은 값을 자식 생성에 다시 깔아 준다. TCA는 모든 로직이 스토어의 reduce라는 단일 진입점으로 들어오고, Scope 같은 조합 도구가 같은 의존성 안에서 자식을 돌리므로 자동으로 전파된다.
쉽게 말하면 모델 방식은 동생에게 "엄마가 정한 규칙"을 매번 직접 전해 줘야 하고, TCA는 온 가족이 같은 방에 있어서 한 번 말하면 다 듣는다.
🔮 코드 예측: 2절의 Onboarding 리듀서 body에 Scope 다음으로 Reduce { _, _ in .run { _ in try await settingsClient.save(key: "done", value: true) } catch: { _, _ in } }를 추가했다(Onboarding에도 @Dependency(\.settingsClient) 선언). .settings(.notificationsToggled)를 보내면 두 save는 각각 어떤 구현을 쓰나?
Settings 쪽 save는 .inMemory, Onboarding 쪽 save는 앱 기본값(liveValue)이다. .dependency가 만드는 _DependencyKeyWritingReducer는 withDependencies { update } operation: { base._reduce(...) }로 감싼 리듀서만 바뀐 값 안에서 돌린다(소스 기준). Settings가 돌려준 .run은 그 안에서 만들어져 .inMemory를 붙잡는다. 형제인 Reduce는 감싸지 않았으므로 원래 값을 쓴다.
쉽게 말하면 연습용 장갑은 Settings 선수에게만 끼웠다. 옆에 서 있는 Onboarding 선수는 원래 장갑 그대로다.
🔁 1부 복습: DependencyKey를 채택하고 liveValue만 구현했다. Xcode Preview에서는 어떤 값이 쓰이나? 테스트에서는?
Preview에서는 기본 previewValue가 liveValue를 돌려주므로 진짜 구현이 쓰인다(네트워크까지 나갈 수 있다). 테스트에서는 기본 testValue가 "no test implementation" 실패를 기록한 뒤 previewValue(= liveValue)를 돌려준다. 그래서 previewValue는 가짜 데이터로, testValue는 미구현으로 채우는 것이 권장이다.
쉽게 말하면 리허설용 소품을 안 만들면 진짜 소품이 무대에 오르고, 연습 경기에서는 심판이 경고를 준다.

🛠 미니 과제 (선택 · 10분)

명언을 불러오는 기능을 만든다. Xcode 프로젝트에 TCA 패키지가 추가돼 있다고 가정한다.

시작 코드 — 리듀서가 바깥 세상을 직접 부른다
@Reducer
struct Quote {
  @ObservableState
  struct State: Equatable { var text: String?; var fetchedAt: Date? }
  enum Action { case refreshTapped, quoteLoaded(String) }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .refreshTapped:
        state.fetchedAt = Date()                           // 통제 밖
        return .run { send in
          let (data, _) = try await URLSession.shared      // 통제 밖
            .data(from: URL(string: "https://example.com/quote")!)
          await send(.quoteLoaded(String(decoding: data, as: UTF8.self)))
        }
      case let .quoteLoaded(text):
        state.text = text
        return .none
      }
    }
  }
}

요구사항

확인 방법: 테스트가 통과한다. withDependencies:에서 fetch 덮어쓰기를 지우면 "Unimplemented: 'QuoteClient.fetch'" 실패가 나는지 본다. Preview에서는 아무것도 덮어쓰지 않아도 previewValue의 문장이 보이는지 본다.

정답 코드
정답 — 등록 · 리듀서 · 테스트
import Testing

@DependencyClient
struct QuoteClient: Sendable {
  var fetch: @Sendable () async throws -> String
}
extension QuoteClient: DependencyKey {
  static let liveValue = QuoteClient(fetch: {
    let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com/quote")!)
    return String(decoding: data, as: UTF8.self)
  })
  static let previewValue = QuoteClient(fetch: { "Preview에서는 바로 보이는 명언" })
  static let testValue = QuoteClient()
}
extension DependencyValues {
  var quoteClient: QuoteClient {
    get { self[QuoteClient.self] }
    set { self[QuoteClient.self] = newValue }
  }
}

@Reducer
struct QuoteFeature {
  @ObservableState
  struct State: Equatable { var text: String?; var fetchedAt: Date? }
  enum Action { case refreshTapped, quoteLoaded(String) }

  @Dependency(\.quoteClient) var quoteClient
  @Dependency(\.date.now) var now

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .refreshTapped:
        state.fetchedAt = now
        return .run { send in
          await send(.quoteLoaded(try await quoteClient.fetch()))
        } catch: { _, send in
          await send(.quoteLoaded("불러오지 못했다"))
        }
      case let .quoteLoaded(text):
        state.text = text
        return .none
      }
    }
  }
}

@MainActor
@Test func 새로고침하면_명언과_시각이_채워진다() async {
  let store = TestStore(initialState: QuoteFeature.State()) {
    QuoteFeature()
  } withDependencies: {
    $0.quoteClient.fetch = { "테스트 명언" }
    $0.date.now = Date(timeIntervalSinceReferenceDate: 0)
  }
  await store.send(.refreshTapped) {
    $0.fetchedAt = Date(timeIntervalSinceReferenceDate: 0)
  }
  await store.receive(\.quoteLoaded) {
    $0.text = "테스트 명언"
  }
}

출처 · 참고자료