A scenario says what a feature does; a slice holds the state it does it with. On the Sean Dinwiddie’s Webmastery team, one maps onto the other a line at a time: the Given is the state before, the When is one event, and each Then is a value a selector returns. This lesson follows one of Module 2’s scenarios into a Redux Toolkit slice and its test.
1. Read the scenario for its parts
The scenario from Writing BDD scenarios, under a Feature line:
Feature: Cart
Scenario: Adding a product to the cart
Given "Widget A" is in stock at $25
And the cart is empty
When the customer adds "Widget A" to the cart
Then the cart contains "Widget A"
And the cart total is $25
Read it for three things:
- The nouns in the Given (a cart, a product, a price) are the state.
- The verb in the When is one event, named for what happened:
itemAdded, rather thansetCart. - Each Then names a fact that can be read back: what the cart holds, and what it totals.
2. The Given is the state before
The slice starts where the Given starts. Here that is an empty cart, and the slice’s initial state already says so. Prices are kept in cents, as whole numbers, so $25 is 2500 and no total drifts by a fraction of a cent.
// features/cart/cartSlice.ts
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'
export type CartLine = { productId: string; name: string; priceCents: number; quantity: number }
type CartState = { lines: CartLine[] }
const initialState: CartState = { lines: [] }
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
itemAdded(state, action: PayloadAction<Omit<CartLine, 'quantity'>>) {
const line = state.lines.find((l) => l.productId === action.payload.productId)
if (line) line.quantity += 1
else state.lines.push({ ...action.payload, quantity: 1 })
},
},
selectors: {
selectLines: (state) => state.lines,
},
})
export const { itemAdded } = cartSlice.actions
export const { selectLines } = cartSlice.selectors
3. The When is one event
The When becomes one dispatched action, named in the past tense for what happened. The reducer takes the last state and the event and decides the next state; the caller never computes it.
Adding a second Widget A is the same event, and the reducer, not the view, knows to raise the quantity. A When that seems to need two events usually hides two scenarios.
The lecture Redux Toolkit and Functional Programming sets the setter-style action this replaces beside the event-style one.
4. Each Then reads a selector
A Then never reaches into the slice’s fields; it reads a selector, so the test still holds when the state’s shape changes. The total is derived, never stored: it is computed from the lines, and createSelector recomputes it only when the lines change.
// features/cart/cartSlice.ts, continued
export const selectTotalCents = createSelector([selectLines], (lines) =>
lines.reduce((sum, line) => sum + line.priceCents * line.quantity, 0),
)
The lecture Redux Toolkit and RTK Query Best Practices holds to the same rule: a total, a count or a label that can be derived from the state is never stored beside it.
5. The scenario becomes the test
In the codebase’s own runner, the test carries the scenario’s name, and its three parts follow the scenario’s. It runs the reducer and the selectors directly, with no store, no view and no browser:
// features/cart/cart.test.ts
import { describe, expect, it } from 'vitest'
import { cartSlice, itemAdded, selectLines, selectTotalCents } from './cartSlice'
const widgetA = { productId: 'widget-a', name: 'Widget A', priceCents: 2500 }
describe('Feature: Cart', () => {
it('Scenario: Adding a product to the cart', () => {
// Given "Widget A" is in stock at $25, and the cart is empty
const before = cartSlice.getInitialState()
// When the customer adds "Widget A" to the cart
const after = cartSlice.reducer(before, itemAdded(widgetA))
// Then the cart contains "Widget A", and the cart total is $25
expect(selectLines({ cart: after }).map((line) => line.name)).toEqual(['Widget A'])
expect(selectTotalCents({ cart: after })).toBe(2500)
})
})
A failing test names the behavior that broke. Run as executable steps with cucumber-js instead, each line of the scenario gets a step definition, and the step definitions call the same reducer and selectors; either way, the words the owner read are the words that fail.
The lecture Redux Toolkit and Functional Programming tests a slice the same way, through its events and what its selectors return.
6. One scenario, two owners
Module 2’s “Placing the order” ends with two Thens: the order is recorded at $25, and her cart is empty. The first belongs to the API, and the Hspec test in The API: Haskell Servant and Nile proves it.
The second belongs to the slice, and the cart empties when the API accepts the order, not when the button is pressed. The slice listens for the fulfilled action of placeOrder, the endpoint the client generates from the API’s contract (the API names that operation when it builds its OpenAPI document, and Endpoints at the boundary generates the endpoint from it):
// in createSlice({ ... }), beside reducers
extraReducers: (builder) => {
builder.addMatcher(ordersApi.endpoints.placeOrder.matchFulfilled, (state) => {
state.lines = []
})
},
Both tests carry the scenario’s name, so a failure on either side names the same behavior.
The view that shows the cart stays minimal: it reads selectLines and selectTotalCents and dispatches itemAdded, and every decision it depends on lives in the slice, where the scenario tests it.