State, testing and computed properties

While implementing a feature on my App I've been relying on computed properties on the state to move some logic out from the views (computed properties for flags that are used in ifs or computed strings that go in Text).

Everything is nice until I want to write some tests. Because there is some logic on computed properties the nice asserts is not enough anymore.

Imagine a state with an enum, based on the specific case of that enum a flag is true or false.

var showTotalChapters: Bool {
    mode == .chapter
}

Now on the tests, I can check for the state of mode in a succinct way, but for the computed property I need to rely on XCTest.

.send(.updateMode(.page)) {
                $0.mode = .page
                $0.showTotalChapters = false // DOESN'T WORK. IS A COMPUTED PROPERTY!
                XCTAssertEqual($0.showTotalChapters, false) // ugly
            },

This works but is not that nice since it won't tests all computed properties by default.

I was thinking if is better to move the computed properties as stored properties, but then you miss I need to worry about keeping in sync that state in code from different actions. I could ensure that the "computed property code" runs at the end of the reducer, but since we tend to do early returns with the effects I have to endup using a defer block.

Any thoughts?

2 Likes

One option is to make this non-computed in a ViewState struct:

struct MyView: View {
  // Only the state that this view observes.
  struct ViewState: Equatable {
    ...
    var mode: Mode
    var showTotalChapters: Bool
  }

  let store: Store<State, Action>

  var body: some View {
    // Scope `State` to `ViewState` for the view store.
    WithViewStore(self.store.scope(state: \.view)) { viewStore in
      ...
    }
  }
}

extension State {
  // Describe `ViewState` from `State` all at once here.
  var view: MyView.ViewState {
    .init(
      ...
      mode: self.mode,
      showTotalChapters: self.mode == .chapter
    )
  }
}

This comes with the caveat that this logic will be performed eagerly, but if your view depends on observing this state then it's more likely that this logic is called anyway. So doing it up-front also "caches" it (if you call viewStore.showTotalChapters it won't perform the logic twice).

And then in your tests, you can scope your test store the same way and assert against these actual properties:

let store = TestStore(
  initialState: State(),
  reducer: reducer,
  environment: environment
)
.scope(state: \.view)

store.assert(
  .send(.updateMode(.page)) {
    $0.mode = .page
    $0.showTotalChapters = false
  },
  ...
)
1 Like

Do you really need the full power of TestStore to test showTotalChapters? Why not just test it directly?

func testShowTotalChapters() {
    XCTAssertFalse(
        Model(mode: .page)
            .showTotalChapters
    )
    
    XCTAssertTrue(
        Model(mode: .chapter)
            .showTotalChapters
    )
}

I will give this a try thanks. At a first glance It would seem like I would have to duplicate a lot of the properties on State but I need to take a deeper look first.

You don't have to, but you don't need the fancy assert either. It's just very convenient.
I much rather prefer to test this things in conjunction, since those computed properties change their output based on the state, which changes based on actions being sent to the store. So going the manual route dismissed the purpose of the TestStore.
As you can see on my snippet right now I'm using a XCTAssertEqual but I still prefer to do it together with the closure that tests the state.

It's possible, though this can sometimes be mitigated by "re-balancing" state into nested structs. Using ViewState structs can also help encourage you to minimize the state needed for a particular view and minimize rerendering calls to body.

2 Likes

For my perspective, if we use XCTAssertEqual there is possibility to forget asserting it, if we used the TestStore, it will failed if we forget to assert.

1 Like