Advanced React Testing: Redux Saga and React Router by Bonnie Schulkin
Click to Contract/Expend
- Server
- Express server
- Auth done via JWT
- Privdes data for shows and bands
- Records ticket purchases
- Not testing server at all
- Mock Service Worker for server responses in tests
- Client
- Create React App
- TypeScript
- Redux Toolkit
- Redux Saga
- React Router
- Chakra UI
- Jest
- Testing Library
- Testing Sagas is testing implementation details
- Not testing app the way a user would
- Testing code, not app behavior
- Good idea for complicated sagas
- redux-saga is known for testability
- "Testing implementation" tradeoffs:
- diagnosis is easier; test maintenance is harder
- Help diagnose behavior-based("functional") tests
- These tests are not for behavioral testing purists
- This section: intro to redux-saga-test-plan syntax
- Next section: complex ticketFlow saga
- controlled by takeEvery
- Last section: signInFlow saga with fork
- controlled by infinite while loop
- Several options for saga test libraries
- redux-saga-test-plan doc
- Integration testing
- run the saga as a whole
- assert on effects (e.g. put or call)
- Unit testing
- run the saga step-by-step
- assert on effects AND order
- We will mostly integration testing in this course
- unit tests for fork cancel, not possible in integration
I've decided to go the "Redux Saga" part through once more
function* genC(i) {
yield i;
yield i + 10;
return 25;
}
const gB = gen(5);
gB.next();
// { value: 5, done: false }
gB.next();
// { value: 15, done: false }
gB.next();
// { value: 25, done: true }Thinking of it as:
Ability to "pause" functions
npm install --save redux-saga// shop.sagas.js
import { take, takeEvery, takeLatest, delay, put } from "redux-saga/effects";
export function* onIncrement() {
yield console.log("I am incremented");
yield delay(3000); // it doesn't block next event of onIncrement()
yield put({ type: "INCREMENT_FROM_SAGA" });
}
export function* incrementSagaTakeEvery() {
yield takeEvery("INCREMENT", onIncrement);
}
/** takeLatest
* with many click event,
* onIncrement will be fired, but if new event occurs before onIncrement finishes,
* it takes only the last event
*/
export function* incrementSagaTakeLatest() {
yield takeLatest("INCREMENT", onIncrement);
}
/* take with while
* This looks similar with takeEvery
* But it is hung on delay(), unlike takeEvery which creates new
*/
export function* incrementSagaTakeWhile() {
while (true) {
yield take("INCREMENT");
yield console.log("I am incremented");
yield delay(5000);
}
}
/** take */
export function* incrementSagaTake() {
yield take("INCREMENT");
console.log("I am incremented");
}// root-saga.js
import { all, call } from "redux-saga/effects";
import { fetchCollectionsStart } from "./shop/shop.sagas";
export default function* rootSaga() {
// all : https://redux-saga.js.org/docs/api/#alleffects---parallel-effects
yield all([call(fetchCollectionsStart)]);
}redux-saga-test-plan : Partial Assertions - Helper Methods
- Integration testing (We will do mostly integration testing)
- Run entire saga
- Tools like mocks and partial matchers
- Unit testing (It will be introduced at the end of this course)
- Run saga step-by-step
- Assert on effects AND order
- redux-saga-test-plan syntax
- return / await expectSaga
- [.not].effect() for assertions
- Partial assertions, such as .call.fn()
- .run() for running the saga
expectSaga(ticketFlow, holdAction);
return expectSaga(ticketFlow, holdAction);What happens if you don't return? -> It will always pass (=fault positive)
- Without return expectSaga() or await expectSaga()
- Test function starts async expectSaga call
- Exits before promise resolves
- Assertions run after promise resolves
- After test has already passed
test function starts -> async call -> test function complets without error -> promise resolves -> assertions run
provide() Syntax
- Static vs. dynamic
- static: array of tuples [matcher, mock value]
- dynamic: object liternal, { matcher: (effect, next) => {} }
- We will be using mostly static
- Dynamic: race isn't available for static, making an effect take longer
- Matchers: https://redux-saga-test-plan.jeremyfairbank.com/integration-testing/mocking/static-providers.html
- Pass ticketFlow to expectSaga
- Mock similar to last error thrown test (network providers, selector, override)
- Provider that throws error, use matchers.call.like:
matchers.call.like({ fn: reserveTicketServerCall, args: [purchaseReservation], // not holdReservation! });
- Dispatch this action to trigger purchaseTickets saga:
startTicketPurchase(purchasePayload) - Assertions similar to last error thrown test
- All of these effects happen in purchaseTicket saga
- Plus assert on cancelSource.cancel call from cancelSource argument
- No need for race() provider here (just let the purchase win)
- Provide all network mocks
- Approaches
- Use previous tests as a guide, or
- More challenging: write without looking at previous tests
- Strong recommendation against copy/paste
Technical Notes
- Dispatch this action for "cancel":
startTicketRelease({ reservation: holdReservation, reason: "test" })
- Dispatch this action for "abort":
startTicketAbort({ reservation: holdReservation, reason: "test" })
- Cancellation toast will look like this:
startToast({ title: "test", status: "warning" })
test.each([argument object]) (test name, test function)
- more redux-saga-test-plan syntax
- .provide() for mocking
- throwError() value for throwing Error
- .dispatch() for dispatching actions after start
- dynamic provider for race effect
- .provide() for mocking
- jest's test.each() for parametrizing tests
- Watcher: while loop with cancel-able fork
- cancel sign in if user navigates away from server returns
- Slice manages status piece of state (values: idle, pending)
- Saga only manages sign in
- sign out doesn't have cancellation controls
- Sign-in vs Sign-up
test.todo()
test.todo('successful sign-in');
There will be a warning message because signInFlow() has infinite loop.
console.warn
[WARNING]: Saga exceeded async timeout of 25msNot to see this error, use silentRun() instead of run()
- Virtually identical to sign in flow
- only difference: payload to signInRequest
- actual code path diverges in call to the server
- Worth including the test?
- probably not as a saga test
- maybe in e2e testing, check that the user was created
// as we have mock provider, authenticateUser completed almost immediately.
.fork(authenticateUser, signInRequestPayload)
// and cancelSignIn() didn't dispatch
.dispatch(cancelSignIn())Give it 500ms delay
const sleep = (delay: number) =>
new Promise((resolve) => setTimeout(resolve, delay));
.provide({s
call: async (effect, next) => {
if (effect.fn === authServerCall) {
await sleep(500);
}
next();
},
})Asserting on cancel Effect
- How to assert a cancel occurred?
- cancel effect not supported by redux-saga-test-plan integration tests
- Workaround in GitHub issue to wrap cancel in custom function
- Complicates production code 👎
- Skip that assertion here
redux-saga-test-plan Unit Tests
- General idea
- propose a flow
- test fails if flow is not possible
- Less flexible / forgibing than intergration tests
- next() moves the saga along
- argument to next() is value yielded from previous effect
- Need to create a mock task for fork / cancel
Unlike Integration Test,
Unit test cannot chagne the order of sagas
- Argument to run() (timeout in milliseconds) to stop infinite loop
- use silentRun() to suppress warnings
- sleep() helper function to make task run long enough to cancel
- cancel effect not supported by integration tests (yet)
- unit tests
- propose a flow
- error if flow is not possible
- I prefer integration tests
- unit tests are best if order is crucial
- or if integration tests aren't possible
If I got stuck during testing redux saga,
create a repo and write a question in Q&A.
- Redux and React Router both have Providers that components need
- Redux: provides store
- React Router: provides history and location details
- Don't wrap with Providers -> errors
- Wrap with these Providers for every render function?
- tedious and repititive
- Solution:
- create custom render function that includes providers
- add options to render options object for initial state and router history
- Put Provider in App, render App for all tests
- Pros:
- closer to production code
- simpler test setup (only render App, no Provider wrap)
- Cons:
- No control over initial state
- Can't render child components (they will be sad and provider-less, and throw errors)
- Pros:
- Wrap components in Provider with test store
- Pros:
- can specify initial state
- can render child components
- Cons:
- more complicated test setup
- Provider needs a store
- need to make a new one for each test
- farther from production code
- will use same function to create store
- more complicated test setup
- Pros:
Winner: Wrap components. Faster, more tarted tests
- Each test needs its own store (no overlap)
- Store should be close as possible to production store
- Testing Library: render (ui, options)
- The plan: write custom render method to
- create store using function from app/store/index.ts
- add initialState to options
- wrap ui in Redux provider with store containing specified initialState
- run default Testing Library render on wrapped ui and return result
- test-utils/index.ts file exports * from @testing-library/react
- then overwrite default render with custom render
- import from test-utils instead of @testing-library/react
- Eventually wrap in test Router as well
- Redux Provider and Router for production in src/index.tsx
- instead of App.tsx
- better control in testing
- Code adapted from Redux testing docs:
- https://redux.js.org/usage/writing-tests#components
- which are based on testing library docs: https://testing-library.com/docs/react-testing-library/setup#custom-render
- Adding Router to custom render: https://testing-library.com/docs/example-react-router/
- Create history using createMemoryHistory pass to test Router provider
- memory
- memoryHistory: history independent of browser
- analogous to setting up test store for Redux
- Add two more properties to render options
- routeHistory: Array of routes (string)
- initialRouteIndex: where in the history to start
- default to last index of routeHistory
- Eventually: make history accessible to tests for assertions
npm i eslint-plugin-simple-import-sort
# and set up the eslint ruleImplementation Details?
- Is this testing implementation details?
- Relying on the structure of the history object
- Alternate plan:
- render App with initial route set to /profile
- check actual text on page
- Which is better?
- Testing code vs testing behavior
- User tradeoff:
- implementation details test is more targeted / more isolated,
- also doesn't test user experience
- Will demonstrate behavior test in the next lecture (optional)
- Effect is the same as so same test approach applies
- Test NavBar push to /signin on Sign in button push
- only history.push instance that doesn't need fake server data
- Test that clicking sign in button loads "/signin" url and page when user is null
- Think about what to pass as options to custom render
- If any!
- Try both styles of testing: unit testing and behavior testing
- Write tests in src/app/component/nav/NavBar.test.tsx
- Resist th urge to copy/paste!
- Redux state tests for NavBar:
- "sign in" button when user is falsy
- "sign out" button and user email when user is truthy
- Behaviour tests (look for elements on the page)
- Choises and tradeoffs:
- providers in App, render App for all tests
- closer to user experience
- less control over tests
- providers in index.tsx, render components in tests
- opposite of above
- Wrap ui in providers for custom render
- test store for Redux
- test history (memoryHistory) for Router
- App "setup" properties to render options object
- Return history with render return object
- Export * from @testing-library/react, overwrite with custom *render*
- import from test-utils instead of @testing-library/react
- providers in App, render App for all tests
- Mock Service Worker (MSW) mock responses from the server
- Why not just mock seelctors?
- Also a viable option
- Mocking server response is more flexible
- One server response could cover a veriety of selectors
- As long as data is covered, don't need to worry about selectors
- Tests more of your app
- Tests selectors (or, say RTK Query calls) which mocks do not
- Works if you change method of server call (e.g. fetch to axios)
- npm install msw
- Create handlers
- Create test server
- Associate with handlers
- Make sure test server listens during all tests
- Reset after each test
- https://mswjs.io/docs/getting-started/integrate/node
rest.get(showsUrl, (req, res, ctx) => {})
- Handler Type: rest or graphql
- HTTP method to mock: get, post, etc.
- Full URL to mock
- Response resolver function
- req: request object
- res: function to create response
- ctx: utility to build response
- https://mswjs.io/docs/basics/response-resolver
- Response resolver function
- Full URL to mock
- HTTP method to mock: get, post, etc.
- Test: band page /bands/:bandId shows the proper content
- Can't just render component
- URL param is only accessible from parent component render
- Even if we add, say, /bands/0 to the route history
- Need to render App, not Bands
- Set location in route history
- Add bands handler to msw
- data comes from server
- Test: clicking "Buy Tickets" from shows pushes the correct /tickets/:showId URL
- unit test only here for pushed route
- could also do functional testing for page elements
- Unit test is more specific to React Router
- More specific to this course
- Test: clicking "Purchase" from Tickets pushes the correct /confirm URL
- Seems simple, but actually quite complicated!
- Need to account for
- showId coming from URL param
- URL param is only accessible if we render parent component
- Need to render App, not Tickets
- showId coming from URL param
- Auth protected: need user in Redux store
- Show data comes from server; async rendering
- Need to make sure show is not sold out!
- URL param located in history.location.pathname
- Query string located in history.location.search
- Random hold ID in pushed URL query string
/conform/${showId}?holdId=${generatedRandomId()}&seatCount=${reservedSeatCount}- will match via regular expression
- Your goal: write a test that
- excludes holdId in the query string
- assert that the app redirects to /tickets/:showId
- For completeness should test when seatCount is missing and when both are missing
- Completeness is not the objective of this course
- Note, this is actually less complicated than the last test
- the query params are in the current route, not the pushed route!
- Things to think about
- What do you need to render here?
- What do you need to include in the route history?
- What, if anything, do you need to include in the Redux store?
- Use Mock Service Worker to responsd to server calls during tests
- Can tailor response to properties of request
- Control route rendered for test:
- Specify URL params and query params in route history path
- Must render parent element for params to be available
- Assert on pushed/redirected route:
- URL params in location.pathname
- Query params in location.search
- Used Jest's expect.stringMatching to match regex
- Test that
- Non-protected routes never redirect to sign-in
- If logged in, auth-protected routes don't redirect
- If not logged in, auth-protected routes:
- redirect to sign-in page
- after login, redirect back to auth-protected route
- Parametrize tests with test.each()
- same tests, different data
- Test by page heading
- Done a lot of router history tests
- Mix it up by asserting on what's showing on the page
- functional test / testing behavior
- Where to put tests?
- will (eventually) parametrize with test.each()
- not to associated with any particular page
- put in auth feature directory
- What to render?
- Checking for redirects, render App
- Run same test with different data using test.each()
- `test.each([argument object])(test name, test function)
- test.each takes an array of argument objects
- Returns a function that works like test
- Takes test name and test function
- Test function takes argument from argument objests
- Can use argument object properties in test name (e.g. "works with $name")
- ...when jestjs/jest#11388 is released
- Fast behavior-based test for every protected route
- Redirect to sign-in if not signed in
- Don't redirect if signed in (Already part of other tests)
- Slower behavior-based tests for only one route
- Redirect back to page after sign-in / sign-up
- Don't redirect after failed sign-in
- But do redirect for failed -> successful sign in
- Need handler for sign-in / sign-in endpoints
- Longer functional test with user flow:
- More the style recommended in this Kent C. Dodds post
- Write fewer, longer tests (https://kentcdodds.com/blog/write-fewer-longer-tests)
- Test steps:
- Go to protected route
- Redirect to sign in
- Sign in (successfully, tahnks to handler!)
- Use user-event for easier entry
- https://testing-library.com/docs/ecosystem-user-event/
- Test for redirect back to initial protected route
- with /signin removed from history
- Test for all routes?
- Not necessary
- We tested redirect for individual routes
- Here we're testing flow mechanism
- All routes use same flow mechanism
- "White box" testing, but reasonable assumptions
- Why not test.each()?
- Long test, would slow down testing
- await findBy* allows async for page elements
- waitFor allows async for assertions
- Requires Testing Library 10+
- If you're getting this error:
- `TypeError: (0, _testUtils.waitforElementToBeRemoved) is not a function
- Run this command
npm install @testing-library/react@^10.0
Unsuccessful Sign-In
- Simulate unsuccessfull sign-in by altering response from server
- This lecture: failed login (bad username/password)
- Code quiz: parametrize for
- server error
- sign-up failure
- sign-up server error
- Defining handler response for one test:
- https://mswjs.io/docs/api/setup-server/reset-handlers
- cleaned up in setupTests.js afterEach
- Parametrize test to cover all these cases:
- Sign in server error
- Sign in failure
- Sign up server error
- Sign up failure
- status 400
- message "email already in use"
- https://mswjs.io/docs/recipes/mocking-error-responses
- What properties are needed in the parametrization array?
- Test non-protected route (no redirect)
- Test redirect to sign in for all protected routes
- parametrization very useful
- Test successful sign in
- "expensive" test
- only one route
- Test unsuccessful -> successful sign in / sign up
- MSW handlers control success or lack thereof!