1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

The Composable Architectureのテストについて触ってみた

Last updated at Posted at 2022-05-11

初めに

今回はTCAのテストについて先日実装してみたのでそこで得た知見を備忘録として残しておきます。
実装するプロジェクトはチュートリアルのプロジェクトのカウントアプリです。

今回テストするアプリ

今回実装しているアプリが持っている機能は主にこちらになります。

Simulator Screen Recording - iPhone 13 Pro - 2022-05-11 at 23.17.56.gif

全体のコード

前提としてこちらの記事を参考にそれぞれのレイヤーの役割を引用しておきます。

State: 1つの機能がそのロジックを実行したり、UIを描画したりするために、必要となるデータを定義する。
Action: 1つの機能で発生するすべてのアクション(ユーザからのUI操作やユーザへの通知やデータ層からのデータ受け取りなど)を表現する。
Reducer: 受け取ったActionに応じてStateを更新するファンクション。
Effect: 副作用(Side Effect)を含む作用を表現する型
Environment: 1つの機能において必要となる依存を保持する。いわゆるDI(Dependency Injection)を提供します。

さらにStoreについて

Store: State/Action/Reducer/Environmentを一つにまとめ、それらの窓口となる。すべてのActionはStoreに対して送信され、それを受けてStoreはReducerを動かす。Reducerの処理結果で発生したStateの変更は、Storeを経由してViewで監視できるようになっている。

struct CounterState: Equatable, Identifiable {
  let id = UUID()
  var count = 0
}

enum CounterAction: Equatable { //←ここの動作をテスト
  case decrementButtonTapped
  case incrementButtonTapped
}

struct CounterEnvironment {}

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
  switch action {
  case .decrementButtonTapped:
    state.count -= 1
    return .none
  case .incrementButtonTapped:
    state.count += 1
    return .none
  }
}

final class CounterViewController: UIViewController {
  let viewStore: ViewStore<CounterState, CounterAction>
  private var cancellables: Set<AnyCancellable> = []

  init(store: Store<CounterState, CounterAction>) {
    self.viewStore = ViewStore(store)
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    self.view.backgroundColor = .systemBackground

    let decrementButton = UIButton(type: .system)
    decrementButton.addTarget(self, action: #selector(decrementButtonTapped), for: .touchUpInside)
    decrementButton.setTitle("−", for: .normal)

    let countLabel = UILabel()
    countLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular)

    let incrementButton = UIButton(type: .system)
    incrementButton.addTarget(self, action: #selector(incrementButtonTapped), for: .touchUpInside)
    incrementButton.setTitle("+", for: .normal)

    let rootStackView = UIStackView(arrangedSubviews: [
      decrementButton,
      countLabel,
      incrementButton,
    ])
    rootStackView.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(rootStackView)

    NSLayoutConstraint.activate([
      rootStackView.centerXAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerXAnchor),
      rootStackView.centerYAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.centerYAnchor),
    ])

    self.viewStore.publisher
      .map { "\($0.count)" }
      .assign(to: \.text, on: countLabel)
      .store(in: &self.cancellables)
  }

  @objc func decrementButtonTapped() {
    self.viewStore.send(.decrementButtonTapped)
  }

  @objc func incrementButtonTapped() {
    self.viewStore.send(.incrementButtonTapped)
  }
}

struct CounterViewController_Previews: PreviewProvider {
  static var previews: some View {
    let vc = CounterViewController(
      store: Store(
        initialState: CounterState(),
        reducer: counterReducer,
        environment: CounterEnvironment()
      )
    )
    return UIViewRepresented(makeUIView: { _ in vc.view })
  }
}

テストするAction

TCAのアーキテクチャ内のActionの役割としてはViewから何らかのActionを列挙型で実装します。
つまり、テストをするところはこのActionの動作ということになります。

enum CounterAction: Equatable {
  case decrementButtonTapped
  case incrementButtonTapped
}

実際のテストコード

final class UIKitCaseStudiesTests: XCTestCase {
  func testCountDown() {
    let store = TestStore(
      initialState: CounterState(),
      reducer: counterReducer,
      environment: CounterEnvironment()
    )

    store.send(.incrementButtonTapped) { // Actionメソッド
      $0.count = 1
    }
    store.send(.decrementButtonTapped) { // Actionメソッド
      $0.count = 0
    }
  }
}

前提としてThe Composable ArchitectureはアーキテクチャでありSDKです。よってsendメソッドを呼び出し期待値を入力することで簡単にテストを実装することができます。
まさにテスト容易性の観点からするととても実装しやすいですね。
以下、sendメソッド内部のコードです。ここのexpectedStateShouldMatchの部分が期待値とマッチしているか判別してくれているようです。

    public func send(
      _ action: LocalAction,
      file: StaticString = #file,
      line: UInt = #line,
      _ update: @escaping (inout LocalState) throws -> Void = { _ in }
    ) {
      if !self.receivedActions.isEmpty {
        var actions = ""
        customDump(self.receivedActions.map(\.action), to: &actions)
        XCTFail(
          """
          Must handle \(self.receivedActions.count) received \
          action\(self.receivedActions.count == 1 ? "" : "s") before sending an action: …

          Unhandled actions: \(actions)
          """,
          file: file, line: line
        )
      }
      var expectedState = self.toLocalState(self.snapshotState)
      self.store.send(.init(origin: .send(action), file: file, line: line))
      do {
        try update(&expectedState)
      } catch {
        XCTFail("Threw error: \(error)", file: file, line: line)
      }
      self.expectedStateShouldMatch(
        expected: expectedState,
        actual: self.toLocalState(self.snapshotState),
        file: file,
        line: line
      )
      if "\(self.file)" == "\(file)" {
        self.line = line
      }
    }

    private func expectedStateShouldMatch( // ←
      expected: LocalState,
      actual: LocalState,
      file: StaticString,
      line: UInt
    ) {
      if expected != actual {
        let difference =
          diff(expected, actual, format: .proportional)
          .map { "\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
          ?? """
          Expected:
          \(String(describing: expected).indent(by: 2))

          Actual:
          \(String(describing: actual).indent(by: 2))
          """

        XCTFail(
          """
          A state change does not match expectation: …

          \(difference)
          """,
          file: file,
          line: line
        )
      }
    }
  }
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?