├── .nvmrc ├── examples ├── redux-saga-example │ ├── README.md │ ├── public │ │ └── index.html │ ├── src │ │ ├── index.js │ │ ├── setupTests.js │ │ ├── Api.js │ │ ├── loginFlow.saga.js │ │ ├── store.js │ │ ├── App.js │ │ ├── App.test.js │ │ └── loginFlow.saga.with-expect-redux.test.js │ ├── .gitignore │ └── package.json ├── redux-observable-example │ ├── README.md │ ├── public │ │ └── index.html │ ├── src │ │ ├── index.js │ │ ├── setupTests.js │ │ ├── Api.js │ │ ├── login.epic.js │ │ ├── store.js │ │ ├── App.js │ │ ├── App.test.js │ │ └── login.epic.with-expect-redux.test.js │ ├── .gitignore │ └── package.json ├── redux-thunk-example │ ├── public │ │ └── index.html │ ├── src │ │ ├── index.js │ │ ├── setupTests.js │ │ ├── store.test.js │ │ ├── App.t-effect.test.js │ │ ├── App.js │ │ ├── store.js │ │ └── App.t-simple.test.js │ ├── .gitignore │ ├── package.json │ └── README.md └── async-effects-example │ ├── .gitignore │ ├── src │ ├── setupTests.js │ ├── store.js │ └── store.test.js │ └── package.json ├── docs ├── example.gif └── logo.svg ├── src ├── index.ts ├── _trySerialize.ts ├── _promiseLike.ts ├── _printTable.ts ├── storeSpy.ts ├── expect.ts ├── not_action_matcher.ts ├── action_matcher.ts └── state_matcher.ts ├── test ├── _assertPromiseDidNotResolve.ts ├── state_matchers.test.ts ├── action_matchers.test.ts ├── betterErrorMessagesTimeout.test.ts ├── action_matchers_predicates.test.ts ├── spec.test.ts └── asyncAndSyncDispatch.test.ts ├── tsconfig.json ├── .travis.yml ├── renovate.json ├── .npmignore ├── .gitignore ├── .github └── workflows │ └── build.yaml ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.12.0 2 | -------------------------------------------------------------------------------- /examples/redux-saga-example/README.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/redux-observable-example/README.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/redux-saga-example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/async-effects-example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /examples/redux-observable-example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rradczewski/expect-redux/HEAD/docs/example.gif -------------------------------------------------------------------------------- /examples/redux-saga-example/src/index.js: -------------------------------------------------------------------------------- 1 | // Ignore, just there so CRA doesn't complain 2 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/index.js: -------------------------------------------------------------------------------- 1 | // Ignore, just there so CRA doesn't complain 2 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/index.js: -------------------------------------------------------------------------------- 1 | // Ignore, just there so CRA doesn't complain 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as expectRedux } from "./expect"; 2 | export { default as storeSpy } from "./storeSpy"; 3 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { expectRedux } from 'expect-redux'; 2 | 3 | expectRedux.configure({ betterErrorMessagesTimeout: 100 }); 4 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { expectRedux } from 'expect-redux'; 2 | 3 | expectRedux.configure({ betterErrorMessagesTimeout: 100 }); 4 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { expectRedux } from 'expect-redux'; 2 | 3 | expectRedux.configure({ betterErrorMessagesTimeout: 100 }); 4 | -------------------------------------------------------------------------------- /examples/async-effects-example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | const { expectRedux } = require('expect-redux'); 2 | 3 | expectRedux.configure({ betterErrorMessagesTimeout: 100 }); 4 | -------------------------------------------------------------------------------- /src/_trySerialize.ts: -------------------------------------------------------------------------------- 1 | export const trySerialize = (o: any): string => { 2 | try { 3 | return JSON.stringify(o); 4 | } catch (e) { 5 | return `{ Unserializable Object: ${e} }`; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/_promiseLike.ts: -------------------------------------------------------------------------------- 1 | export interface PromiseLike { 2 | then( 3 | onFulfill: (result: any) => PromiseLike | unknown, 4 | onReject?: (error: any) => PromiseLike | unknown 5 | ): PromiseLike; 6 | catch(onReject: (error: any) => PromiseLike | unknown): PromiseLike; 7 | } 8 | -------------------------------------------------------------------------------- /test/_assertPromiseDidNotResolve.ts: -------------------------------------------------------------------------------- 1 | export const assertPromiseDidNotResolve = promise => { 2 | let isDone = false; 3 | promise.then(() => { 4 | isDone = true; 5 | }); 6 | 7 | return Promise.resolve().then(() => 8 | isDone ? Promise.reject("Promise was resolved") : Promise.resolve() 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es2015.collection", "es2015.iterable", "es2015.promise", "dom", "es5"], 6 | "declaration": true, 7 | "outDir": "./dist" 8 | }, 9 | "exclude": ["examples", "node_modules"], 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/async-effects-example/src/store.js: -------------------------------------------------------------------------------- 1 | const { createStore, applyMiddleware, compose } = require("redux"); 2 | const { thunk } = require("redux-thunk"); 3 | 4 | const reducer = (state = {}) => state; 5 | 6 | module.exports = { 7 | configureStore: (storeEnhancers = []) => 8 | createStore(reducer, compose(applyMiddleware(thunk), ...storeEnhancers)), 9 | }; 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | cache: 4 | npm: true 5 | directories: 6 | - node_modules 7 | - examples/async-effects-example/node_modules 8 | - examples/redux-observable-example/node_modules 9 | - examples/redux-saga-example/node_modules 10 | - examples/redux-thunk-example/node_modules 11 | 12 | install: 13 | - npm install 14 | 15 | script: 16 | - npm run build 17 | -------------------------------------------------------------------------------- /examples/redux-saga-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /examples/redux-observable-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | node_modules 23 | build 24 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":dependencyDashboard", 5 | ":semanticPrefixFixDepsChoreOthers", 6 | "group:monorepos", 7 | "group:recommended", 8 | "replacements:all", 9 | "workarounds:all" 10 | ], 11 | "additionalBranchPrefix": "{{packageFileDir}}-", 12 | "dependencyDashboard": true, 13 | "automerge": true, 14 | "automergeType": "branch" 15 | } 16 | -------------------------------------------------------------------------------- /src/_printTable.ts: -------------------------------------------------------------------------------- 1 | import { sprintf } from "sprintf-js"; 2 | import { trySerialize } from "./_trySerialize"; 3 | 4 | const printTable = actions => { 5 | const longestMessage: number = actions.reduce( 6 | (last, action) => Math.max(last, action.type.length), 7 | 0 8 | ); 9 | 10 | const printAction = ({ type, ...props }) => 11 | sprintf(`\t%${longestMessage + 3}s\t%s`, type, trySerialize(props)); 12 | 13 | return `${sprintf(`\t%${longestMessage + 3}s\t%s`, "TYPE", "PROPS")} 14 | ${actions.map(printAction).join("\n")}`; 15 | }; 16 | 17 | export { printTable }; 18 | 19 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/store.test.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from './store'; 2 | 3 | it('allows to increase the counter locally', () => { 4 | const store = configureStore(); 5 | expect(store.getState().counter).toEqual(0); 6 | store.dispatch({ type: 'INCREASE_COUNTER_LOCALLY' }); 7 | expect(store.getState().counter).toEqual(1); 8 | }); 9 | 10 | it('allows to set the counter from remote', () => { 11 | const store = configureStore(); 12 | expect(store.getState().counter).toEqual(0); 13 | store.dispatch({ type: 'SET_COUNTER_FROM_REMOTE', counter: 3 }); 14 | expect(store.getState().counter).toEqual(3); 15 | }); 16 | -------------------------------------------------------------------------------- /examples/async-effects-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-effects-example", 3 | "version": "1.0.0", 4 | "description": "A simple example of how to use expect-redux", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "author": "Raimo Radczewski ", 10 | "license": "MIT", 11 | "dependencies": { 12 | "redux": "^5.0.1", 13 | "redux-thunk": "^3.1.0" 14 | }, 15 | "jest": { 16 | "setupFiles": [ 17 | "/src/setupTests.js" 18 | ] 19 | }, 20 | "devDependencies": { 21 | "expect-redux": "file:../../", 22 | "jest": "^30.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | .idea 35 | docs 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | .idea 35 | lib 36 | dist 37 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/Api.js: -------------------------------------------------------------------------------- 1 | const authorize = (user, password) => 2 | fetch("http://our-backend.local/authorize", { 3 | method: "POST", 4 | headers: { 5 | "Content-Type": "application/json" 6 | }, 7 | body: JSON.stringify({ 8 | user, 9 | password 10 | }) 11 | }) 12 | .then(result => (result.ok ? result.json() : Promise.reject(result))) 13 | .then(body => body.token); 14 | 15 | const storeItem = items => 16 | Object.keys(items).forEach(key => 17 | window.localStorage.setItem(key, items[key]) 18 | ); 19 | 20 | const clearItem = key => window.localStorage.removeItem(key); 21 | 22 | export default { 23 | authorize, 24 | storeItem, 25 | clearItem 26 | }; 27 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/Api.js: -------------------------------------------------------------------------------- 1 | const authorize = (user, password) => 2 | fetch("http://our-backend.local/authorize", { 3 | method: "POST", 4 | headers: { 5 | "Content-Type": "application/json" 6 | }, 7 | body: JSON.stringify({ 8 | user, 9 | password 10 | }) 11 | }) 12 | .then(result => (result.ok ? result.json() : Promise.reject(result))) 13 | .then(body => body.token); 14 | 15 | const storeItem = items => 16 | Object.keys(items).forEach(key => 17 | window.localStorage.setItem(key, items[key]) 18 | ); 19 | 20 | const clearItem = key => window.localStorage.removeItem(key); 21 | 22 | export default { 23 | authorize, 24 | storeItem, 25 | clearItem 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, workflow_dispatch] 4 | 5 | jobs: 6 | get-lts: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - id: get 10 | uses: msimerson/node-lts-versions@v1 11 | outputs: 12 | active: ${{ steps.get.outputs.active }} 13 | lts: ${{ steps.get.outputs.lts }} 14 | min: ${{ steps.get.outputs.min }} 15 | 16 | build: 17 | runs-on: ubuntu-latest 18 | needs: get-lts 19 | strategy: 20 | matrix: 21 | node-version: ${{ fromJson(needs.get-lts.outputs.active) }} 22 | fail-fast: false 23 | steps: 24 | - uses: actions/setup-node@v6 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - uses: actions/checkout@v6 28 | - run: npm ci 29 | - run: npm run build -------------------------------------------------------------------------------- /examples/redux-saga-example/src/loginFlow.saga.js: -------------------------------------------------------------------------------- 1 | // Taken from https://redux-saga.js.org/docs/advanced/NonBlockingCalls.html 2 | import { fork, call, take, put } from "redux-saga/effects"; 3 | import Api from "./Api"; 4 | 5 | function* authorize(user, password) { 6 | try { 7 | const token = yield call(Api.authorize, user, password); 8 | yield put({ type: "LOGIN_SUCCESS", token }); 9 | yield call(Api.storeItem, { token }); 10 | } catch (error) { 11 | yield put({ type: "LOGIN_ERROR", error }); 12 | } 13 | } 14 | 15 | function* loginFlow() { 16 | while (true) { 17 | const { user, password } = yield take("LOGIN_REQUEST"); 18 | yield fork(authorize, user, password); 19 | yield take(["LOGOUT", "LOGIN_ERROR"]); 20 | yield call(Api.clearItem, "token"); 21 | } 22 | } 23 | 24 | export { loginFlow }; 25 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/App.t-effect.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act, screen, fireEvent, render } from "@testing-library/react"; 3 | import { expectRedux, storeSpy } from "expect-redux"; 4 | 5 | import App from "./App"; 6 | import { configureStore } from "./store"; 7 | 8 | it("will increase the counter", async () => { 9 | const services = { 10 | counterService: () => 11 | new Promise((resolve) => setTimeout(() => resolve(2), 0)), 12 | }; 13 | const store = configureStore(services, [storeSpy]); 14 | render(); 15 | 16 | await act(async () => { 17 | fireEvent.click(screen.getByTestId("increase-remotely")); 18 | await new Promise((resolve) => setTimeout(resolve)); 19 | }); 20 | 21 | return expectRedux(store) 22 | .toDispatchAnAction() 23 | .matching({ type: "SET_COUNTER_FROM_REMOTE", counter: 2 }); 24 | }); 25 | -------------------------------------------------------------------------------- /examples/redux-saga-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-saga-expect-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^19.0.0", 7 | "react-dom": "^19.0.0", 8 | "react-redux": "^9.1.2", 9 | "react-scripts": "^5.0.1", 10 | "redux": "^5.0.1", 11 | "redux-saga": "^1.3.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ], 28 | "devDependencies": { 29 | "@testing-library/dom": "^10.4.0", 30 | "@testing-library/react": "^16.0.1", 31 | "expect-redux": "file:../../" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-thunk-expect-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^19.0.0", 7 | "react-dom": "^19.0.0", 8 | "react-redux": "^9.1.2", 9 | "react-scripts": "^5.0.1", 10 | "redux": "^5.0.1", 11 | "redux-thunk": "^3.1.0" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ], 28 | "devDependencies": { 29 | "@testing-library/dom": "^10.4.0", 30 | "@testing-library/react": "^16.0.1", 31 | "expect-redux": "file:../../" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/redux-observable-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-observable-expect-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^19.0.0", 7 | "react-dom": "^19.0.0", 8 | "react-redux": "^9.1.2", 9 | "react-scripts": "^5.0.1", 10 | "redux": "^5.0.1", 11 | "redux-observable": "^3.0.0-rc.2", 12 | "rxjs": "^7.8.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": [ 24 | ">0.2%", 25 | "not dead", 26 | "not ie <= 11", 27 | "not op_mini all" 28 | ], 29 | "devDependencies": { 30 | "@testing-library/dom": "^10.4.0", 31 | "@testing-library/react": "^16.0.1", 32 | "expect-redux": "file:../../" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/login.epic.js: -------------------------------------------------------------------------------- 1 | import { combineEpics, ofType } from "redux-observable"; 2 | import { of, from } from "rxjs"; 3 | import { map, ignoreElements, catchError, mergeMap, mapTo, tap } from "rxjs/operators"; 4 | import Api from "./Api"; 5 | 6 | const loginEpic = action$ => 7 | action$.pipe( 8 | ofType("LOGIN_REQUEST"), 9 | mergeMap(({ user, password }) => 10 | from(Api.authorize(user, password)).pipe( 11 | tap(token => Api.storeItem({ token })), 12 | map(token => ({ type: "LOGIN_SUCCESS", token })) 13 | ) 14 | ), 15 | catchError(error => 16 | of({ type: "LOGIN_ERROR", error }).pipe(tap(_ => Api.clearItem("token"))) 17 | ) 18 | ); 19 | 20 | const logoutEpic = action$ => 21 | action$.pipe( 22 | ofType("LOGOUT"), 23 | tap(() => Api.clearItem("token")), 24 | ignoreElements() 25 | ); 26 | 27 | export const authEpics = combineEpics(loginEpic, logoutEpic); 28 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import { 5 | increaseCounterLocallyActionCreator, 6 | increaseCounterRemotely, 7 | } from "./store"; 8 | 9 | const App = ({ counter, increaseLocally, increaseRemotely }) => ( 10 |
11 | Current counter value is {counter}. Want to 12 | increase it 13 | 16 | {" or "} 17 | 20 |
21 | ); 22 | 23 | const mapDispatchToProps = (dispatch) => ({ 24 | increaseLocally: () => dispatch(increaseCounterLocallyActionCreator), 25 | increaseRemotely: () => dispatch(increaseCounterRemotely), 26 | }); 27 | 28 | export default connect(({ counter }) => ({ counter }), mapDispatchToProps)(App); 29 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import createSagaMiddleware from "redux-saga"; 3 | 4 | import { loginFlow } from "./loginFlow.saga"; 5 | 6 | const reducer = ( 7 | state = { isLoggedIn: false, loginError: undefined }, 8 | action 9 | ) => { 10 | if (action.type === "LOGIN_SUCCESS") { 11 | return { ...state, isLoggedIn: true }; 12 | } 13 | if (action.type === "LOGIN_ERROR") { 14 | return { ...state, loginError: action.error }; 15 | } 16 | if (action.type === "LOGOUT") { 17 | return { isLoggedIn: false, loginError: undefined }; 18 | } 19 | return state; 20 | }; 21 | 22 | export const configureStore = (storeEnhancers = []) => { 23 | const sagaMiddleware = createSagaMiddleware(); 24 | const store = createStore( 25 | reducer, 26 | compose(...[applyMiddleware(sagaMiddleware), ...storeEnhancers]) 27 | ); 28 | 29 | sagaMiddleware.run(loginFlow); 30 | 31 | return store; 32 | }; 33 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import { createEpicMiddleware } from "redux-observable"; 3 | 4 | import { authEpics } from "./login.epic"; 5 | 6 | const reducer = ( 7 | state = { isLoggedIn: false, loginError: undefined }, 8 | action 9 | ) => { 10 | if (action.type === "LOGIN_SUCCESS") { 11 | return { ...state, isLoggedIn: true }; 12 | } 13 | if (action.type === "LOGIN_ERROR") { 14 | return { ...state, loginError: action.error }; 15 | } 16 | if (action.type === "LOGOUT") { 17 | return { isLoggedIn: false, loginError: undefined }; 18 | } 19 | return state; 20 | }; 21 | 22 | export const configureStore = (storeEnhancers = []) => { 23 | const epicMiddleware = createEpicMiddleware(); 24 | const store = createStore( 25 | reducer, 26 | compose(...[applyMiddleware(epicMiddleware), ...storeEnhancers]) 27 | ); 28 | 29 | epicMiddleware.run(authEpics); 30 | 31 | return store; 32 | }; 33 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import { withExtraArgument } from "redux-thunk"; 3 | 4 | const reducer = (state = { counter: 0 }, action) => { 5 | if (action.type === "INCREASE_COUNTER_LOCALLY") { 6 | return { counter: state.counter + 1 }; 7 | } 8 | if (action.type === "SET_COUNTER_FROM_REMOTE") { 9 | return { counter: action.counter }; 10 | } 11 | return state; 12 | }; 13 | 14 | export const configureStore = (services = {}, storeEnhancers = []) => 15 | createStore( 16 | reducer, 17 | compose( 18 | ...[applyMiddleware(withExtraArgument(services)), ...storeEnhancers] 19 | ) 20 | ); 21 | 22 | export const increaseCounterLocallyActionCreator = (dispatch) => 23 | dispatch({ type: "INCREASE_COUNTER_LOCALLY" }); 24 | 25 | export const increaseCounterRemotely = async ( 26 | dispatch, 27 | getState, 28 | { counterService } 29 | ) => { 30 | const counter = await counterService(); 31 | dispatch({ type: "SET_COUNTER_FROM_REMOTE", counter: counter }); 32 | }; 33 | -------------------------------------------------------------------------------- /examples/async-effects-example/src/store.test.js: -------------------------------------------------------------------------------- 1 | const { expectRedux, storeSpy } = require("expect-redux"); 2 | const { configureStore } = require("./store"); 3 | 4 | const storeForTest = () => configureStore([storeSpy]); 5 | 6 | const effect = dispatch => { 7 | dispatch({ type: "REQUEST_STARTED" }); 8 | 9 | return fetch("/api/count").then(result => { 10 | if (result.ok) { 11 | return result.json().then(jsonBody => { 12 | dispatch({ 13 | type: "REQUEST_SUCCESS", 14 | payload: jsonBody.count 15 | }); 16 | }); 17 | } 18 | }); 19 | }; 20 | 21 | describe("service", () => { 22 | it("retrieves the current count", () => { 23 | const store = storeForTest(); 24 | 25 | global.fetch = jest.fn().mockResolvedValue({ 26 | ok: true, 27 | json: jest.fn().mockResolvedValue({ count: 42 }) 28 | }); 29 | 30 | store.dispatch(effect); 31 | 32 | return expectRedux(store) 33 | .toDispatchAnAction() 34 | .matching({ 35 | type: "REQUEST_SUCCESS", 36 | payload: 42 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Raimo Radczewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/README.md: -------------------------------------------------------------------------------- 1 | # Example React App tested with `expect-redux` 2 | 3 | Scaffolded with [Create React App](https://github.com/facebookincubator/create-react-app). 4 | 5 | This example app illustrates the possible applications for tests using `expect-redux`. 6 | 7 | The app uses [`redux-thunk`](https://github.com/gaearon/redux-thunk) to illustrate the need of testing asynchronous effects. Its [`.withExtraArgument`](https://github.com/gaearon/redux-thunk#injecting-a-custom-argument) is used as a cheap dependency-injection mechanism so we can provide a fake implementation [in the effect test](src/App.t-effect.test.js). 8 | 9 | The test [`App.t-effect.test.js`](src/App.t-effect.test.js) shows how the click of a button might trigger a cascade of things, yet we only assert the final action being dispatched to the store. 10 | 11 | The other test, [`App.t-simple.test.js`](src/App.t-simple.test.js), illustrates how `expect-redux` can be used to wait for changes in the app and then assert the state [of redux](src/App.t-simple.test.js#L19-L31) or [of the rendered component](src/App.t-simple.test.js#L33-L45). 12 | 13 | ## Running 14 | 15 | ```shell 16 | npm install 17 | npm test 18 | # npm start (tests are running, that's enough isn't it) 19 | ``` 20 | -------------------------------------------------------------------------------- /src/storeSpy.ts: -------------------------------------------------------------------------------- 1 | import { Action, Store, StoreEnhancer } from "redux"; 2 | import { ActionMatcher } from "./action_matcher"; 3 | 4 | type SpyExtension = { 5 | actions: Array; 6 | registerMatcher: (matcher: ActionMatcher) => void; 7 | unregisterMatcher: (matcher: ActionMatcher) => void; 8 | }; 9 | 10 | export type StoreWithSpy> = Store & SpyExtension; 11 | 12 | const storeEnhancer: StoreEnhancer = nextCreateStore => ( 13 | reducer, 14 | initialState 15 | ) => { 16 | const actions: Array = []; 17 | const matchers = new Set(); 18 | 19 | const recorder = (state, action) => { 20 | actions.push(action); 21 | matchers.forEach(matcher => matcher.test(action)); 22 | return reducer(state, action); 23 | }; 24 | 25 | const store = nextCreateStore(recorder, initialState); 26 | 27 | const registerMatcher = matcher => { 28 | actions.forEach(action => matcher.test(action)); 29 | matchers.add(matcher); 30 | }; 31 | 32 | const unregisterMatcher = matcher => { 33 | matchers.delete(matcher); 34 | }; 35 | 36 | return { 37 | ...store, 38 | actions, 39 | registerMatcher, 40 | unregisterMatcher 41 | }; 42 | }; 43 | 44 | export default storeEnhancer; 45 | -------------------------------------------------------------------------------- /src/expect.ts: -------------------------------------------------------------------------------- 1 | import { ActionMatcher } from "./action_matcher"; 2 | import { NotActionMatcher } from "./not_action_matcher"; 3 | import { StateMatcher } from "./state_matcher"; 4 | import { StoreWithSpy } from "./storeSpy"; 5 | 6 | const expectRedux = (store: StoreWithSpy) => { 7 | const timeout = expectRedux.options.betterErrorMessagesTimeout; 8 | 9 | return { 10 | toHaveState: (): StateMatcher => StateMatcher.empty(store), 11 | toDispatchAnAction: (): ActionMatcher => 12 | ActionMatcher.empty(store, timeout), 13 | toNotDispatchAnAction: (dispatchTimeout: number): ActionMatcher => 14 | NotActionMatcher.empty(store, dispatchTimeout) 15 | }; 16 | }; 17 | 18 | type BetterErrorMessagesOptions = { 19 | timeout: number; 20 | }; 21 | 22 | /* Deprecated, use expectRedux.configure */ 23 | expectRedux.enableBetterErrorMessages = ( 24 | options: boolean | BetterErrorMessagesOptions 25 | ) => { 26 | console.warn( 27 | "expectRedux.enableBetterErrorMessages is deprecated. Replace with `expectRedux.configure({ betterErrorMessagesTimeout: 100 })`" 28 | ); 29 | expectRedux.configure({ 30 | betterErrorMessagesTimeout: 31 | typeof options === "boolean" 32 | ? false 33 | : (options).timeout 34 | }); 35 | }; 36 | 37 | type Options = { 38 | betterErrorMessagesTimeout: number | false; 39 | }; 40 | expectRedux.options = { betterErrorMessagesTimeout: false }; 41 | 42 | expectRedux.configure = (options: Options) => 43 | (expectRedux.options = { ...expectRedux.options, ...options }); 44 | 45 | export default expectRedux; 46 | -------------------------------------------------------------------------------- /test/state_matchers.test.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | import { storeSpy } from "../src"; 3 | import { StateMatcher } from "../src/state_matcher"; 4 | 5 | describe("StateMatcher", () => { 6 | it("resolves if the state already matches", () => { 7 | const state = { foo: "before" }; 8 | 9 | const store = createStore(state => state, state, storeSpy); 10 | const matcher = StateMatcher.empty(store).matching({ foo: "before" }); 11 | 12 | return matcher; 13 | }); 14 | 15 | it("resolves if the state eventually matches", () => { 16 | const state = { foo: "before" }; 17 | 18 | const store = createStore( 19 | (state, action) => { 20 | if (action.type === "BE_AFTER") { 21 | return { foo: "after" }; 22 | } 23 | return state; 24 | }, 25 | state, 26 | storeSpy 27 | ); 28 | 29 | const matcher = StateMatcher.empty(store).matching({ foo: "after" }); 30 | setTimeout(() => store.dispatch({ type: "BE_AFTER" }), 0); 31 | 32 | return matcher; 33 | }); 34 | 35 | it("allows to select a subtree of the state", () => { 36 | const state = { foo: { bar: "before" } }; 37 | 38 | const store = createStore( 39 | (state, action) => { 40 | if (action.type === "BE_AFTER") { 41 | return { foo: { bar: "after" } }; 42 | } 43 | return state; 44 | }, 45 | state, 46 | storeSpy 47 | ); 48 | 49 | const matcher = StateMatcher.empty(store) 50 | .withSubtree((state: any) => state.foo) 51 | .matching({ bar: "after" }); 52 | 53 | setTimeout(() => store.dispatch({ type: "BE_AFTER" }), 0); 54 | 55 | return matcher; 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /examples/redux-thunk-example/src/App.t-simple.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act, render, screen, fireEvent } from "@testing-library/react"; 3 | import { expectRedux, storeSpy } from "expect-redux"; 4 | 5 | import App from "./App"; 6 | import { configureStore } from "./store"; 7 | 8 | it("will increase the counter", () => { 9 | const store = configureStore({}, [storeSpy]); 10 | render(); 11 | 12 | act(() => { 13 | fireEvent.click(screen.getByTestId("increase-locally")); 14 | }); 15 | 16 | return expectRedux(store) 17 | .toDispatchAnAction() 18 | .matching({ type: "INCREASE_COUNTER_LOCALLY" }); 19 | }); 20 | 21 | it("can be verified what the state of the store is afterwards", async () => { 22 | const store = configureStore({}, [storeSpy]); 23 | render(); 24 | 25 | expect(store.getState().counter).toEqual(0); 26 | act(() => { 27 | fireEvent.click(screen.getByTestId("increase-locally")); 28 | }); 29 | 30 | await expectRedux(store) 31 | .toDispatchAnAction() 32 | .matching({ type: "INCREASE_COUNTER_LOCALLY" }); 33 | 34 | expect(store.getState().counter).toEqual(1); 35 | }); 36 | 37 | it("can be verified what the state of the component is afterwards", async () => { 38 | const store = configureStore({}, [storeSpy]); 39 | render(); 40 | 41 | expect(screen.getByTestId("counter-value").textContent).toEqual("0"); 42 | 43 | act(() => { 44 | fireEvent.click(screen.getByTestId("increase-locally")); 45 | }); 46 | 47 | await expectRedux(store) 48 | .toDispatchAnAction() 49 | .matching({ type: "INCREASE_COUNTER_LOCALLY" }); 50 | 51 | expect(screen.getByTestId("counter-value").textContent).toEqual("1"); 52 | }); 53 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | class App extends React.Component { 5 | state = { 6 | username: "", 7 | password: "" 8 | }; 9 | 10 | tryLogin = () => this.props.loginRequest(this.state.username, this.state.password); 11 | 12 | render() { 13 | const { isLoggedIn, loginError } = this.props; 14 | const { username, password } = this.state; 15 | return ( 16 |
17 | {isLoggedIn ? ( 18 |
19 |

Thanks for logging in.

20 | 21 |
22 | ) : ( 23 |
24 | {loginError &&

Invalid credentials. Please try again

} 25 |

Please login below

26 |
27 | this.setState({ username: e.target.value })} 32 | /> 33 | this.setState({ password: e.target.value })} 38 | /> 39 | 40 |
41 |
42 | )} 43 |
44 | ); 45 | } 46 | } 47 | 48 | const mapStateToProps = ({ isLoggedIn, loginError }) => ({ isLoggedIn, loginError }); 49 | const mapDispatchToProps = { 50 | loginRequest: (user, password) => ({ type: "LOGIN_REQUEST", user, password }), 51 | logout: () => ({ type: "LOGOUT" }) 52 | }; 53 | 54 | export default connect( 55 | mapStateToProps, 56 | mapDispatchToProps 57 | )(App); 58 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | class App extends React.Component { 5 | state = { 6 | username: "", 7 | password: "" 8 | }; 9 | 10 | tryLogin = () => this.props.loginRequest(this.state.username, this.state.password); 11 | 12 | render() { 13 | const { isLoggedIn, loginError } = this.props; 14 | const { username, password } = this.state; 15 | return ( 16 |
17 | {isLoggedIn ? ( 18 |
19 |

Thanks for logging in.

20 | 21 |
22 | ) : ( 23 |
24 | {loginError &&

Invalid credentials. Please try again

} 25 |

Please login below

26 |
27 | this.setState({ username: e.target.value })} 33 | /> 34 | this.setState({ password: e.target.value })} 40 | /> 41 | 42 |
43 |
44 | )} 45 |
46 | ); 47 | } 48 | } 49 | 50 | const mapStateToProps = ({ isLoggedIn, loginError }) => ({ isLoggedIn, loginError }); 51 | const mapDispatchToProps = { 52 | loginRequest: (user, password) => ({ type: "LOGIN_REQUEST", user, password }), 53 | logout: () => ({ type: "LOGOUT" }) 54 | }; 55 | 56 | export default connect( 57 | mapStateToProps, 58 | mapDispatchToProps 59 | )(App); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expect-redux", 3 | "version": "5.0.3", 4 | "description": "Async expect matchers for redux", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.js", 11 | "import": "./dist/index.mjs", 12 | "types": "./dist/index.d.ts" 13 | } 14 | }, 15 | "scripts": { 16 | "build": "run-s test build:type-check build:js prune examples:install examples:test", 17 | "build:js": "tsup src/index.ts --target es2020 --format cjs,esm --dts --clean", 18 | "build:type-check": "tsc --noEmit", 19 | "prune": "npm prune --production", 20 | "test": "jest", 21 | "examples:install": "set -o errexit; for i in $(ls examples/); do (cd examples/$i; npm install); done", 22 | "examples:test": "set -o errexit; for i in $(ls examples/); do (cd examples/$i; CI=1 npm test); done" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/rradczewski/expect-redux.git" 27 | }, 28 | "keywords": [ 29 | "jest", 30 | "redux", 31 | "expect" 32 | ], 33 | "files": [ 34 | "dist" 35 | ], 36 | "author": "Raimo Radczewski ", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/rradczewski/expect-redux/issues" 40 | }, 41 | "jest": { 42 | "testEnvironment": "node", 43 | "testPathIgnorePatterns": [ 44 | "/node_modules/", 45 | "/examples/", 46 | "/dist" 47 | ], 48 | "transform": { 49 | "^.+.[tj]sx?$": [ 50 | "ts-jest", 51 | {} 52 | ] 53 | } 54 | }, 55 | "homepage": "https://github.com/rradczewski/expect-redux#readme", 56 | "devDependencies": { 57 | "@types/jest": "^30.0.0", 58 | "@types/ramda": "^0.31.0", 59 | "jest": "^30.0.0", 60 | "nodemon": "^3.0.0", 61 | "npm-run-all2": "^8.0.0", 62 | "redux": "^5.0.0", 63 | "rimraf": "^6.0.0", 64 | "ts-jest": "^29.0.0", 65 | "tsup": "^8.3.5", 66 | "typescript": "^5.0.0" 67 | }, 68 | "dependencies": { 69 | "ramda": "^0.32.0", 70 | "sprintf-js": "^1.1.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/not_action_matcher.ts: -------------------------------------------------------------------------------- 1 | import { allPass } from "ramda"; 2 | import { ActionMatcher } from "./action_matcher"; 3 | import { StoreWithSpy } from "./storeSpy"; 4 | import { printTable } from "./_printTable"; 5 | 6 | class NotActionMatcher extends ActionMatcher { 7 | static empty: (...args: any) => ActionMatcher = ( 8 | store: StoreWithSpy, 9 | timeout: number 10 | ): NotActionMatcher => new EmptyNotActionMatcher(store, timeout); 11 | 12 | constructor( 13 | predicate: (action: any) => boolean, 14 | errorMessage: string, 15 | store: StoreWithSpy, 16 | timeout: number 17 | ) { 18 | super(predicate, errorMessage, store, timeout); 19 | } 20 | 21 | onTimeout() { 22 | this.resolve(); 23 | } 24 | 25 | test(action: any): void { 26 | if (this.predicate(action)) { 27 | this.fail(); 28 | } 29 | } 30 | 31 | fail(): void { 32 | const actions = this.store.actions; 33 | 34 | const message = `Expected action ${ 35 | this.errorMessage 36 | } not to be dispatched to store, but was dispatched. 37 | 38 | The following actions got dispatched to the store (${actions.length}): 39 | 40 | ${printTable(actions)}\n`; 41 | 42 | const error = new Error(message); 43 | error.stack = error.name + ": " + error.message; 44 | this.reject(error); 45 | } 46 | 47 | and( 48 | otherPredicate: (action: any) => boolean, 49 | otherErrorMessage: string 50 | ): NotActionMatcher { 51 | this.destroy(); 52 | 53 | return new NotActionMatcher( 54 | allPass([this.predicate, otherPredicate]), 55 | `${this.errorMessage} and ${otherErrorMessage}`, 56 | this.store, 57 | this.timeout 58 | ); 59 | } 60 | } 61 | 62 | class EmptyNotActionMatcher extends NotActionMatcher { 63 | constructor(store: StoreWithSpy, timeout: number) { 64 | super(() => false, "", store, timeout); 65 | } 66 | 67 | and( 68 | otherPredicate: (action: any) => boolean, 69 | otherErrorMessage: string 70 | ): NotActionMatcher { 71 | this.store.unregisterMatcher(this); 72 | 73 | return new NotActionMatcher( 74 | otherPredicate, 75 | otherErrorMessage, 76 | this.store, 77 | this.timeout 78 | ); 79 | } 80 | } 81 | 82 | export { NotActionMatcher }; 83 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, act, fireEvent } from "@testing-library/react"; 3 | import { expectRedux, storeSpy } from "expect-redux"; 4 | 5 | import App from "./App"; 6 | import Api from "./Api"; 7 | import { configureStore } from "./store"; 8 | 9 | jest.mock("./Api"); 10 | 11 | describe("App Component", () => { 12 | beforeEach(() => jest.resetAllMocks()); 13 | 14 | let store; 15 | beforeEach(() => { 16 | store = configureStore([storeSpy]); 17 | render(); 18 | }); 19 | 20 | const fillInUserName = (value) => 21 | act(async () => 22 | fireEvent.change(screen.getByTestId("username"), { target: { value } }) 23 | ); 24 | 25 | const fillInPassword = (value) => 26 | act(async () => 27 | fireEvent.change(screen.getByTestId("password"), { target: { value } }) 28 | ); 29 | 30 | const submitForm = () => 31 | act(async () => fireEvent.submit(screen.getByTestId("login"))); 32 | 33 | describe("logging in", () => { 34 | it("will work with correct credentials", async () => { 35 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 36 | 37 | await fillInUserName("MY_USER"); 38 | await fillInPassword("MY_PASSWORD"); 39 | await submitForm(); 40 | 41 | await expectRedux(store).toDispatchAnAction().matching({ 42 | type: "LOGIN_REQUEST", 43 | user: "MY_USER", 44 | password: "MY_PASSWORD", 45 | }); 46 | 47 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_SUCCESS"); 48 | 49 | expect(document.body.textContent).toContain("Thanks for logging in."); 50 | }); 51 | 52 | it("won't work if you supply bad credentials", async () => { 53 | Api.authorize.mockRejectedValue("INVALID CREDENTIALS"); 54 | 55 | await fillInUserName("MY_USER"); 56 | await fillInPassword("MY_WRONG_PASSWORD"); 57 | await submitForm(); 58 | 59 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_ERROR"); 60 | 61 | expect(document.body.textContent).toContain("Invalid credentials"); 62 | }); 63 | }); 64 | 65 | describe("Logging out", () => { 66 | beforeEach(async () => { 67 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 68 | 69 | await fillInUserName("MY_USER"); 70 | await fillInPassword("MY_PASSWORD"); 71 | await submitForm(); 72 | 73 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_SUCCESS"); 74 | }); 75 | 76 | it("works when logged in", async () => { 77 | act(() => { 78 | fireEvent.click(screen.getByTestId("logout"), { button: 0 }); 79 | }); 80 | 81 | await expectRedux(store).toDispatchAnAction().ofType("LOGOUT"); 82 | 83 | expect(document.body.textContent).toContain("Please login below"); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, act, screen, fireEvent } from "@testing-library/react"; 2 | import React from "react"; 3 | import { expectRedux, storeSpy } from "expect-redux"; 4 | 5 | import App from "./App"; 6 | import Api from "./Api"; 7 | import { configureStore } from "./store"; 8 | 9 | jest.mock("./Api"); 10 | 11 | describe("App Component", () => { 12 | beforeEach(() => jest.resetAllMocks()); 13 | 14 | let store, component; 15 | beforeEach(() => { 16 | store = configureStore([storeSpy]); 17 | component = render(); 18 | }); 19 | 20 | const fillInUserName = (value) => 21 | act(async () => 22 | fireEvent.change(screen.getByTestId("username"), { target: { value } }) 23 | ); 24 | 25 | const fillInPassword = (value) => 26 | act(async () => 27 | fireEvent.change(screen.getByTestId("password"), { target: { value } }) 28 | ); 29 | 30 | const submitForm = () => 31 | act(async () => fireEvent.submit(screen.getByTestId("login"))); 32 | 33 | describe("logging in", () => { 34 | it("will work with correct credentials", async () => { 35 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 36 | 37 | await fillInUserName("MY_USER"); 38 | await fillInPassword("MY_PASSWORD"); 39 | await submitForm(); 40 | 41 | await expectRedux(store).toDispatchAnAction().matching({ 42 | type: "LOGIN_REQUEST", 43 | user: "MY_USER", 44 | password: "MY_PASSWORD", 45 | }); 46 | 47 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_SUCCESS"); 48 | 49 | expect(document.body.textContent).toContain("Thanks for logging in."); 50 | }); 51 | 52 | it("won't work if you supply bad credentials", async () => { 53 | Api.authorize.mockRejectedValue("INVALID CREDENTIALS"); 54 | 55 | await fillInUserName("MY_USER"); 56 | await fillInPassword("MY_WRONG_PASSWORD"); 57 | await submitForm(); 58 | 59 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_ERROR"); 60 | 61 | expect(document.body.textContent).toContain("Invalid credentials"); 62 | }); 63 | }); 64 | 65 | describe("Logging out", () => { 66 | beforeEach(async () => { 67 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 68 | 69 | await fillInUserName("MY_USER"); 70 | await fillInPassword("MY_PASSWORD"); 71 | await submitForm(); 72 | 73 | await expectRedux(store).toDispatchAnAction().ofType("LOGIN_SUCCESS"); 74 | }); 75 | 76 | it("works when logged in", async () => { 77 | act(() => { 78 | fireEvent.click(screen.getByTestId("logout")); 79 | }); 80 | 81 | await expectRedux(store).toDispatchAnAction().ofType("LOGOUT"); 82 | 83 | expect(document.body.textContent).toContain("Please login below"); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/action_matchers.test.ts: -------------------------------------------------------------------------------- 1 | import { ActionMatcher } from "../src/action_matcher"; 2 | import { StoreWithSpy } from "../src/storeSpy"; 3 | import { assertPromiseDidNotResolve } from "./_assertPromiseDidNotResolve"; 4 | 5 | describe("ActionMatcher", () => { 6 | const storeForTest = () => >({ 7 | registerMatcher: jest.fn(), 8 | unregisterMatcher: jest.fn(), 9 | actions: [] 10 | }); 11 | 12 | it("should unregister a matcher when it matches", () => { 13 | const store = storeForTest(); 14 | 15 | const matcher = new ActionMatcher( 16 | action => action.attrA === "attrA", 17 | "attrA equals attrA", 18 | store, 19 | false 20 | ); 21 | 22 | matcher.test({ attrA: "attrA" }); 23 | 24 | expect(store.unregisterMatcher).toHaveBeenCalledWith(matcher); 25 | }); 26 | 27 | describe("constructor", () => { 28 | it("should register the matcher with the store", done => { 29 | const store = storeForTest(); 30 | const matcher = new ActionMatcher(action => true, "woop", store, false); 31 | 32 | setTimeout(() => { 33 | expect(store.registerMatcher).toHaveBeenCalledWith(matcher); 34 | done(); 35 | }, 0); 36 | }); 37 | }); 38 | 39 | describe(".and(ActionMatcher)", () => { 40 | const dummyStore = storeForTest(); 41 | 42 | const matcherA = new ActionMatcher( 43 | action => action.attrA === "attrA", 44 | "attrA equals attrA", 45 | dummyStore, 46 | false 47 | ); 48 | const otherPredicate = action => action.attrB === "attrB"; 49 | const otherErrorMessage = "attrB equals attrB"; 50 | 51 | it("should only resolve if both predicates match", () => { 52 | const matcherBoth = matcherA.and(otherPredicate, otherErrorMessage); 53 | matcherBoth.test({ attrA: "attrA", attrB: "attrB" }); 54 | return matcherBoth; 55 | }); 56 | 57 | it("should not resolve if the first one does not resolve", () => { 58 | const matcherBoth = matcherA.and(otherPredicate, otherErrorMessage); 59 | matcherBoth.test({ attrA: "NOT_ATTR_A", attrB: "attrB" }); 60 | return assertPromiseDidNotResolve(matcherBoth); 61 | }); 62 | 63 | it("should not resolve if the second one does not resolve", () => { 64 | const matcherBoth = matcherA.and(otherPredicate, otherErrorMessage); 65 | matcherBoth.test({ attrA: "attrA", attrB: "NOT_ATTR_B" }); 66 | return assertPromiseDidNotResolve(matcherBoth); 67 | }); 68 | 69 | it("should concatenate the error message", () => { 70 | const matcherBoth = matcherA.and(otherPredicate, otherErrorMessage); 71 | expect(matcherBoth.errorMessage).toEqual( 72 | "attrA equals attrA and attrB equals attrB" 73 | ); 74 | }); 75 | 76 | it("should unregister both matchers and register the new one", done => { 77 | const store = >({ 78 | registerMatcher: jest.fn(), 79 | unregisterMatcher: jest.fn() 80 | }); 81 | 82 | const matcherA = new ActionMatcher( 83 | action => action.attrA === "attrA", 84 | "attrA equals attrA", 85 | store, 86 | false 87 | ); 88 | 89 | const matcherBoth = matcherA.and(otherPredicate, otherErrorMessage); 90 | 91 | setTimeout(() => { 92 | expect(store.registerMatcher).toHaveBeenCalledWith(matcherBoth); 93 | expect(store.unregisterMatcher).toHaveBeenCalledWith(matcherA); 94 | done(); 95 | }, 0); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/betterErrorMessagesTimeout.test.ts: -------------------------------------------------------------------------------- 1 | import { identity } from "ramda"; 2 | import { createStore } from "redux"; 3 | import { expectRedux, storeSpy } from "../src"; 4 | 5 | describe("betterErrorMessagesTimeout", () => { 6 | beforeEach(() => { 7 | expectRedux.configure({ betterErrorMessagesTimeout: 1 }); 8 | }); 9 | afterEach(() => { 10 | expectRedux.configure({ betterErrorMessagesTimeout: false }); 11 | }); 12 | 13 | describe("on actions", () => { 14 | it("should report all dispatched actions in a brief format", async () => { 15 | const store = createStore(identity, {}, storeSpy); 16 | 17 | store.dispatch({ type: "foo", value: "bla" }); 18 | 19 | try { 20 | await expectRedux(store) 21 | .toDispatchAnAction() 22 | .ofType("bar") 23 | .matching(foo => foo === true); 24 | fail("No error thrown"); 25 | } catch (e) { 26 | expect(e.stack).not.toContain("at "); 27 | expect(e.message).toMatch( 28 | /Expected action of type '\w+' and passing predicate '[^']+' to be dispatched to store, but did not happen in \d+ms/ 29 | ); 30 | expect(e.message).toMatch( 31 | "The following actions got dispatched to the store instead (2)" 32 | ); 33 | expect(e.message).toMatch('foo\t{"value":"bla"}'); 34 | } 35 | }); 36 | 37 | it("should negate the message for toNotDispatchAnAction()", async () => { 38 | const store = createStore(identity, {}, storeSpy); 39 | 40 | store.dispatch({ type: "bar", value: "bla" }); 41 | 42 | try { 43 | await expectRedux(store) 44 | .toNotDispatchAnAction(1000) 45 | .ofType("bar"); 46 | fail("No error thrown"); 47 | } catch (e) { 48 | expect(e.message).toMatch( 49 | "Expected action of type 'bar' not to be dispatched to store, but was dispatched" 50 | ); 51 | expect(e.message).toMatch( 52 | "The following actions got dispatched to the store (2)" 53 | ); 54 | expect(e.message).toMatch('bar\t{"value":"bla"}'); 55 | } 56 | }); 57 | 58 | it("should report the total number of dispatched actions", async () => { 59 | const store = createStore(identity, {}, storeSpy); 60 | 61 | store.dispatch({ type: "foo", value: "bla" }); 62 | store.dispatch({ type: "foo", value: "bla" }); 63 | 64 | try { 65 | await expectRedux(store) 66 | .toDispatchAnAction() 67 | .ofType("bar") 68 | .matching(foo => foo === true); 69 | fail("No error thrown"); 70 | } catch (e) { 71 | expect(e.message).toContain("(3)"); 72 | } 73 | }); 74 | }); 75 | 76 | describe("on state", () => { 77 | it("should print the expected and the actual state, plus all dispatched actions", async () => { 78 | const store = createStore(identity, { foo: "initial" }, storeSpy); 79 | 80 | store.dispatch({ type: "SOME_ACTION" }); 81 | 82 | try { 83 | await expectRedux(store) 84 | .toHaveState() 85 | .withSubtree((state: any) => state.foo) 86 | .matching({ foo: "bar" }); 87 | fail("No error thrown"); 88 | } catch (e) { 89 | expect(e.message).toMatch("SOME_ACTION"); 90 | expect(e.message).toMatch( 91 | JSON.stringify({ foo: "initial" }, undefined, 2) 92 | ); 93 | } 94 | }); 95 | }); 96 | 97 | describe("regressions", () => { 98 | it("should not fail while serializing circular objects", async () => { 99 | const actionA: any = { type: "FOO" }; 100 | actionA.foo = actionA; 101 | 102 | const store = createStore(identity, {}, storeSpy); 103 | try { 104 | await expectRedux(store) 105 | .toDispatchAnAction() 106 | .matching(actionA); 107 | fail("No error thrown"); 108 | } catch (e) { 109 | expect(e.message).toContain("Unserializable Object"); 110 | } 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/action_matchers_predicates.test.ts: -------------------------------------------------------------------------------- 1 | import { propEq } from "ramda"; 2 | import { ActionMatcher } from "../src/action_matcher"; 3 | import { assertPromiseDidNotResolve } from "./_assertPromiseDidNotResolve"; 4 | 5 | const dummyTimeout = () => { }; 6 | 7 | const dummyStore = { 8 | actions: [], 9 | registerMatcher: () => undefined, 10 | unregisterMatcher: () => undefined 11 | }; 12 | 13 | describe("ofType", () => { 14 | it("should resolve if action has the asserted type", () => { 15 | const promise = ActionMatcher.empty(dummyStore, false).ofType("WANTED_TYPE"); 16 | promise.test({ type: "WANTED_TYPE" }); 17 | return promise; 18 | }); 19 | 20 | it("should not resolve if the test doesn't pass", () => { 21 | const promise = ActionMatcher.empty(dummyStore, false).ofType("WANTED_TYPE"); 22 | promise.test({ type: "GIVEN_TYPE" }); 23 | return assertPromiseDidNotResolve(promise); 24 | }); 25 | 26 | it("should provide a meaningful error message", () => { 27 | const promise = ActionMatcher.empty(dummyStore, false).ofType("WOOP"); 28 | expect(promise.errorMessage).toEqual(`of type 'WOOP'`); 29 | }); 30 | }); 31 | 32 | describe("matching", () => { 33 | describe("matching(obj)", () => { 34 | it("should match the whole action", () => { 35 | const promise = ActionMatcher.empty(dummyStore, false).matching({ 36 | attrA: "FOO", 37 | attrB: "BAR" 38 | }); 39 | promise.test({ attrA: "FOO", attrB: "BAR" }); 40 | return promise; 41 | }); 42 | 43 | it("should not resolve if the object does not match", () => { 44 | const promise = ActionMatcher.empty(dummyStore, false).matching({ 45 | attrA: "FOO", 46 | attrB: "BAR" 47 | }); 48 | promise.test({ attrA: "SOMETHING ELSE", attrB: "BAR" }); 49 | return assertPromiseDidNotResolve(promise); 50 | }); 51 | 52 | it("should provide a meaningful error message", () => { 53 | const obj = { 54 | attrA: "FOO", 55 | attrB: "BAR" 56 | }; 57 | const promise = ActionMatcher.empty(dummyStore, false).matching(obj); 58 | expect(promise.errorMessage).toEqual(`equal to ${JSON.stringify(obj)}`); 59 | }); 60 | }); 61 | 62 | describe("matching(predicate)", () => { 63 | it("should match the predicate", () => { 64 | const promise = ActionMatcher.empty(dummyStore, false).matching( 65 | propEq("FOO", "attrA") 66 | ); 67 | promise.test({ attrA: "FOO" }); 68 | return promise; 69 | }); 70 | 71 | it("should not resolve if the predicate does not match", () => { 72 | const promise = ActionMatcher.empty(dummyStore, false).matching({ 73 | attrA: "FOO" 74 | }); 75 | promise.test(propEq("SOMETHING ELSE", "attrA")); 76 | return assertPromiseDidNotResolve(promise); 77 | }); 78 | 79 | it("should provide a meaningful error message", () => { 80 | const predicate = () => true; 81 | const promise = ActionMatcher.empty(dummyStore, false).matching(predicate); 82 | expect(promise.errorMessage).toEqual( 83 | `passing predicate '${predicate.toString()}'` 84 | ); 85 | }); 86 | }); 87 | 88 | describe("asserting(assertion)", () => { 89 | it("should pass if the assertion does not throw", () => { 90 | const promise = ActionMatcher.empty(dummyStore, false).asserting(({ attrA }) => 91 | expect(attrA).toEqual("FOO") 92 | ); 93 | promise.test({ attrA: "FOO" }); 94 | return promise; 95 | }); 96 | 97 | it("should not resolve if the predicate does not match", () => { 98 | const promise = ActionMatcher.empty(dummyStore, false).asserting(({ attrA }) => 99 | expect(attrA).toEqual("FOO") 100 | ); 101 | promise.test(propEq("SOMETHING ELSE", "attrA")); 102 | return assertPromiseDidNotResolve(promise); 103 | }); 104 | 105 | it("should provide a meaningful error message", () => { 106 | const assertion = () => expect(true).toBe(false); 107 | const promise = ActionMatcher.empty(dummyStore, false).asserting(assertion); 108 | expect(promise.errorMessage).toEqual( 109 | `passing assertion '${assertion.toString()}'` 110 | ); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/action_matcher.ts: -------------------------------------------------------------------------------- 1 | import { allPass, equals, propEq } from "ramda"; 2 | import { StoreWithSpy } from "./storeSpy"; 3 | import { printTable } from "./_printTable"; 4 | import { PromiseLike } from "./_promiseLike"; 5 | import { trySerialize } from "./_trySerialize"; 6 | 7 | class ActionMatcher implements PromiseLike { 8 | innerPromise: Promise; 9 | store: StoreWithSpy; 10 | _resolve: Function; 11 | _reject: Function; 12 | errorMessage: string; 13 | predicate: (action: any) => boolean; 14 | timeout: number | false; 15 | timeoutId: any; 16 | 17 | static empty: (...args: any) => ActionMatcher = ( 18 | store: StoreWithSpy, 19 | timeout: number | false 20 | ) => new EmptyActionMatcher(store, timeout); 21 | 22 | constructor( 23 | predicate: (action: any) => boolean, 24 | errorMessage: string, 25 | store: StoreWithSpy, 26 | timeout: number | false 27 | ) { 28 | this.predicate = predicate; 29 | this.errorMessage = errorMessage; 30 | this.store = store; 31 | this.timeout = timeout; 32 | 33 | this.innerPromise = new Promise((resolve, reject) => { 34 | this._resolve = resolve; 35 | this._reject = reject; 36 | setTimeout(() => { 37 | if (this.timeout !== false) { 38 | this.timeoutId = setTimeout(() => this.onTimeout(), this.timeout); 39 | } 40 | this.store.registerMatcher(this); 41 | }, 0); 42 | }); 43 | } 44 | 45 | resolve() { 46 | this.destroy(); 47 | this._resolve(); 48 | } 49 | 50 | reject(e: Error) { 51 | this.destroy(); 52 | this._reject(e); 53 | } 54 | 55 | onTimeout() { 56 | const actions = this.store.actions; 57 | 58 | const message = `Expected action ${this.errorMessage 59 | } to be dispatched to store, but did not happen in ${this.timeout}ms. 60 | 61 | The following actions got dispatched to the store instead (${actions.length}): 62 | 63 | ${printTable(actions)}\n`; 64 | const error = new Error(message); 65 | error.stack = error.name + ": " + error.message; 66 | this.reject(error); 67 | } 68 | 69 | destroy(): void { 70 | this.store.unregisterMatcher(this); 71 | if (this.innerPromise) this.catch(() => undefined); 72 | else { 73 | console.log("Unregistered innerPromise here"); 74 | } 75 | if (this.timeoutId) clearTimeout(this.timeoutId); 76 | } 77 | 78 | test(action: any): void { 79 | if (this.predicate(action)) { 80 | this.resolve(); 81 | } 82 | } 83 | 84 | then( 85 | onFulfill?: (result: any) => PromiseLike | unknown, 86 | onReject?: (error: any) => PromiseLike | unknown 87 | ) { 88 | return this.innerPromise.then(onFulfill, onReject); 89 | } 90 | 91 | catch(onReject: (error: any) => PromiseLike | unknown) { 92 | return this.innerPromise.catch(onReject); 93 | } 94 | 95 | end(cb: Function) { 96 | return this.then(() => cb(), error => cb(error)); 97 | } 98 | 99 | and( 100 | otherPredicate: (action: any) => boolean, 101 | otherErrorMessage: string 102 | ): ActionMatcher { 103 | this.destroy(); 104 | 105 | return new ActionMatcher( 106 | allPass([this.predicate, otherPredicate]), 107 | `${this.errorMessage} and ${otherErrorMessage}`, 108 | this.store, 109 | this.timeout 110 | ); 111 | } 112 | 113 | ofType(type: string) { 114 | return this.and(propEq(type, "type"), `of type '${type}'`); 115 | } 116 | 117 | matching( 118 | objectOrPredicate: Object | ((action: any) => boolean) 119 | ): ActionMatcher { 120 | if (typeof objectOrPredicate === "function") { 121 | return this.and( 122 | <(action: any) => boolean>objectOrPredicate, 123 | `passing predicate '${objectOrPredicate.toString()}'` 124 | ); 125 | } else { 126 | return this.and( 127 | equals(objectOrPredicate), 128 | `equal to ${trySerialize(objectOrPredicate)}` 129 | ); 130 | } 131 | } 132 | 133 | asserting(assertion: (action: any) => any): ActionMatcher { 134 | return this.and(action => { 135 | try { 136 | assertion(action); 137 | return true; 138 | } catch (e) { 139 | return false; 140 | } 141 | }, `passing assertion '${assertion.toString()}'`); 142 | } 143 | } 144 | 145 | class EmptyActionMatcher extends ActionMatcher { 146 | constructor(store: StoreWithSpy, timeout: number | false) { 147 | super(() => true, "", store, timeout); 148 | } 149 | 150 | and( 151 | otherPredicate: (action: any) => boolean, 152 | otherErrorMessage: string 153 | ): ActionMatcher { 154 | this.destroy(); 155 | return new ActionMatcher( 156 | otherPredicate, 157 | otherErrorMessage, 158 | this.store, 159 | this.timeout 160 | ); 161 | } 162 | } 163 | 164 | export { ActionMatcher }; 165 | 166 | -------------------------------------------------------------------------------- /src/state_matcher.ts: -------------------------------------------------------------------------------- 1 | import { equals, pipe } from "ramda"; 2 | import { StoreWithSpy } from "./storeSpy"; 3 | import { printTable } from "./_printTable"; 4 | import { PromiseLike } from "./_promiseLike"; 5 | 6 | class StateMatcher implements PromiseLike { 7 | static empty: (...args: any) => StateMatcher = ( 8 | store: StoreWithSpy, 9 | timeout: number | false 10 | ) => new StateMatcher(() => true, "", store, timeout); 11 | 12 | store: StoreWithSpy; 13 | predicate: (state: unknown) => boolean; 14 | errorMessage: string; 15 | timeout: number | false; 16 | timeoutId: any; 17 | 18 | innerPromise: Promise; 19 | _resolve: Function; 20 | _reject: Function; 21 | unsubscribe: Function; 22 | 23 | constructor( 24 | predicate: (state: unknown) => boolean, 25 | errorMessage: string, 26 | store: StoreWithSpy, 27 | timeout: number | false 28 | ) { 29 | this.predicate = predicate; 30 | this.errorMessage = errorMessage; 31 | this.store = store; 32 | this.timeout = timeout; 33 | 34 | this.innerPromise = new Promise((_resolve, _reject) => { 35 | this._resolve = _resolve; 36 | this._reject = _reject; 37 | 38 | setTimeout(() => { 39 | if (this.timeout !== false) { 40 | this.timeoutId = setTimeout(() => this.onTimeout(), this.timeout); 41 | } 42 | this.unsubscribe = this.store.subscribe(() => this.test()); 43 | this.test(); 44 | }, 0); 45 | }); 46 | } 47 | 48 | destroy() { 49 | if (this.innerPromise) this.catch(() => undefined); 50 | if (this.unsubscribe) this.unsubscribe(); 51 | if (this.timeoutId) clearTimeout(this.timeoutId); 52 | } 53 | 54 | resolve() { 55 | this.destroy(); 56 | this._resolve(); 57 | } 58 | 59 | reject(e?: Error) { 60 | this.destroy(); 61 | this._reject(e); 62 | } 63 | 64 | onTimeout() { 65 | const actions = this.store.actions; 66 | 67 | const message = `State did not match expected state. 68 | 69 | Expectation: 70 | ${this.errorMessage} 71 | 72 | Actual state: 73 | ${JSON.stringify(this.store.getState(), undefined, 2) || ""} 74 | 75 | The following actions got dispatched to the store (${actions.length}): 76 | 77 | ${printTable(actions)}\n`; 78 | 79 | const error = new Error(message); 80 | error.stack = error.name+": "+error.message; 81 | this.reject(error); 82 | } 83 | 84 | test() { 85 | if (this.predicate(this.store.getState())) { 86 | this.resolve(); 87 | } 88 | } 89 | 90 | matching(expectedState: unknown): StateMatcher { 91 | this.destroy(); 92 | return new StateMatcher( 93 | equals(expectedState), 94 | `equaling ${JSON.stringify(expectedState) || ""}`, 95 | this.store, 96 | this.timeout 97 | ); 98 | } 99 | 100 | withSubtree(selector: (state: any) => unknown): StateMatcher { 101 | this.destroy(); 102 | return SelectingStateMatcher.empty(selector, this.store); 103 | } 104 | 105 | then( 106 | onFulfill?: (result: any) => PromiseLike | unknown, 107 | onReject?: (error: any) => PromiseLike | unknown 108 | ) { 109 | return this.innerPromise.then(onFulfill, onReject); 110 | } 111 | 112 | catch(onReject: (error: any) => PromiseLike | unknown) { 113 | return this.innerPromise.catch(onReject); 114 | } 115 | 116 | end(cb: Function) { 117 | return this.then(() => cb(), error => cb(error)); 118 | } 119 | } 120 | 121 | class SelectingStateMatcher extends StateMatcher { 122 | static empty: (...args: any) => StateMatcher = ( 123 | selector: (state: Object) => Object, 124 | store: StoreWithSpy, 125 | timeout: number | false 126 | ) => new SelectingStateMatcher(selector, () => true, "", store, timeout); 127 | 128 | selector: (state: Object) => unknown; 129 | 130 | constructor( 131 | selector: (state: Object) => unknown, 132 | predicate: (state: unknown) => boolean, 133 | errorMessage: string, 134 | store: StoreWithSpy, 135 | timeout: number | false 136 | ) { 137 | super(predicate, errorMessage, store, timeout); 138 | this.selector = selector; 139 | } 140 | 141 | matching(expectedState: unknown): StateMatcher { 142 | this.destroy(); 143 | return new SelectingStateMatcher( 144 | this.selector, 145 | equals(expectedState), 146 | `substate equaling ${JSON.stringify(expectedState) || ""}`, 147 | this.store, 148 | this.timeout 149 | ); 150 | } 151 | 152 | withSubtree(selector: (state: Object) => unknown): StateMatcher { 153 | this.destroy(); 154 | return SelectingStateMatcher.empty( 155 | pipe( 156 | this.selector, 157 | selector 158 | ), 159 | this.store, 160 | this.timeout 161 | ); 162 | } 163 | 164 | test() { 165 | if (this.predicate(this.selector(this.store.getState()))) { 166 | this.resolve(); 167 | } 168 | } 169 | } 170 | 171 | export { StateMatcher }; 172 | -------------------------------------------------------------------------------- /examples/redux-observable-example/src/login.epic.with-expect-redux.test.js: -------------------------------------------------------------------------------- 1 | import { storeSpy, expectRedux } from "expect-redux"; 2 | import { configureStore } from "./store"; 3 | import Api from "./Api"; 4 | 5 | jest.mock("./Api"); 6 | 7 | describe("loginFlow", () => { 8 | beforeEach(() => jest.resetAllMocks()); 9 | 10 | describe("if the user provides valid credentials", () => { 11 | beforeEach(() => { 12 | // Happy path, so authorization always works 13 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 14 | }); 15 | 16 | it("stores the token in the localStorage", async () => { 17 | /// GIVEN - a user who visits the website 18 | // Add the storeSpy from expectRedux as a storeEnhancer 19 | const store = configureStore([storeSpy]); 20 | 21 | /// WHEN - they enter their credentials and login 22 | store.dispatch({ 23 | type: "LOGIN_REQUEST", 24 | user: "MY_USER", 25 | password: "MY_CORRECT_PASSWORD" 26 | }); 27 | 28 | /// THEN - The token is stored locally for other calls 29 | // Without waiting for LOGIN_SUCCESS, this test would need a timeout 30 | // We don't need to assert the state here, as it's not used at all 31 | // in the given example. It's more important to assert the behaviour 32 | // of the saga, which is driven purely by actions, not by store state. 33 | await expectRedux(store) 34 | .toDispatchAnAction() 35 | .matching({ type: "LOGIN_SUCCESS", token: "SOME_TOKEN" }); 36 | 37 | // This assertion makes sure that the username and the password is 38 | // correctly passed from action to Api.authorize. 39 | // A unit test asserting the correct fetch behaviour of Api.authorize 40 | // is out of scope here, it would go into ./Api.test.js 41 | expect(Api.authorize).toHaveBeenCalledWith( 42 | "MY_USER", 43 | "MY_CORRECT_PASSWORD" 44 | ); 45 | 46 | // This is the important assertion, that our storage mechanism was called. 47 | // An 'Api.test.js' file would test its implementation, but it's out of 48 | // scope for this test. 49 | expect(Api.storeItem).toHaveBeenCalledWith({ token: "SOME_TOKEN" }); 50 | }); 51 | 52 | it("logs out the user when they issue a LOGOUT action", async () => { 53 | // This test only works because the call to Api.clearItem is synchronous 54 | // and happens right after the epic$ has run. 55 | // If it were asynchronous, we would need to wait for the LOGOUT to 56 | // happen, e.g. by dispatching a 'LOGOUT_SUCCESS' action 57 | 58 | /// GIVEN - a successfully logged in user 59 | const store = configureStore([storeSpy]); 60 | store.dispatch({ 61 | type: "LOGIN_REQUEST", 62 | user: "MY_USER", 63 | password: "MY_PASSWORD" 64 | }); 65 | 66 | await expectRedux(store) 67 | .toDispatchAnAction() 68 | .ofType("LOGIN_SUCCESS"); 69 | 70 | /// WHEN - they log out 71 | store.dispatch({ type: "LOGOUT" }); 72 | 73 | /// THEN - the storage is cleared 74 | expect(Api.clearItem).toHaveBeenCalledWith("token"); 75 | }); 76 | }); 77 | 78 | describe("if the credentials are not valid", () => { 79 | beforeEach(() => { 80 | Api.authorize.mockRejectedValue("INVALID CREDENTIALS"); 81 | }); 82 | 83 | it("reports the error", () => { 84 | /// GIVEN - a user who visits the website 85 | const store = configureStore([storeSpy]); 86 | 87 | /// WHEN - The user provides wrong credentials 88 | store.dispatch({ 89 | type: "LOGIN_REQUEST", 90 | user: "MY_USER", 91 | password: "MY_WRONG_PASSWORD" 92 | }); 93 | 94 | /// THEN - the error is reported 95 | // Note that we don't care about other behaviour (such as not setting 96 | // localStorage [see below]) as this test is purely about error reporting 97 | // A more narrow test that asserts exactly a single outcome, instead of 98 | // all of it like we did above (LOGIN_SUCCESS, storeItem, authorize). 99 | return expectRedux(store) 100 | .toDispatchAnAction() 101 | .matching({ type: "LOGIN_ERROR", error: "INVALID CREDENTIALS" }); 102 | }); 103 | 104 | it("clears an old token and does not store a new one", async () => { 105 | /// GIVEN - a user who visits the website 106 | const store = configureStore([storeSpy]); 107 | 108 | /// WHEN - The user provides wrong credentials 109 | store.dispatch({ 110 | type: "LOGIN_REQUEST", 111 | user: "MY_USER", 112 | password: "MY_WRONG_PASSWORD" 113 | }); 114 | 115 | /// THEN - the (non-existent) token isn't stored and an old token is 116 | /// cleared. 117 | // Again, we need to wait for the LOGIN_ERROR action because the Api- 118 | // calls were asynchronous and thus not necessarily resolved the moment 119 | // we would check our mocks. 120 | await expectRedux(store) 121 | .toDispatchAnAction() 122 | .ofType("LOGIN_ERROR"); 123 | 124 | // This is not whitebox testing the saga (we'd need to pay attention to 125 | // the order of invocations then), but instead it's behaviour. 126 | expect(Api.storeItem).not.toHaveBeenCalled(); 127 | expect(Api.clearItem).toHaveBeenCalledWith("token"); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /examples/redux-saga-example/src/loginFlow.saga.with-expect-redux.test.js: -------------------------------------------------------------------------------- 1 | import { storeSpy, expectRedux } from "expect-redux"; 2 | import { configureStore } from "./store"; 3 | import Api from "./Api"; 4 | 5 | jest.mock("./Api"); 6 | 7 | describe("loginFlow", () => { 8 | beforeEach(() => jest.resetAllMocks()); 9 | 10 | describe("if the user provides valid credentials", () => { 11 | beforeEach(() => { 12 | // Happy path, so authorization always works 13 | Api.authorize.mockResolvedValue("SOME_TOKEN"); 14 | }); 15 | 16 | it("stores the token in the localStorage", async () => { 17 | /// GIVEN - a user who visits the website 18 | // Add the storeSpy from expectRedux as a storeEnhancer 19 | const store = configureStore([storeSpy]); 20 | 21 | /// WHEN - they enter their credentials and login 22 | store.dispatch({ 23 | type: "LOGIN_REQUEST", 24 | user: "MY_USER", 25 | password: "MY_CORRECT_PASSWORD" 26 | }); 27 | 28 | /// THEN - The token is stored locally for other calls 29 | // Without waiting for LOGIN_SUCCESS, this test would need a timeout 30 | // We don't need to assert the state here, as it's not used at all 31 | // in the given example. It's more important to assert the behaviour 32 | // of the saga, which is driven purely by actions, not by store state. 33 | await expectRedux(store) 34 | .toDispatchAnAction() 35 | .matching({ type: "LOGIN_SUCCESS", token: "SOME_TOKEN" }); 36 | 37 | // This assertion makes sure that the username and the password is 38 | // correctly passed from action to Api.authorize. 39 | // A unit test asserting the correct fetch behaviour of Api.authorize 40 | // is out of scope here, it would go into ./Api.test.js 41 | expect(Api.authorize).toHaveBeenCalledWith( 42 | "MY_USER", 43 | "MY_CORRECT_PASSWORD" 44 | ); 45 | 46 | // This is the important assertion, that our storage mechanism was called. 47 | // An 'Api.test.js' file would test its implementation, but it's out of 48 | // scope for this test. 49 | expect(Api.storeItem).toHaveBeenCalledWith({ token: "SOME_TOKEN" }); 50 | }); 51 | 52 | it("logs out the user when they issue a LOGOUT action", async () => { 53 | // This test only works because the call to Api.clearItem is synchronous 54 | // and happens right after `yield take [..., 'LOGOUT']`. 55 | // If it were asynchronous, we would need to wait for the LOGOUT to 56 | // happen, e.g. by dispatching a 'LOGOUT_SUCCESS' action 57 | 58 | /// GIVEN - a successfully logged in user 59 | const store = configureStore([storeSpy]); 60 | store.dispatch({ 61 | type: "LOGIN_REQUEST", 62 | user: "MY_USER", 63 | password: "MY_PASSWORD" 64 | }); 65 | 66 | await expectRedux(store) 67 | .toDispatchAnAction() 68 | .ofType("LOGIN_SUCCESS"); 69 | 70 | /// WHEN - they log out 71 | store.dispatch({ type: "LOGOUT" }); 72 | 73 | /// THEN - the storage is cleared 74 | expect(Api.clearItem).toHaveBeenCalledWith("token"); 75 | }); 76 | }); 77 | 78 | describe("if the credentials are not valid", () => { 79 | beforeEach(() => { 80 | Api.authorize.mockRejectedValue("INVALID CREDENTIALS"); 81 | }); 82 | 83 | it("reports the error", () => { 84 | /// GIVEN - a user who visits the website 85 | const store = configureStore([storeSpy]); 86 | 87 | /// WHEN - The user provides wrong credentials 88 | store.dispatch({ 89 | type: "LOGIN_REQUEST", 90 | user: "MY_USER", 91 | password: "MY_WRONG_PASSWORD" 92 | }); 93 | 94 | /// THEN - the error is reported 95 | // Note that we don't care about other behaviour (such as not setting 96 | // localStorage [see below]) as this test is purely about error reporting 97 | // A more narrow test that asserts exactly a single outcome, instead of 98 | // all of it like we did above (LOGIN_SUCCESS, storeItem, authorize). 99 | return expectRedux(store) 100 | .toDispatchAnAction() 101 | .matching({ type: "LOGIN_ERROR", error: "INVALID CREDENTIALS" }); 102 | }); 103 | 104 | it("clears an old token and does not store a new one", async () => { 105 | /// GIVEN - a user who visits the website 106 | const store = configureStore([storeSpy]); 107 | 108 | /// WHEN - The user provides wrong credentials 109 | store.dispatch({ 110 | type: "LOGIN_REQUEST", 111 | user: "MY_USER", 112 | password: "MY_WRONG_PASSWORD" 113 | }); 114 | 115 | /// THEN - the (non-existent) token isn't stored and an old token is 116 | /// cleared. 117 | // Again, we need to wait for the LOGIN_ERROR action because the Api- 118 | // calls were asynchronous and thus not necessarily resolved the moment 119 | // we would check our mocks. 120 | await expectRedux(store) 121 | .toDispatchAnAction() 122 | .ofType("LOGIN_ERROR"); 123 | 124 | // This is not whitebox testing the saga (we'd need to pay attention to 125 | // the order of invocations then), but instead it's behaviour. 126 | expect(Api.storeItem).not.toHaveBeenCalled(); 127 | expect(Api.clearItem).toHaveBeenCalledWith("token"); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/spec.test.ts: -------------------------------------------------------------------------------- 1 | import { identity } from "ramda"; 2 | import { createStore } from "redux"; 3 | import { expectRedux, storeSpy } from "../src/"; 4 | 5 | describe("expectRedux(store)", () => { 6 | const storeFactory = (reducer = identity, initialState = {}) => 7 | createStore(reducer, initialState, storeSpy); 8 | 9 | describe("toDispatchAnAction()", () => { 10 | it("matching(obj)", () => { 11 | const store = storeFactory(); 12 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 13 | return expectRedux(store) 14 | .toDispatchAnAction() 15 | .matching({ type: "MY_TYPE", payload: 42 }); 16 | }); 17 | 18 | it("matching(predicate)", () => { 19 | const store = storeFactory(); 20 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 21 | return expectRedux(store) 22 | .toDispatchAnAction() 23 | .matching(action => action.payload === 42); 24 | }); 25 | 26 | it("asserting(assertion)", () => { 27 | const store = storeFactory(); 28 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 29 | return expectRedux(store) 30 | .toDispatchAnAction() 31 | .asserting(action => 32 | expect(action).toEqual({ type: "MY_TYPE", payload: 42 }) 33 | ); 34 | }); 35 | 36 | it("ofType(str)", () => { 37 | const store = storeFactory(); 38 | store.dispatch({ type: "MY_TYPE" }); 39 | return expectRedux(store) 40 | .toDispatchAnAction() 41 | .ofType("MY_TYPE"); 42 | }); 43 | 44 | it("ofType(str).matching(obj)", () => { 45 | const store = storeFactory(); 46 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 47 | return expectRedux(store) 48 | .toDispatchAnAction() 49 | .ofType("MY_TYPE") 50 | .matching({ type: "MY_TYPE", payload: 42 }); 51 | }); 52 | 53 | it("ofType(str).matching(predicate)", () => { 54 | const store = storeFactory(); 55 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 56 | return expectRedux(store) 57 | .toDispatchAnAction() 58 | .ofType("MY_TYPE") 59 | .matching(action => action.payload === 42); 60 | }); 61 | 62 | it("ofType(str).asserting(assertion)", () => { 63 | const store = storeFactory(); 64 | store.dispatch({ type: "MY_TYPE", payload: 42 }); 65 | return expectRedux(store) 66 | .toDispatchAnAction() 67 | .ofType("MY_TYPE") 68 | .asserting(action => expect(action).toHaveProperty("payload", 42)); 69 | }); 70 | 71 | describe("end(done)", () => { 72 | it("should call done when it resolves", done => { 73 | const store = storeFactory(); 74 | store.dispatch({ type: "MY_TYPE" }); 75 | 76 | expectRedux(store) 77 | .toDispatchAnAction() 78 | .ofType("MY_TYPE") 79 | .end(done); 80 | }); 81 | }); 82 | }); 83 | 84 | describe("toNotDispatchAnAction(timeout)", () => { 85 | it("should resolve only if a matching action is not dispatched inside timeout", () => { 86 | const store = storeFactory(); 87 | 88 | return expectRedux(store) 89 | .toNotDispatchAnAction(100) 90 | .ofType("MY_TYPE"); 91 | }); 92 | 93 | it("should support composition of matchers", () => { 94 | const store = storeFactory(); 95 | 96 | store.dispatch({ type: "MY_TYPE" }); 97 | 98 | return expectRedux(store) 99 | .toNotDispatchAnAction(100) 100 | .ofType("MY_TYPE") 101 | .matching(() => false); 102 | }); 103 | 104 | it("should fail if an action is dispatched that matches", () => { 105 | const store = storeFactory(); 106 | 107 | store.dispatch({ type: "MY_TYPE" }); 108 | 109 | return expectRedux(store) 110 | .toNotDispatchAnAction(0) 111 | .ofType("MY_TYPE") 112 | .then(() => Promise.reject("Was called"), () => Promise.resolve()); 113 | }); 114 | 115 | it("should fail if an action is dispatched that matches a composite", () => { 116 | const store = storeFactory(); 117 | 118 | store.dispatch({ type: "MY_TYPE", foo: "BAR" }); 119 | 120 | return expectRedux(store) 121 | .toNotDispatchAnAction(0) 122 | .ofType("MY_TYPE") 123 | .matching(action => action.foo === "BAR") 124 | .then(() => Promise.reject("Was called"), () => Promise.resolve()); 125 | }); 126 | }); 127 | 128 | describe("toHaveState()", () => { 129 | describe("matching(obj)", () => { 130 | it("should match on the exact state", () => { 131 | const store = storeFactory(undefined, { foo: "bar" }); 132 | 133 | return expectRedux(store) 134 | .toHaveState() 135 | .matching({ foo: "bar" }); 136 | }); 137 | 138 | it("should not match if a value is different", done => { 139 | const store = storeFactory(undefined, { foo: "bar" }); 140 | 141 | expectRedux(store) 142 | .toHaveState() 143 | .matching({ foo: "different" }) 144 | .then(() => done("should not happen"), () => done()); 145 | 146 | setTimeout(() => done()); 147 | }); 148 | }); 149 | 150 | describe("withSubtree(selector)", () => { 151 | it("applies a selector function before a matcher", () => { 152 | const store = storeFactory(undefined, { foo: { bar: "value" } }); 153 | 154 | return expectRedux(store) 155 | .toHaveState() 156 | .withSubtree((state: any) => state.foo) 157 | .matching({ bar: "value" }); 158 | }); 159 | 160 | it("should be composabl", () => { 161 | const store = storeFactory(undefined, { foo: { bar: "value" } }); 162 | 163 | return expectRedux(store) 164 | .toHaveState() 165 | .withSubtree((state: any) => state.foo) 166 | .withSubtree((foo: any) => foo.bar) 167 | .matching("value"); 168 | }); 169 | }); 170 | 171 | describe("end(done)", () => { 172 | it("should call done with no argument if it resolves", done => { 173 | const store = storeFactory(undefined, { foo: "bar" }); 174 | 175 | expectRedux(store) 176 | .toHaveState() 177 | .matching({ foo: "bar" }) 178 | .end(done); 179 | }); 180 | }); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://raw.githubusercontent.com/rradczewski/expect-redux/HEAD/docs/logo.svg?sanitize=true) 2 | 3 | [![npm version](https://badge.fury.io/js/expect-redux.svg)](https://badge.fury.io/js/expect-redux) 4 | [![CircleCI](https://circleci.com/gh/rradczewski/expect-redux.svg?style=svg)](https://circleci.com/gh/rradczewski/expect-redux) 5 | [![Deps](https://david-dm.org/rradczewski/expect-redux.svg)](https://david-dm.org/rradczewski/expect-redux) [![DevDeps](https://david-dm.org/rradczewski/expect-redux/dev-status.svg)](https://david-dm.org/rradczewski/expect-redux) 6 | 7 | # expect-redux - better interaction testing for redux 8 | 9 | `expect-redux` is a testing library that enables you to write tests that verify the behavior of your business logic, no matter if you are using `redux-saga`, `redux-observable` or just `redux-thunk`. It provides a fluent DSL that makes writing tests with asynchronousness in mind a lot easier. 10 | 11 | Here's a simple example to give you an idea: 12 | 13 | ```js 14 | it("should dispatch SUCCESSFULLY_CALLED on success", () => { 15 | const store = createStore(...); 16 | 17 | fetch('/some-call-to-an-api') 18 | .then(() => store.dispatch({ type: "SUCCESSFULLY_CALLED" })) 19 | 20 | return expectRedux(store) 21 | .toDispatchAnAction() 22 | .ofType("SUCCESSFULLY_CALLED") 23 | }); 24 | ``` 25 | 26 | It doesn't matter if the action is dispatched asynchronously or even if it already was dispatched when you call `expectRedux(store)...`, `expect-redux` records all previously dispatched actions as well as every action that will be dispatched. 27 | 28 | See [`/examples`](examples/) for some example projects using different side-effect libraries that are tested with `expect-redux`, or checkout [this blogpost](https://ymmv.craftswerk.io/2018/11/expect-redux-better-interaction-tests-with-redux) where I compare testing strategies for several side-effect libraries and explain how `expect-redux` helps you to put them under test. 29 | 30 | A first version of `expect-redux` was developed for use in our projects at [@VaamoTech](https://twitter.com/VaamoTech) for [Vaamo](https://vaamo.de). 31 | 32 | ## Installation 33 | 34 | ```sh 35 | npm install --save-dev expect-redux 36 | ``` 37 | 38 | ## Usage 39 | 40 | `expect-redux` asserts the behavior of your effects, so it's best if you test your store like you would create it in your application code, not in isolation. 41 | 42 | In order to record actions that are dispatched to it, your store only needs to be configured with a spy as an additional `storeEnhancer`: 43 | 44 | ```js 45 | // store.js 46 | import { createStore, compose } from "redux"; 47 | 48 | export const configureStore = (extraStoreEnhancers = []) => { 49 | const storeEnhancers = [ 50 | , 51 | /* here goes e.g. applyMiddleware */ ...extraStoreEnhancers 52 | ]; 53 | 54 | const store = createStore(reducer, compose(...storeEnhancers)); 55 | 56 | return store; 57 | }; 58 | ``` 59 | 60 | With that, you can add `storeSpy` from `expect-redux` as an `extraStoreEnhancer` in the setup of your tests: 61 | 62 | ```js 63 | import { configureStore } from "./store.js"; 64 | import { storeSpy } from "expect-redux"; 65 | 66 | const storeForTest = () => configureStore([storeSpy]); 67 | 68 | describe("your test", () => { 69 | const store = storeForTest(); 70 | // ... 71 | }); 72 | ``` 73 | 74 | ## API 75 | 76 | `expect-redux` supports both test-runners that support waiting for a `Promise` to resolve, such as `jest` or `mocha`, but also (thanks to [`chai-redux`](https://github.com/ScaCap/chai-redux) for the inspiration) supplying a `done` callback to `end(...)` as the last call to every assertion. 77 | 78 | ```js 79 | it("works if you return a Promise", () => { 80 | return expectRedux(store) 81 | .toDispatchAnAction() 82 | .ofType("PROMISE"); 83 | }); 84 | ``` 85 | 86 | ```js 87 | it("works if you provide a `done` callback", done => { 88 | expectRedux(store) 89 | .toDispatchAnAction() 90 | .ofType("DONE") 91 | .end(done); 92 | }); 93 | ``` 94 | 95 | With the `Promise`-interface, it's easy to write async-await tests with assertions that wait for a certain action to be dispatched: 96 | 97 | ```js 98 | it("works if you use async-await", async () => { 99 | // do something... 100 | 101 | await expectRedux(store) 102 | .toDispatchAnAction() 103 | .ofType("WAITING FOR THIS"); 104 | 105 | // do something else 106 | }); 107 | ``` 108 | 109 | ### Configuration 110 | 111 | #### `expectRedux.configure({ betterErrorMessagesTimeout: number | false })` 112 | 113 | Fail if no expectation matched after `timeout` miliseconds. This is a workaround so you get a meaningful error message instead of a timeout error. Can go into the setup file as it's a global switch. 114 | 115 | ![A screencast showing how the error messages look like](docs/example.gif) 116 | 117 | ### Assertions on Actions 118 | 119 | `expect-redux` is built to assert behavior of side-effects, less the state that results in an action being dispatched. `toDispatchAnAction()` and `toNotDispatchAnAction()` encourage testing the reducer in isolation and instead testing the action producing side-effects. 120 | 121 | #### `expectRedux(store).toDispatchAnAction().ofType(type)` 122 | 123 | Matches by the passed `type` of an action only 124 | 125 | #### `expectRedux(store).toDispatchAnAction().matching(object)` 126 | 127 | Matches an action equal to the passed `object` (using [`R.equals`](http://ramdajs.com/docs/#equals)) 128 | 129 | #### `expectRedux(store).toDispatchAnAction().matching(predicate)` 130 | 131 | Matches an action that satisfies the given `predicate`. predicate must be a function `Action => boolean`, e.g. `R.propEq('payload', 'foobar')`. Will not fail if the `predicate` returns `false`. 132 | 133 | #### `expectRedux(store).toDispatchAnAction().asserting(assertion)` 134 | 135 | Matches an action that won't let the given `assertion` throw an exception. Assertion must be a function `Action => any`, e.g. `action => expect(action.payload).toEqual(42)`. Will not fail if the `assertion` throws. 136 | 137 | #### `expectRedux(store).toDispatchAnAction().ofType(type).matching(predicate)` 138 | 139 | Matches an action that both is of type `type` and satisfies the given `predicate`. Like above, predicate must be a function `action => boolean`. 140 | 141 | #### `expectRedux(store).toDispatchAnAction().ofType(type).asserting(assertion)` 142 | 143 | Matches an action that both is of type `type` and does not let the given `assertion` throw. Assertion must be a function `Action => any`, e.g. `action => expect(action.payload).toEqual(42)`. Will not fail if the `assertion` throws. 144 | 145 | #### `expectRedux(store).toNotDispatchAnAction(timeout: number)...` 146 | 147 | Will negate all following predicates. If an action is dispatched that matches the predicates before `timeout` was reached, the test will fail. 148 | 149 | Note that this is a highly dangerous feature, as it relies on the `timeout` to prove that an action was not dispatched. If it takes longer for your side effect to dispatch the action, the test could misleadingly pass even though the action is ultimately dispatched to the store. 150 | 151 | ### State related assertions 152 | 153 | #### `expectRedux(store).toHaveState().matching(expected: Object)` 154 | 155 | Will create an assertion that resolves once the store state matches the provided `expected` state. 156 | 157 | #### `expectRedux(store).toHaveState().withSubtree(selector: Object => mixed)...` 158 | 159 | Will select a subtree of the state before checking the following assertion. For example: 160 | 161 | ```js 162 | expectRedux(store) 163 | .toHaveState() 164 | .withSubtree(state => state.title) 165 | .matching("Welcome to my website"); 166 | ``` 167 | 168 | ## Similar or related libraries 169 | 170 | - [chai-redux](https://github.com/ScaCap/chai-redux) - a very similar assertion library with a `chai`-like DSL and timing-based assertions (first this action, then that action) 171 | - [redux-saga-test-plan](https://github.com/jfairbank/redux-saga-test-plan) - a testing framework similar to expect-redux, but tied closely to redux-saga 172 | - [redux-action-assertions](https://github.com/dmitry-zaets/redux-actions-assertions) 173 | - [redux-mock-store](https://github.com/arnaudbenard/redux-mock-store) 174 | -------------------------------------------------------------------------------- /test/asyncAndSyncDispatch.test.ts: -------------------------------------------------------------------------------- 1 | import { identity, propEq } from "ramda"; 2 | import { createStore } from "redux"; 3 | import { expectRedux, storeSpy } from "../src/"; 4 | 5 | 6 | const testPreviouslyDispatchedAction = (action, fun) => 7 | it("on previously dispatched actions", done => { 8 | const store = createStore(identity, {}, storeSpy); 9 | store.dispatch(action); 10 | fun(store, done); 11 | }); 12 | 13 | const testEventuallyDispatchedAction = (action, fun) => 14 | it("on eventually dispatched actions", done => { 15 | const store = createStore(identity, {}, storeSpy); 16 | setTimeout(() => store.dispatch(action)); 17 | fun(store, done); 18 | }); 19 | 20 | const testSyncAndAsync = (action, fun) => { 21 | testPreviouslyDispatchedAction(action, fun); 22 | testEventuallyDispatchedAction(action, fun); 23 | }; 24 | 25 | describe("toDispatchAnAction()", () => { 26 | describe("ofType", () => { 27 | testSyncAndAsync({ type: "TEST_ACTION" }, (store, done) => 28 | expectRedux(store) 29 | .toDispatchAnAction() 30 | .ofType("TEST_ACTION") 31 | .then(done, done) 32 | ); 33 | 34 | describe("does not succeed if no action matches", () => { 35 | testSyncAndAsync({ type: "TEST_ACTION" }, (store, done) => { 36 | let failed = false; 37 | const fail = () => { 38 | failed = true; 39 | done(new Error("Should not happen")); 40 | }; 41 | 42 | expectRedux(store) 43 | .toDispatchAnAction() 44 | .ofType("ANOTHER_ACTION") 45 | .then(fail, fail); 46 | 47 | // Finish successfully after dispatching the action 48 | setTimeout(() => (failed ? undefined : done()), 10); 49 | }); 50 | }); 51 | }); 52 | 53 | describe("matching(object)", () => { 54 | testSyncAndAsync({ type: "TEST_ACTION", payload: 1 }, (store, done) => 55 | expectRedux(store) 56 | .toDispatchAnAction() 57 | .matching({ type: "TEST_ACTION", payload: 1 }) 58 | .then(done, done) 59 | ); 60 | 61 | describe("does not succeed if no action matches", () => { 62 | testSyncAndAsync({ type: "TEST_ACTION", payload: 1 }, (store, done) => { 63 | let failed = false; 64 | const fail = () => { 65 | failed = true; 66 | done(new Error("Should not happen")); 67 | }; 68 | 69 | expectRedux(store) 70 | .toDispatchAnAction() 71 | .matching({ type: "TEST_ACTION", payload: 2 }) 72 | .then(fail, fail); 73 | 74 | // Finish successfully after dispatching the action 75 | setTimeout(() => (failed ? undefined : done()), 10); 76 | }); 77 | }); 78 | }); 79 | 80 | describe("matching(predicate)", () => { 81 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => 82 | expectRedux(store) 83 | .toDispatchAnAction() 84 | .matching(propEq(42, "payload")) 85 | .then(done, done) 86 | ); 87 | 88 | describe("does not succeed if no action matches", () => { 89 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => { 90 | let failed = false; 91 | const fail = () => { 92 | failed = true; 93 | done(new Error("Should not happen")); 94 | }; 95 | 96 | expectRedux(store) 97 | .toDispatchAnAction() 98 | .matching(propEq(43, "payload")) 99 | .then(fail, fail); 100 | 101 | // Finish successfully after dispatching the action 102 | setTimeout(() => (failed ? undefined : done()), 10); 103 | }); 104 | }); 105 | }); 106 | 107 | describe("asserting(assertion)", () => { 108 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => 109 | expectRedux(store) 110 | .toDispatchAnAction() 111 | .asserting(action => expect(action.payload).toEqual(42)) 112 | .then(done, done) 113 | ); 114 | 115 | describe("does not succeed if no action matches", () => { 116 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => { 117 | let failed = false; 118 | const fail = () => { 119 | failed = true; 120 | done(new Error("Should not happen")); 121 | }; 122 | 123 | expectRedux(store) 124 | .toDispatchAnAction() 125 | .asserting(action => expect(action.payload).toEqual(43)) 126 | .then(fail, fail); 127 | 128 | // Finish successfully after dispatching the action 129 | setTimeout(() => (failed ? undefined : done()), 10); 130 | }); 131 | }); 132 | }); 133 | 134 | describe("ofType(type).matching(predicate)", () => { 135 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => 136 | expectRedux(store) 137 | .toDispatchAnAction() 138 | .ofType("TEST_ACTION") 139 | .matching(propEq(42, "payload")) 140 | .then(() => done(), done) 141 | ); 142 | 143 | it("should only match ONE action that satisfies both predicates", done => { 144 | const store = createStore(identity, {}, storeSpy); 145 | store.dispatch({ type: "TEXT_ACTION_1", payload: 1 }); 146 | store.dispatch({ type: "TEXT_ACTION_2", payload: 2 }); 147 | 148 | let failed = false; 149 | const fail = () => { 150 | failed = true; 151 | done( 152 | new Error( 153 | "Predicates individually matched for at least one single action, but not for exactly the same" 154 | ) 155 | ); 156 | }; 157 | 158 | expectRedux(store) 159 | .toDispatchAnAction() 160 | .ofType("TEST_ACTION_1") 161 | .matching(propEq(2, "payload")) 162 | .then(fail); 163 | 164 | setTimeout(() => (failed ? undefined : done()), 10); 165 | }); 166 | 167 | describe("does not succeed if no action matches", () => { 168 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => { 169 | let failed = false; 170 | const fail = () => { 171 | failed = true; 172 | done(new Error("Should not happen")); 173 | }; 174 | 175 | expectRedux(store) 176 | .toDispatchAnAction() 177 | .ofType("TEST_ACTION") 178 | .matching(propEq(43, "payload")) 179 | .then(fail, fail); 180 | 181 | // Finish successfully after dispatching the action 182 | setTimeout(() => (failed ? undefined : done()), 10); 183 | }); 184 | }); 185 | }); 186 | 187 | describe("ofType(type).asserting(assertion)", () => { 188 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => 189 | expectRedux(store) 190 | .toDispatchAnAction() 191 | .ofType("TEST_ACTION") 192 | .asserting(action => expect(action.payload).toEqual(42)) 193 | .then(() => done(), done) 194 | ); 195 | 196 | it("should only match ONE action that satisfies both predicates", done => { 197 | const store = createStore(identity, {}, storeSpy); 198 | store.dispatch({ type: "TEXT_ACTION_1", payload: 1 }); 199 | store.dispatch({ type: "TEXT_ACTION_2", payload: 2 }); 200 | 201 | let failed = false; 202 | const fail = () => { 203 | failed = true; 204 | done( 205 | new Error( 206 | "Predicates individually matched for at least one single action, but not for exactly the same" 207 | ) 208 | ); 209 | }; 210 | 211 | expectRedux(store) 212 | .toDispatchAnAction() 213 | .ofType("TEST_ACTION_1") 214 | .asserting(action => expect(action.payload).toEqual(2)) 215 | .then(fail); 216 | 217 | setTimeout(() => (failed ? undefined : done()), 10); 218 | }); 219 | 220 | describe("does not succeed if no action matches", () => { 221 | testSyncAndAsync({ type: "TEST_ACTION", payload: 42 }, (store, done) => { 222 | let failed = false; 223 | const fail = () => { 224 | failed = true; 225 | done(new Error("Should not happen")); 226 | }; 227 | 228 | expectRedux(store) 229 | .toDispatchAnAction() 230 | .ofType("TEST_ACTION") 231 | .asserting(action => expect(action.payload).toEqual(43)) 232 | .then(fail, fail); 233 | 234 | // Finish successfully after dispatching the action 235 | setTimeout(() => (failed ? undefined : done()), 10); 236 | }); 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 136 | 137 | --------------------------------------------------------------------------------