feat: redux tests pt 1 (#19)

* feat: redux tests pt 1

* chore: add app thunkAction tests

* chore: resolve lint issues

* Update src/editors/data/redux/app/reducer.test.js

Co-authored-by: connorhaugh <49422820+connorhaugh@users.noreply.github.com>

Co-authored-by: connorhaugh <49422820+connorhaugh@users.noreply.github.com>
This commit is contained in:
Ben Warzeski
2022-02-24 16:59:30 -05:00
committed by GitHub
parent 2b0346fe84
commit 1a1900f213
12 changed files with 397 additions and 59 deletions

16
package-lock.json generated
View File

@@ -6650,7 +6650,8 @@
"eslint-import-resolver-alias": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz",
"integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w=="
"integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==",
"dev": true
},
"eslint-import-resolver-node": {
"version": "0.3.6",
@@ -11928,6 +11929,11 @@
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
"dev": true
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs="
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -14858,6 +14864,14 @@
"deep-diff": "^0.3.5"
}
},
"redux-mock-store": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz",
"integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==",
"requires": {
"lodash.isplainobject": "^4.0.6"
}
},
"redux-saga": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz",

View File

@@ -64,6 +64,7 @@
"redux": "4.1.2",
"redux-devtools-extension": "^2.13.9",
"redux-logger": "^3.0.6",
"redux-mock-store": "^1.5.4",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"tinymce": "^5.10.2"

View File

@@ -0,0 +1,59 @@
import { initialState, actions, reducer } from './reducer';
const testingState = {
...initialState,
arbitraryField: 'arbitrary',
};
describe('app reducer', () => {
it('has initial state', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});
const testValue = 'roll for initiative';
describe('handling actions', () => {
describe('initialize', () => {
it('loads initial input fields into the store', () => {
const data = {
studioEndpointUrl: 'testURL',
blockId: 'anID',
courseId: 'OTHERid',
blockType: 'someTYPE',
};
expect(reducer(
testingState,
actions.initialize({ ...data, other: 'field' }),
)).toEqual({
...testingState,
...data,
});
});
});
const setterTest = (action, target) => {
describe(action, () => {
it(`load ${target} from payload`, () => {
expect(reducer(testingState, actions[action](testValue))).toEqual({
...testingState,
[target]: testValue,
});
});
});
};
[
['setUnitUrl', 'unitUrl'],
['setBlockValue', 'blockValue'],
['setBlockContent', 'blockContent'],
['setBlockTitle', 'blockTitle'],
['setSaveResponse', 'saveResponse'],
].map(args => setterTest(...args));
describe('initializeEditor', () => {
it('sets editorInitialized to true', () => {
expect(reducer(testingState, actions.initializeEditor())).toEqual({
...testingState,
editorInitialized: true,
});
});
});
});
});

View File

@@ -0,0 +1,98 @@
// import * in order to mock in-file references
import * as urls from '../../services/cms/urls';
import * as selectors from './selectors';
jest.mock('reselect', () => ({
createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
}));
jest.mock('../../services/cms/urls', () => ({
unit: (args) => ({ unit: args }),
}));
const testState = { some: 'arbitraryValue' };
const testValue = 'my VALUE';
describe('app selectors unit tests', () => {
const {
appSelector,
simpleSelectors,
} = selectors;
describe('appSelector', () => {
it('returns the app data', () => {
expect(appSelector({ ...testState, app: testValue })).toEqual(testValue);
});
});
describe('simpleSelectors', () => {
const testSimpleSelector = (key) => {
test(`${key} simpleSelector returns its value from the app store`, () => {
const { preSelectors, cb } = simpleSelectors[key];
expect(preSelectors).toEqual([appSelector]);
expect(cb({ ...testState, [key]: testValue })).toEqual(testValue);
});
};
describe('simple selectors link their values from app store', () => {
[
'blockContent',
'blockId',
'blockType',
'blockValue',
'courseId',
'editorInitialized',
'saveResponse',
'studioEndpointUrl',
'unitUrl',
].map(testSimpleSelector);
});
});
describe('returnUrl', () => {
it('is memoized based on unitUrl and studioEndpointUrl', () => {
expect(selectors.returnUrl.preSelectors).toEqual([
simpleSelectors.unitUrl,
simpleSelectors.studioEndpointUrl,
]);
});
it('returns urls.unit with the unitUrl if loaded, else an empty string', () => {
const { cb } = selectors.returnUrl;
const studioEndpointUrl = 'baseURL';
const unitUrl = 'some unit url';
expect(cb(null, studioEndpointUrl)).toEqual('');
expect(cb(unitUrl, studioEndpointUrl)).toEqual(urls.unit({ unitUrl, studioEndpointUrl }));
});
});
describe('isInitialized selector', () => {
it('is memoized based on unitUrl, editorInitialized, and blockValue', () => {
expect(selectors.isInitialized.preSelectors).toEqual([
simpleSelectors.unitUrl,
simpleSelectors.editorInitialized,
simpleSelectors.blockValue,
]);
});
it('returns true iff unitUrl, blockValue, and editorInitialized are all truthy', () => {
const { cb } = selectors.isInitialized;
const truthy = {
url: { url: 'data' },
blockValue: { block: 'value' },
editorInitialized: true,
};
[
[[truthy.url, truthy.blockValue, false], false],
[[null, truthy.blockValue, true], false],
[[truthy.url, null, true], false],
[[truthy.url, truthy.blockValue, true], true],
].map(([args, expected]) => expect(cb(...args)).toEqual(expected));
});
});
describe('typeHeader', () => {
it('is memoized based on blockType', () => {
expect(selectors.typeHeader.preSelectors).toEqual([simpleSelectors.blockType]);
});
it('returns Text if the blockType is html', () => {
expect(selectors.typeHeader.cb('html')).toEqual('Text');
});
it('returns the blockType capitalized if not html', () => {
expect(selectors.typeHeader.cb('video')).toEqual('Video');
expect(selectors.typeHeader.cb('random')).toEqual('Random');
});
});
});

View File

@@ -4,7 +4,7 @@ import * as module from './selectors';
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
export const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
export const isInactive = ({ status }) => status === RequestStates.inactive;
export const isPending = ({ status }) => status === RequestStates.pending;
@@ -19,15 +19,19 @@ export const errorCode = (request) => request.error?.response?.data;
export const data = (request) => request.data;
export const connectedStatusSelectors = () => ({
isInactive: module.statusSelector(isInactive),
isPending: module.statusSelector(isPending),
isCompleted: module.statusSelector(isCompleted),
isFailed: module.statusSelector(isFailed),
isFinished: module.statusSelector(isFinished),
error: module.statusSelector(error),
errorCode: module.statusSelector(errorCode),
errorStatus: module.statusSelector(errorStatus),
data: module.statusSelector(data),
});
export default StrictDict({
requestStatus,
isInactive: statusSelector(isInactive),
isPending: statusSelector(isPending),
isCompleted: statusSelector(isCompleted),
isFailed: statusSelector(isFailed),
isFinished: statusSelector(isFinished),
error: statusSelector(error),
errorCode: statusSelector(errorCode),
errorStatus: statusSelector(errorStatus),
data: statusSelector(data),
...module.connectedStatusSelectors(),
});

View File

@@ -0,0 +1,122 @@
import { RequestStates } from '../../constants/requests';
// import * in order to mock in-file references
import * as selectors from './selectors';
jest.mock('reselect', () => ({
createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
}));
const testValue = 'my test VALUE';
const testKey = 'MY test key';
describe('request selectors', () => {
describe('basic selectors', () => {
describe('requestStatus', () => {
it('returns the state associated with the given requestKey', () => {
expect(
selectors.requestStatus(
{ requests: { [testKey]: testValue } },
{ requestKey: testKey },
),
).toEqual(testValue);
});
});
describe('statusSelector', () => {
it('returns a state selector that applies a fn against request state by requestKey', () => {
const myMethod = ({ data }) => ({ myData: data });
expect(selectors.statusSelector(myMethod)(
{ requests: { [testKey]: { data: testValue } } },
{ requestKey: testKey },
)).toEqual({ myData: testValue });
});
});
describe('state selectors', () => {
const testStateSelector = (selector, expected) => {
describe(selector, () => {
it(`returns true iff the request status equals ${expected}`, () => {
expect(selectors[selector]({ status: expected })).toEqual(true);
expect(selectors[selector]({ status: 'other' })).toEqual(false);
});
});
};
testStateSelector('isInactive', RequestStates.inactive);
testStateSelector('isPending', RequestStates.pending);
testStateSelector('isCompleted', RequestStates.completed);
testStateSelector('isFailed', RequestStates.failed);
describe('isFinished', () => {
it('returns true iff the request is completed or failed', () => {
expect(selectors.isFinished({ status: RequestStates.completed })).toEqual(true);
expect(selectors.isFinished({ status: RequestStates.failed })).toEqual(true);
expect(selectors.isFinished({ status: 'other' })).toEqual(false);
});
});
});
describe('error selectors', () => {
describe('error', () => {
it('returns the error for the request', () => {
expect(selectors.error({ error: testValue })).toEqual(testValue);
});
});
describe('errorStatus', () => {
it('returns the status the error response iff one exists', () => {
expect(selectors.errorStatus({})).toEqual(undefined);
expect(selectors.errorStatus({ error: {} })).toEqual(undefined);
expect(selectors.errorStatus({ error: { response: {} } })).toEqual(undefined);
expect(selectors.errorStatus(
{ error: { response: { status: testValue } } },
)).toEqual(testValue);
});
});
describe('errorCode', () => {
it('returns the status the error code iff one exists', () => {
expect(selectors.errorCode({})).toEqual(undefined);
expect(selectors.errorCode({ error: {} })).toEqual(undefined);
expect(selectors.errorCode({ error: { response: {} } })).toEqual(undefined);
expect(selectors.errorCode(
{ error: { response: { data: testValue } } },
)).toEqual(testValue);
});
});
});
describe('data', () => {
it('returns the data from the request', () => {
expect(selectors.data({ data: testValue })).toEqual(testValue);
});
});
});
describe('exported selectors', () => {
test('requestStatus forwards basic selector', () => {
expect(selectors.default.requestStatus).toEqual(selectors.requestStatus);
});
describe('statusSelector selectors', () => {
let statusSelector;
let connectedSelectors;
beforeEach(() => {
statusSelector = selectors.statusSelector;
selectors.statusSelector = jest.fn(key => ({ statusSelector: key }));
connectedSelectors = selectors.connectedStatusSelectors();
});
afterEach(() => {
selectors.statusSelector = statusSelector;
});
const testStatusSelector = (name) => {
describe(name, () => {
it(`returns a status selector keyed to the ${name} selector`, () => {
expect(connectedSelectors[name].statusSelector).toEqual(selectors[name]);
});
});
};
[
'isInactive',
'isPending',
'isCompleted',
'isFailed',
'error',
'errorCode',
'errorStatus',
'data',
].map(testStatusSelector);
});
});
});

View File

@@ -1,6 +1,7 @@
import { StrictDict } from '../../../utils';
import { actions, selectors } from '..';
import { actions } from '..';
import * as requests from './requests';
import * as module from './app';
export const fetchBlock = () => (dispatch) => {
dispatch(requests.fetchBlock({
@@ -24,14 +25,14 @@ export const fetchUnit = () => (dispatch) => {
*/
export const initialize = (data) => (dispatch) => {
dispatch(actions.app.initialize(data));
dispatch(fetchBlock());
dispatch(fetchUnit());
dispatch(module.fetchBlock());
dispatch(module.fetchUnit());
};
/**
* @param {func} onSuccess
*/
export const saveBlock = ({ content, returnToUnit }) => (dispatch, getState) => {
export const saveBlock = ({ content, returnToUnit }) => (dispatch) => {
dispatch(actions.app.setBlockContent(content));
dispatch(requests.saveBlock({
content,

View File

@@ -1,42 +1,95 @@
import { locationId } from './data/constants/app';
import { actions } from './data/redux';
import thunkActions from './app';
import { actions } from '..';
import * as thunkActions from './app';
jest.mock('./requests', () => ({
initializeApp: (args) => ({ initializeApp: args }),
fetchBlock: (args) => ({ fetchBlock: args }),
fetchUnit: (args) => ({ fetchUnit: args }),
saveBlock: (args) => ({ saveBlock: args }),
}));
const testValue = 'test VALUE';
describe('app thunkActions', () => {
let dispatch;
let dispatchedAction;
beforeEach(() => {
dispatch = jest.fn((action) => ({ dispatch: action }));
});
describe('initialize', () => {
describe('fetchBlock', () => {
beforeEach(() => {
thunkActions.initialize()(dispatch);
thunkActions.fetchBlock()(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches initializeApp with locationId and onSuccess', () => {
expect(dispatchedAction.initializeApp.locationId).toEqual(locationId);
expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function');
it('dispatches fetchBlock action', () => {
expect(dispatchedAction.fetchBlock).not.toEqual(undefined);
});
describe('on success', () => {
test('loads oraMetadata, courseMetadata and list data', () => {
dispatch.mockClear();
const response = {
oraMetadata: { some: 'ora-metadata' },
courseMetadata: { some: 'course-metadata' },
submissions: { some: 'submissions' },
};
dispatchedAction.initializeApp.onSuccess(response);
expect(dispatch.mock.calls).toEqual([
[actions.app.loadOraMetadata(response.oraMetadata)],
[actions.app.loadCourseMetadata(response.courseMetadata)],
[actions.submissions.loadList(response.submissions)],
]);
});
it('dispatches actions.app.setBlockValue on success', () => {
dispatch.mockClear();
dispatchedAction.fetchBlock.onSuccess(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue));
});
it('dispatches actions.app.setBlockValue on failure', () => {
dispatch.mockClear();
dispatchedAction.fetchBlock.onFailure(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setBlockValue(testValue));
});
});
describe('fetchUnit', () => {
beforeEach(() => {
thunkActions.fetchUnit()(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches fetchUnit action', () => {
expect(dispatchedAction.fetchUnit).not.toEqual(undefined);
});
it('dispatches actions.app.setUnitUrl on success', () => {
dispatch.mockClear();
dispatchedAction.fetchUnit.onSuccess(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setUnitUrl(testValue));
});
it('dispatches actions.app.setUnitUrl on failure', () => {
dispatch.mockClear();
dispatchedAction.fetchUnit.onFailure(testValue);
expect(dispatch).toHaveBeenCalledWith(actions.app.setUnitUrl(testValue));
});
});
describe('initialize', () => {
it('dispatches actions.app.initialize, and then fetches both block and unit', () => {
const { fetchBlock, fetchUnit } = thunkActions;
thunkActions.fetchBlock = () => 'fetchBlock';
thunkActions.fetchUnit = () => 'fetchUnit';
thunkActions.initialize(testValue)(dispatch);
expect(dispatch.mock.calls).toEqual([
[actions.app.initialize(testValue)],
[thunkActions.fetchBlock()],
[thunkActions.fetchUnit()],
]);
thunkActions.fetchBlock = fetchBlock;
thunkActions.fetchUnit = fetchUnit;
});
});
describe('saveBlock', () => {
let returnToUnit;
let calls;
beforeEach(() => {
returnToUnit = jest.fn();
thunkActions.saveBlock({ content: testValue, returnToUnit })(dispatch);
calls = dispatch.mock.calls;
});
it('dispatches actions.app.setBlockContent with content, before dispatching saveBlock', () => {
expect(calls[0]).toEqual([actions.app.setBlockContent(testValue)]);
const saveCall = calls[1][0];
expect(saveCall.saveBlock).not.toEqual(undefined);
});
it('dispatches saveBlock with passed content', () => {
expect(calls[1][0].saveBlock.content).toEqual(testValue);
});
it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
dispatch.mockClear();
const response = 'testRESPONSE';
calls[1][0].saveBlock.onSuccess(response);
expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
expect(returnToUnit).toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,6 @@
import { actions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import api from 'data/services/lms/api';
import { actions } from '..';
import { RequestKeys } from '../../constants/requests';
import api from '../../services/cms/api';
import * as requests from './requests';
jest.mock('data/services/lms/api', () => ({

View File

@@ -1,10 +1,6 @@
import queryString from 'query-string';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import * as utils from './utils';
jest.mock('query-string', () => ({
stringifyUrl: jest.fn((url, options) => ({ url, options })),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
@@ -26,14 +22,4 @@ describe('cms service utils', () => {
expect(utils.post(...args)).toEqual(post(...args));
});
});
describe('stringifyUrl', () => {
it('forwards url and query to stringifyUrl with options to skip null and ""', () => {
const url = 'here.com';
const query = { some: 'set', of: 'queryParams' };
const options = { skipNull: true, skipEmptyString: true };
expect(utils.stringifyUrl(url, query)).toEqual(
queryString.stringifyUrl({ url, query }, options),
);
});
});
});

View File

@@ -3,11 +3,11 @@ import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import rootReducer, { actions, selectors } from 'data/redux';
import rootReducer, { actions, selectors } from './redux';
import exportedStore, { createStore } from './store';
jest.mock('data/redux', () => ({
jest.mock('./redux', () => ({
__esModule: true,
default: 'REDUCER',
actions: 'ACTIONS',