← TCA 14일 마스터 9 / 14

Day 9 · 테스트 ② — 실전

exhaustivity = .off와 $0의 뜻 변화, store.dependencies로 실패→성공 흐름, @Suite·인자 테스트·의존성 트레이트, @Shared 테스트 미리보기, 테스트 호스트 앱·남은 이펙트·액션 버퍼 함정을 @Observable 테스트와 비교한다.
진행률
0 / 0 완료
Day 8의 exhaustive TestStore는 기능 하나(리프 기능)를 테스트할 때 강하다. 그런데 부모가 자식 셋을 품은 화면에서 "로그인하면 탭이 바뀐다" 하나를 확인하려고 자식 내부의 모든 상태까지 적어야 한다면 테스트는 길어지고 잘 깨진다. 오늘은 실무에서 쓰는 도구를 다룬다. 관심 있는 것만 증명하는 non-exhaustive, 테스트 중간에 의존성 바꾸기, Swift Testing 스타일, 공유 상태 테스트 미리보기, 그리고 테스트가 이유 없이 깨질 때 먼저 의심할 함정이다.

오늘의 목표 · 25분

  • 통합 테스트를 exhaustivity = .off로 줄이고, 클로저 안의 상대 변경(removeLast())을 절대값 대입으로 고친다.
  • store.dependencies로 테스트 중간에 의존성을 바꿔 "실패 → 재시도 성공" 흐름을 한 테스트에 담는다.
  • XCTest 테스트를 @MainActor @Suite struct + @Test + 인자 테스트로 옮긴다.
  • "테스트는 통과했는데 스위트가 실패" 같은 증상을 보고 테스트 호스트 앱, 남은 이펙트, 받지 않은 액션 중 원인을 찾는다.

1. Non-exhaustive — 관심 있는 것만 증명한다 🔥 핵심

TCA 문서 · Testing의 기준은 분명하다. 리프 기능은 exhaustive, 여러 기능을 묶은 통합 흐름은 non-exhaustive다. 문서의 로그인 → 탭 전환 예제를 컴파일되는 크기로 줄여 쓴다.

테스트할 기능 ① — 로그인(자식) + 인증 의존성
@DependencyClient
struct AuthClient {
  var login: @Sendable (_ email: String) async throws -> Void
}
extension AuthClient: DependencyKey {
  static let liveValue = AuthClient(login: { _ in
    try await Task.sleep(for: .seconds(1))     // 실제로는 서버 호출
  })
  static let testValue = AuthClient()          // 모든 엔드포인트가 unimplemented
}
extension DependencyValues {
  var authClient: AuthClient {
    get { self[AuthClient.self] }
    set { self[AuthClient.self] = newValue }
  }
}

@Reducer
struct Login {
  @ObservableState
  struct State: Equatable {
    var email = ""
    var isLoading = false
    var errorMessage: String?
    var canSubmit: Bool { email.contains("@") && !isLoading }
  }
  enum Action {
    case delegate(Delegate)
    case loginFailed
    case loginSucceeded
    case submitButtonTapped
    @CasePathable                    // 중첩 enum은 직접 붙여야 \.delegate.didLogin이 된다
    enum Delegate { case didLogin }
  }
  @Dependency(\.authClient) var authClient

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .delegate:
        return .none
      case .loginFailed:
        state.isLoading = false
        state.errorMessage = "로그인에 실패했다"
        return .none
      case .loginSucceeded:
        state.isLoading = false
        return .send(.delegate(.didLogin))
      case .submitButtonTapped:
        state.isLoading = true
        state.errorMessage = nil
        return .run { [email = state.email] send in
          try await authClient.login(email)
          await send(.loginSucceeded)
        } catch: { _, send in
          await send(.loginFailed)
        }
      }
    }
  }
}
테스트할 기능 ② — 부모가 delegate를 받아 탭을 바꾼다 (Day 5 패턴 · 라벨 없는 Scope)
@Reducer
struct AppFeature {
  enum Tab: Equatable { case activity, profile }
  @ObservableState
  struct State: Equatable {
    var isLoggedIn = false
    var login = Login.State()
    var selectedTab = Tab.profile
  }
  enum Action {
    case login(Login.Action)
  }
  var body: some ReducerOf<Self> {
    Scope(\.login, action: \.login) { Login() }   // 자식이 읽는 DependencyValues 값도 그대로 물려받는다
    Reduce { state, action in
      switch action {
      case .login(.delegate(.didLogin)):
        state.isLoggedIn = true
        state.selectedTab = .activity
        return .none
      case .login:
        return .none
      }
    }
  }
}

Exhaustive로 쓰면 부모 테스트가 자식의 isLoading과 내부 액션 loginSucceeded까지 알아야 한다. 자식이 로딩 표시 방식을 바꾸면 부모 테스트가 깨진다.

exhaustive 통합 테스트 — 자식 내부까지 전부 적어야 한다
import Testing

@MainActor
@Test func loginExhaustive() async {
  let store = TestStore(initialState: AppFeature.State(login: Login.State(email: "blob@example.com"))) {
    AppFeature()
  } withDependencies: {                    // DependencyValues의 authClient를 성공으로
    $0.authClient.login = { _ in }
  }
  await store.send(\.login.submitButtonTapped) {
    $0.login.isLoading = true              // 자식 내부 상태
  }
  await store.receive(\.login.loginSucceeded) {
    $0.login.isLoading = false             // 자식 내부 액션
  }
  await store.receive(\.login.delegate.didLogin) {
    $0.isLoggedIn = true
    $0.selectedTab = .activity             // 정작 확인하고 싶은 것
  }
}
✅ non-exhaustive — "로그인하면 activity 탭"만 증명
import Testing

@MainActor
@Test func loginSwitchesTab() async {
  let store = TestStore(initialState: AppFeature.State(login: Login.State(email: "blob@example.com"))) {
    AppFeature()
  } withDependencies: {                    // DependencyValues의 authClient를 성공으로
    $0.authClient.login = { _ in }
  }
  store.exhaustivity = .off
  await store.send(\.login.submitButtonTapped)          // 상태 단언 생략
  await store.receive(\.login.delegate.didLogin) {      // 사이의 loginSucceeded는 건너뛴다
    $0.selectedTab = .activity
  }
}

두 테스트 모두 1.26.2에서 실행해 통과를 확인했다. .off(showSkippedAssertions: true)로 바꾸면 테스트는 여전히 통과하지만, 건너뛴 상태 변화와 액션이 회색 "expected failure"로 표시된다. 운영 버그를 쫓을 때 "안 보고 있던 곳에서 무슨 일이 있었나"를 확인하는 용도다.

$0의 뜻이 뒤집힌다

가장 헷갈리는 차이다. exhaustive에서 $0은 액션 전 상태, non-exhaustive에서는 액션 후(실제) 상태다. 그래서 .off에서는 removeLast()·append 같은 상대 변경을 쓸 수 없다.

exhaustivity = .on (기본) exhaustivity = .off $0 = 액션 전: ["a", "b"] $0.values.removeLast() → ["a"] 실제 ["a"]와 같음 → 통과 $0 = 액션 후: ["a"] $0.values.removeLast() → [] 실제 ["a"]와 다름 → 실패 같은 클로저, 다른 $0 — .off에서는 "최종값을 대입"한다
non-exhaustive 스토어는 이미 바뀐 상태를 $0으로 준다. 상대 변경은 한 번 더 적용되므로 최종값 대입으로 쓴다.
✅ .off에서는 최종값을 대입한다
import Testing

@Reducer
struct Recents {
  @ObservableState
  struct State: Equatable {
    var values: [String] = []
  }
  enum Action { case removeButtonTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      state.values.removeLast()
      return .none
    }
  }
}

@MainActor
@Test func removeNonExhaustive() async {
  let store = TestStore(initialState: Recents.State(values: ["a", "b"])) { Recents() }
  store.exhaustivity = .off
  await store.send(.removeButtonTapped) {
    $0.values = ["a"]                       // 또는 #expect($0.values.count == 1)
  }
}
❌ .off에서 상대 변경 — 이미 지운 배열을 한 번 더 지운다
store.exhaustivity = .off
await store.send(.removeButtonTapped) {
  $0.values.removeLast()     // 실행 결과: "A state change does not match expectation"
}
도구하는 일
exhaustivity = .off적은 것만 검사. 받지 않은 액션·남은 이펙트는 조용히 넘어감
.off(showSkippedAssertions: true)위와 같되 건너뛴 것을 회색으로 표시. 클로저가 두 번 실행되므로 클로저 안에서 부수효과(UUID 생성 등) 금지
withExhaustivity(.off) { … }블록 안에서만 모드 전환, 끝나면 복원
skipReceivedActions() · skipInFlightEffects()exhaustive 스토어에서 쌓인 액션 비우기 · 실행 중 이펙트 취소. strict: true(기본)면 건너뛸 게 없을 때 실패
finish() · assert { }이펙트가 끝날 때까지 대기 · 액션 없이 현재 상태만 단언
🔑 SwiftUI(@Observable) 테스트는 원래 non-exhaustive다

#expect는 적은 식만 본다. 그래서 @Observable 모델 테스트는 늘 .off와 같은 상태다. TCA는 기본이 엄격하고 필요할 때 느슨하게, @Observable은 엄격하게 만들 방법이 없다. 이 차이가 TCA 테스트의 핵심 대가이자 이득이다.

2. 의존성 바꾸기 — 시작할 때, 중간에, 안 바꿨을 때

Day 7 2부에서 의존성을 어디서 바꾸는지 봤다. 테스트에서는 세 곳이 중요하다. TestStore의 dependencies 문서 주석은 "테스트 도중에 바꿔 실패하다가 성공하는 흐름을 시뮬레이션할 수 있다"고 적는다.

TestStore 생성 withDependencies: 테스트용 의존성 값 DependencyValues 리듀서 @Dependency 이펙트 (.run) authClient.login 테스트 중간 교체 store.dependencies.x = … 안 바꾼 값 = testValue unimplemented면 호출 순간 실패 기록
리듀서와 이펙트는 TestStore가 가진 의존성 값을 읽는다. 시작할 때 채우고, 필요하면 중간에 바꾸고, 안 바꾼 것은 testValue가 막아 준다.
✅ 실패 → 재시도 성공을 한 테스트에 (store.dependencies 중간 교체)
import Testing

struct LoginFailure: Error {}

@MainActor
@Test func retryAfterFailure() async {
  let store = TestStore(initialState: Login.State(email: "blob@example.com")) {
    Login()
  } withDependencies: {                    // DependencyValues: 처음엔 실패하는 서버
    $0.authClient.login = { _ in throw LoginFailure() }
  }
  await store.send(.submitButtonTapped) { $0.isLoading = true }
  await store.receive(\.loginFailed) {
    $0.isLoading = false
    $0.errorMessage = "로그인에 실패했다"
  }

  store.dependencies.authClient.login = { _ in }   // 이제 성공
  await store.send(.submitButtonTapped) {
    $0.isLoading = true
    $0.errorMessage = nil
  }
  await store.receive(\.loginSucceeded) { $0.isLoading = false }
  await store.receive(\.delegate.didLogin)
}
❌ 덮어쓰지 않고 호출 — testValue(AuthClient())가 막는다
let store = TestStore(initialState: Login.State(email: "blob@example.com")) { Login() }
await store.send(.submitButtonTapped) { $0.isLoading = true }
// 실행 결과: Unimplemented: 'AuthClient.login'   ← 테스트 실패로 기록
// (unimplemented 엔드포인트는 에러를 던지므로 이어서 loginFailed가 들어온다)

@Observable 모델도 swift-dependencies를 그대로 쓸 수 있다. swift-dependencies 문서 · Testing의 예제가 바로 모델 기반이다. 모델을 withDependencies 안에서 만들면 그 값이 모델에 붙는다.

SwiftUI(@Observable) + swift-dependencies — 같은 재시도 흐름
import Testing

@Observable @MainActor
final class LoginModel {
  var email = ""
  var errorMessage: String?
  @ObservationIgnored @Dependency(\.authClient) var authClient

  func loginButtonTapped() async {
    do {
      try await authClient.login(email)
      errorMessage = nil
    } catch {
      errorMessage = "로그인에 실패했다"
    }
  }
}

@MainActor
@Test func modelRetry() async {
  let model = withDependencies {           // DependencyValues를 이 블록 안에서만 교체
    $0.authClient.login = { _ in throw LoginFailure() }
  } operation: {
    LoginModel()
  }
  await model.loginButtonTapped()
  #expect(model.errorMessage == "로그인에 실패했다")

  await withDependencies {
    $0.authClient.login = { _ in }
  } operation: {
    await model.loginButtonTapped()
  }
  #expect(model.errorMessage == nil)
}
SwiftUI (@Observable + swift-dependencies)TCA (TestStore)
시작 시 교체withDependencies { } operation: { Model() }TestStore(…) { } withDependencies: { }
중간 교체withDependencies로 다시 감싸서 호출store.dependencies.x = … 한 줄
중간 상태(isLoading) 확인메서드가 끝난 뒤라 볼 수 없음send 클로저에서 증명

3. Swift Testing 스타일

Testing 문서의 모든 예제는 이제 @Test 기반이다. 규칙은 세 가지다. 스위트를 @MainActor로, 테스트 함수를 async로, TestStore는 테스트 안의 지역 변수로 둔다(Day 8).

✅ @Suite + 인자 테스트 + 계산 프로퍼티 확인
import Testing

@MainActor
@Suite struct LoginTests {
  @Test(arguments: ["", "blob", "blob@example.com"])
  func canSubmit(email: String) {
    let store = TestStore(initialState: Login.State(email: email)) { Login() }
    #expect(store.state.canSubmit == email.contains("@"))
  }

  @Test func submitLeadsToDelegate() async {
    let store = TestStore(initialState: Login.State(email: "blob@example.com")) {
      Login()
    } withDependencies: {                  // DependencyValues의 authClient를 성공으로
      $0.authClient.login = { _ in }
    }
    store.exhaustivity = .off
    await store.send(.submitButtonTapped) { $0.isLoading = true }   // .off: $0은 액션 후 상태
    await store.receive(\.delegate.didLogin)
  }
}
XCTestSwift Testing
선언final class FeatureTests: XCTestCase + func testX() async@Suite struct + @Test func x() async
단언XCTAssertEqual#expect · #require
의존성 일괄 교체invokeTest()를 오버라이드해 withDependencies로 감쌈.dependency(\.uuid, .incrementing) · .dependencies { … } 트레이트
실행 방식—같은 프로세스 안에서 병렬 → 베이스 스위트 권장
🔬 의존성 트레이트와 베이스 스위트 (swift-dependencies)

트레이트는 DependenciesTestSupport를 테스트 타깃에만 링크해야 쓸 수 있다(앱 타깃에 링크하면 컴파일이 실패한다고 문서가 경고). Swift Testing은 테스트를 같은 프로세스에서 병렬로 돌리므로, 상태를 가진 의존성이 테스트끼리 섞일 수 있다. 문서의 권장은 빈 베이스 스위트 @Suite(.dependencies) struct BaseSuite {}를 만들고 모든 스위트를 extension BaseSuite { … } 안에 두는 것이다. 이 페이지의 코드 검사기에는 이 모듈이 없어서, 트레이트 문법은 별도로 모듈을 빌드해 타입 검사만 했다.

4. 공유 상태 테스트 미리보기

Day 12에서 다룰 @Shared는 참조처럼 동작하는데도 TestStore가 exhaustive로 검사한다. TCA 문서 · Sharing state에 따르면 @Shared는 테스트 중에 액션 전 스냅샷을 따로 보관한다. 단, 이펙트 안에서 바꾼 공유 상태는 store.assert로 따로 증명해야 한다.

✅ 리듀서에서 바꾼 값은 send로, 이펙트에서 바꾼 값은 assert로
import Testing

@Reducer
struct LaunchCounter {
  @ObservableState
  struct State: Equatable {
    @Shared var count: Int
  }
  enum Action {
    case appLaunched
    case resetButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .appLaunched:
        state.$count.withLock { $0 += 1 }
        return .none
      case .resetButtonTapped:
        return .run { [count = state.$count] _ in
          count.withLock { $0 = 0 }            // 액션 없이 이펙트가 직접 바꾼다
        }
      }
    }
  }
}

@MainActor
@Test func launchAndReset() async {
  let store = TestStore(initialState: LaunchCounter.State(count: Shared(value: 0))) {
    LaunchCounter()
  }
  await store.send(.appLaunched) {
    $0.$count.withLock { $0 = 1 }
  }
  await store.send(.resetButtonTapped)
  store.assert {
    $0.$count.withLock { $0 = 0 }
  }
}

store.assert를 빼면 실행 결과는 Test store finished before asserting against changes to shared state.이다(1.26.2에서 확인). @Observable 모델을 여러 화면이 참조로 공유하면 테스트에서 "액션 전" 모습을 따로 얻을 방법이 없다. 끝난 뒤의 값만 볼 수 있다. .appStorage·.fileStorage 같은 영속 전략의 테스트 격리는 Day 12 2부에서 본다.

5. 흔한 함정 — 테스트는 통과했는데 스위트가 실패할 때

① 테스트 호스트 앱

Testing 문서의 "Testing gotchas"가 첫 번째로 꼽는다. 앱 타깃에서 테스트를 돌리면 시뮬레이터가 실제 앱 진입점도 같이 실행한다. 앱 코드가 live 의존성을 건드리면, 테스트 컨텍스트이기 때문에 실패가 기록된다. 개별 테스트는 모두 초록인데 스위트가 빨갛게 나오는 이유다.

시뮬레이터 속 앱 프로세스 (앱 타깃 테스트) 테스트 러너 TestStore 테스트들 개별 테스트: 통과 @main App 루트 뷰 · live 의존성 스위트: 실패 기록 해결: SPM 모듈에서 테스트하거나, 진입점에서 isTesting이면 루트 뷰를 그리지 않는다 프레임워크·SPM 라이브러리 테스트는 앱을 띄우지 않으므로 이 문제가 없다
아래 줄의 앱 코드가 테스트와 같은 프로세스에서 돌며 테스트 컨텍스트에서 live 의존성을 건드린다.
✅ 빠른 우회 — 테스트 중에는 루트 뷰를 그리지 않는다
@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      if !isTesting {            // IssueReporting(TCA가 함께 가져옴)의 전역 값
        RootView()
      }
    }
  }
}

struct RootView: View {
  var body: some View { Text("홈") }
}

TCA Testing 문서는 같은 목적으로 TestContext.current == nil을 보여 준다. swift-dependencies 문서와 IssueReporting 소스는 isTesting을 쓴다. 소스 주석에 따르면 isTesting은 "프로세스가 테스트를 돌리는 중인가"이고, TestContext.current는 "지금 이 작업이 테스트 안인가"다.

② 남은 이펙트 · 받지 않은 액션 · 액션 버퍼

증상 (실패 메시지)원인고치는 법
An effect returned for this action is still running타이머·스트림처럼 끝나지 않는 이펙트끄는 액션 보내기, TestStoreTask.cancel(), 시계 진행, 또는 skipInFlightEffects()
Must handle N received actions before sending an actionTestStore는 이펙트가 보낸 액션을 단언 전에 먼저 처리해 버퍼에 쌓는다다음 send 전에 receive, 관심 없으면 skipReceivedActions()
혼자 돌리면 통과, 같이 돌리면 엉뚱한 의존성 실패다른 테스트가 탈출시킨 Task가 늦게 실행됨(test case leakage)테스트 안에서 Task { }를 띄우고 버리지 않는다
의존성을 바꿨는데 live가 불림 + "implemented in both" 경고테스트 타깃이 TCA를 정적으로 한 번 더 링크테스트 타깃의 "Link Binary With Libraries"에서 제거
⚠️ 버퍼가 공유 상태 테스트를 바꾼다

SharingState 문서는 4절의 store.assert가 필요한 이유를 이렇게 설명한다. TestStore가 @Shared보다 먼저 만들어진 도구라서, 받은 액션을 단언하기 전에 먼저 처리한다. 이펙트가 공유 상태를 바꾼 시점과 테스트가 단언하는 시점이 어긋나므로, 이펙트발 공유 상태 변화는 별도 assert로 받는다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
통합 테스트를 non-exhaustive로 쓰는 이유 두 가지는?
(1) 부모 테스트가 자식 내부 상태·액션까지 알아야 하는 부담이 사라진다. (2) 자식이 내부 로직을 바꿔도 부모 테스트가 깨지지 않는다. 문서는 여기에 복붙으로 늘어나는 긴 테스트 문제도 든다. 리프 기능은 여전히 exhaustive로 테스트한다.
쉽게 말하면 "택배가 우리 집에 왔나"만 확인하면 되는데 물류센터 컨베이어 벨트 속도까지 적을 필요는 없다.
테스트 중간에 의존성을 바꾸는 TCA 코드 한 줄과, 그것이 @Observable 방식보다 나은 점은?
store.dependencies.authClient.login = { _ in }. @Observable 모델은 withDependencies로 호출을 다시 감싸야 한다. TCA 쪽은 그 사이의 isLoading 같은 중간 상태도 send 클로저로 증명할 수 있다.
쉽게 말하면 연습 경기 중에 상대 팀 선수를 바꾸는 것이다. 경기를 새로 시작하지 않아도 된다.
🔮 코드 예측: 1절 Recents에서 values: ["a", "b"]로 시작하고 exhaustivity = .off인 채 send(.removeButtonTapped) { $0.values.removeLast() }를 보내면? 같은 코드를 .on에서 돌리면?
.off: 실패한다. $0은 이미 ["a"]이고 한 번 더 지워 []가 되므로 실제 ["a"]와 다르다. 실제 메시지는 A state change does not match expectation이고 diff에 + [0]: "a"가 보인다. .on: 통과한다. $0이 액션 전 ["a", "b"]라서 removeLast() 결과가 실제와 같다. 둘 다 1.26.2에서 실행해 확인했다.
쉽게 말하면 이미 한 입 먹은 빵을 받아 놓고 "한 입 먹은 모습으로 만들어 봐"라고 하면 두 입이 된다.
🔁 지난 Day 복습 (Day 8): exhaustive 스토어에서 receive를 빼먹고 다음 액션을 보내면 어떤 메시지가 나오나?
Must handle N received actions before sending an action. 이펙트가 되돌린 액션이 버퍼에 남아 있다는 뜻이다. 그 액션들이 바꾼 상태까지 증명하지 않았으므로 상태 불일치 실패도 함께 나올 수 있다.
쉽게 말하면 우편함에 편지가 쌓였는데 안 읽고 답장을 보내려 하면 "먼저 읽어!"라고 막힌다.

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

아래 할 일 목록 리듀서로 세 가지 테스트를 쓴다.

시작 코드 — UUID 의존성을 쓰는 할 일 목록
struct Todo: Equatable, Identifiable {
  let id: UUID
  var title: String
}

@Reducer
struct TodoList {
  @ObservableState
  struct State: Equatable {
    var todos: IdentifiedArrayOf<Todo> = []
  }
  enum Action {
    case addButtonTapped(String)
  }
  @Dependency(\.uuid) var uuid

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .addButtonTapped(title):
        state.todos.append(Todo(id: uuid(), title: title))
        return .none
      }
    }
  }
}
정답 코드
정답 — 세 가지 테스트
import Testing

@MainActor
@Suite struct TodoListTests {
  @Test func addTwo() async {
    let store = TestStore(initialState: TodoList.State()) {
      TodoList()
    } withDependencies: {
      $0.uuid = .incrementing
    }
    await store.send(.addButtonTapped("우유")) {
      $0.todos = [Todo(id: UUID(0), title: "우유")]
    }
    await store.send(.addButtonTapped("빵")) {
      $0.todos = [
        Todo(id: UUID(0), title: "우유"),
        Todo(id: UUID(1), title: "빵"),
      ]
    }
  }

  @Test func addThreeCountOnly() async {
    let store = TestStore(initialState: TodoList.State()) {
      TodoList()
    } withDependencies: {
      $0.uuid = .incrementing
    }
    store.exhaustivity = .off
    await store.send(.addButtonTapped("a"))
    await store.send(.addButtonTapped("b"))
    await store.send(.addButtonTapped("c")) {
      #expect($0.todos.count == 3)          // .off: $0은 액션 후 상태
    }
  }

  @Test(arguments: ["우유", "빵", "달걀"])
  func addOne(title: String) async {
    let store = TestStore(initialState: TodoList.State()) {
      TodoList()
    } withDependencies: {
      $0.uuid = .incrementing
    }
    await store.send(.addButtonTapped(title)) {
      $0.todos = [Todo(id: UUID(0), title: title)]
    }
  }
}

인자 테스트는 인자마다 TestStore를 새로 만들므로 매번 UUID(0)부터 시작한다.

출처 · 참고자료