初めに
今回はTCAのテストについて先日実装してみたのでそこで得た知見を備忘録として残しておきます。
実装するプロジェクトはチュートリアルのプロジェクトのカウントアプリです。
今回テストするアプリ
今回実装しているアプリが持っている機能は主にこちらになります。
全体のコード
前提としてこちらの記事を参考にそれぞれのレイヤーの役割を引用しておきます。
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
)
}
}
}