Merge pull request #18 from muselesscreator/integration
feat: Integration testing - round 1
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.snap linguist-generated=false
|
||||
@@ -15,6 +15,7 @@ export const ConfirmModal = ({
|
||||
<AlertModal
|
||||
className="confirm-modal"
|
||||
title={title}
|
||||
onClose={() => ({})}
|
||||
isOpen={isOpen}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
|
||||
@@ -29,6 +29,7 @@ export const InfoPopover = ({ children }) => (
|
||||
src={InfoOutline}
|
||||
alt="criterion info"
|
||||
iconAs={Icon}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ exports[`ConfirmModal snapshot: closed 1`] = `
|
||||
</ActionRow>
|
||||
}
|
||||
isOpen={false}
|
||||
onClose={[Function]}
|
||||
title="test-title"
|
||||
>
|
||||
<p>
|
||||
@@ -48,6 +49,7 @@ exports[`ConfirmModal snapshot: open 1`] = `
|
||||
</ActionRow>
|
||||
}
|
||||
isOpen={true}
|
||||
onClose={[Function]}
|
||||
title="test-title"
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -21,6 +21,7 @@ exports[`Info Popover Component snapshot 1`] = `
|
||||
alt="criterion info"
|
||||
className="criteria-help-icon"
|
||||
iconAs={[MockFunction Icon]}
|
||||
onClick={[Function]}
|
||||
src={[MockFunction icons.InfoOutline]}
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { ArrowBack, Launch } from '@edx/paragon/icons';
|
||||
import { Hyperlink, Icon } from '@edx/paragon';
|
||||
|
||||
import selectors from 'data/selectors';
|
||||
import { locationId } from 'data/constants/app';
|
||||
@@ -15,12 +15,14 @@ import urls from 'data/services/lms/urls';
|
||||
export const ListViewBreadcrumb = ({ courseId, oraName }) => (
|
||||
<>
|
||||
<Hyperlink className="py-4" destination={urls.openResponse(courseId)}>
|
||||
<ArrowBack className="mr-3" />
|
||||
<Icon icon={ArrowBack} className="mr-3" />
|
||||
Back to all open responses
|
||||
</Hyperlink>
|
||||
<p className="h3 py-4">
|
||||
{oraName}
|
||||
<Hyperlink destination={urls.ora(courseId, locationId)} target="_blank" />
|
||||
<Hyperlink destination={urls.ora(courseId, locationId)} target="_blank">
|
||||
<Icon icon={Launch} />
|
||||
</Hyperlink>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Hyperlink: () => 'Hyperlink',
|
||||
Icon: () => 'Icon',
|
||||
}));
|
||||
jest.mock('@edx/paragon/icons', () => ({
|
||||
ArrowBack: 'icons.ArrowBack',
|
||||
Launch: 'icons.Launch',
|
||||
}));
|
||||
|
||||
jest.mock('data/selectors', () => ({
|
||||
|
||||
@@ -6,8 +6,9 @@ exports[`ListViewBreadcrumb component component snapshot: empty (no list data) 1
|
||||
className="py-4"
|
||||
destination="openResponseUrl(test-course-id)"
|
||||
>
|
||||
<SvgArrowBack
|
||||
<Icon
|
||||
className="mr-3"
|
||||
icon="icons.ArrowBack"
|
||||
/>
|
||||
Back to all open responses
|
||||
</Hyperlink>
|
||||
@@ -18,7 +19,11 @@ exports[`ListViewBreadcrumb component component snapshot: empty (no list data) 1
|
||||
<Hyperlink
|
||||
destination="oraUrl(test-course-id, fake-location-id)"
|
||||
target="_blank"
|
||||
/>
|
||||
>
|
||||
<Icon
|
||||
icon="icons.Launch"
|
||||
/>
|
||||
</Hyperlink>
|
||||
</p>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
@@ -110,6 +110,7 @@ exports[`ListView component component render tests snapshots snapshot: happy pat
|
||||
Array [
|
||||
Object {
|
||||
"buttonText": "View all responses",
|
||||
"className": "view-all-responses-btn",
|
||||
"handleClick": [MockFunction this.handleViewAllResponsesClick],
|
||||
"variant": "primary",
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ export class ListView extends React.Component {
|
||||
selectedBulkAction(selectedFlatRows) {
|
||||
return {
|
||||
buttonText: `View selected responses (${selectedFlatRows.length})`,
|
||||
className: 'view-selected-responses-btn',
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
variant: 'primary',
|
||||
};
|
||||
@@ -86,6 +87,7 @@ export class ListView extends React.Component {
|
||||
{
|
||||
buttonText: 'View all responses',
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
className: 'view-all-responses-btn',
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -100,7 +100,7 @@ const app = createReducer(initialState, {
|
||||
[actions.grading.preloadPrev]: (state, { payload }) => ({ ...state, prev: payload }),
|
||||
[actions.grading.loadNext]: (state, { payload }) => ({
|
||||
...state,
|
||||
prev: state.current,
|
||||
prev: { response: state.current.response },
|
||||
current: { response: state.next.response, ...payload },
|
||||
activeIndex: state.activeIndex + 1,
|
||||
gradeData: {
|
||||
@@ -111,7 +111,7 @@ const app = createReducer(initialState, {
|
||||
}),
|
||||
[actions.grading.loadPrev]: (state, { payload }) => ({
|
||||
...state,
|
||||
next: state.current,
|
||||
next: { response: state.current.response },
|
||||
current: { response: state.prev.response, ...payload },
|
||||
gradeData: {
|
||||
...state.gradeData,
|
||||
|
||||
@@ -140,7 +140,21 @@ export const startGrading = () => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the grading process for the current submisison
|
||||
* Cancels the grading process for the current submisison.
|
||||
* Releases the lock and dispatches stopGrading on success.
|
||||
*/
|
||||
export const cancelGrading = () => (dispatch, getState) => {
|
||||
dispatch(requests.setLock({
|
||||
value: false,
|
||||
submissionId: selectors.grading.selected.submissionId(getState()),
|
||||
onSuccess: () => {
|
||||
dispatch(module.stopGrading());
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the grading process for the current submission (local only)
|
||||
* Clears the local grade data for the current submission and sets grading state
|
||||
* to False
|
||||
*/
|
||||
@@ -154,5 +168,6 @@ export default StrictDict({
|
||||
loadNext,
|
||||
loadPrev,
|
||||
startGrading,
|
||||
cancelGrading,
|
||||
stopGrading,
|
||||
});
|
||||
|
||||
@@ -313,9 +313,38 @@ describe('grading thunkActions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelGrading', () => {
|
||||
let stopGrading;
|
||||
beforeAll(() => {
|
||||
stopGrading = thunkActions.stopGrading;
|
||||
thunkActions.stopGrading = () => 'stop grading';
|
||||
});
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.cancelGrading());
|
||||
actionArgs = dispatched.setLock;
|
||||
});
|
||||
afterAll(() => {
|
||||
thunkActions.stopGrading = stopGrading;
|
||||
});
|
||||
test('dispatches setLock with selected submissionId and value: false', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
expect(actionArgs.value).toEqual(false);
|
||||
expect(actionArgs.submissionId).toEqual(selectors.grading.selected.submissionId(testState));
|
||||
});
|
||||
describe('onSuccess', () => {
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
});
|
||||
test('dispatches stopGrading thunkAction', () => {
|
||||
actionArgs.onSuccess();
|
||||
expect(dispatch.mock.calls).toContainEqual([thunkActions.stopGrading()]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopGrading', () => {
|
||||
it('dispatches grading.clearGrade and app.setGrading(false)', () => {
|
||||
thunkActions.stopGrading()(dispatch);
|
||||
thunkActions.stopGrading()(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.clearGrade()],
|
||||
[actions.app.setGrading(false)],
|
||||
|
||||
249
src/test/app.test.jsx
Normal file
249
src/test/app.test.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
import * as redux from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import thunk from 'redux-thunk';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import fakeData from 'data/services/lms/fakeData';
|
||||
import api from 'data/services/lms/api';
|
||||
import reducers from 'data/reducers';
|
||||
import { gradingStatuses } from 'data/services/lms/constants';
|
||||
import messages from 'i18n';
|
||||
|
||||
import App from 'App';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
getLoginRedirectUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
const configureStore = () => redux.createStore(
|
||||
reducers,
|
||||
redux.compose(redux.applyMiddleware(thunk)),
|
||||
);
|
||||
|
||||
let el;
|
||||
let store;
|
||||
let state;
|
||||
|
||||
/**
|
||||
* Simple wrapper for updating the top-level state variable, that also returns the new value
|
||||
* @return {obj} - current redux store state
|
||||
*/
|
||||
const getState = () => {
|
||||
state = store.getState();
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Fake Data for quick access */
|
||||
const submissionIds = [
|
||||
fakeData.ids.submissionId(0),
|
||||
fakeData.ids.submissionId(1),
|
||||
fakeData.ids.submissionId(2),
|
||||
fakeData.ids.submissionId(3),
|
||||
fakeData.ids.submissionId(4),
|
||||
];
|
||||
const submissions = submissionIds.map(id => fakeData.mockSubmission(id));
|
||||
const responses = submissions.map(({ response }) => response);
|
||||
const statuses = submissionIds.map(id => fakeData.mockSubmissionStatus(id));
|
||||
|
||||
const resolveFns = {};
|
||||
/**
|
||||
* Mock the api with jest functions that can be tested against.
|
||||
*/
|
||||
const mockApi = () => {
|
||||
api.initializeApp = jest.fn(() => new Promise(
|
||||
(resolve) => {
|
||||
resolveFns.initialize = () => resolve({
|
||||
oraMetadata: fakeData.oraMetadata,
|
||||
courseMetadata: fakeData.courseMetadata,
|
||||
submissions: fakeData.submissions,
|
||||
});
|
||||
},
|
||||
));
|
||||
api.fetchSubmission = jest.fn((submissionId) => new Promise(
|
||||
(resolve) => resolve(fakeData.mockSubmission(submissionId)),
|
||||
));
|
||||
api.fetchSubmissionStatus = jest.fn((submissionId) => new Promise(
|
||||
(resolve) => resolve(fakeData.mockSubmissionStatus(submissionId)),
|
||||
));
|
||||
api.fetchSubmissionResponse = jest.fn((submissionId) => new Promise(
|
||||
(resolve) => resolve({ response: fakeData.mockSubmission(submissionId).response }),
|
||||
));
|
||||
};
|
||||
|
||||
/**
|
||||
* load and configure the store, render the element, and populate the top-level state object
|
||||
*/
|
||||
const renderEl = async () => {
|
||||
store = configureStore();
|
||||
el = await render(
|
||||
<IntlProvider locale="en" messages={messages.en}>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
getState();
|
||||
};
|
||||
|
||||
/**
|
||||
* resolve the initalization promise, and update state object
|
||||
*/
|
||||
const initialize = async () => {
|
||||
resolveFns.initialize();
|
||||
await act(() => el.findByText(fakeData.ids.username(0)));
|
||||
getState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the first 5 entries in the table and click the "View Selected Responses" button
|
||||
* Wait for the review page to show and update the top-level state object.
|
||||
*/
|
||||
const makeTableSelections = async () => {
|
||||
const table = el.getByRole('table');
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
const checkbox = (index) => within(rows.item(index)).getByTitle('Toggle Row Selected');
|
||||
const clickIndex = (index) => userEvent.click(checkbox(index));
|
||||
[0, 1, 2, 3, 4].forEach(clickIndex);
|
||||
userEvent.click(el.container.querySelector('.view-selected-responses-btn'));
|
||||
await act(() => el.findByText('Show Rubric'));
|
||||
getState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Click the "next" button in review modal
|
||||
*/
|
||||
const clickNext = async () => {
|
||||
userEvent.click(el.getByLabelText('Load next submission'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Click the "next" button in review modal
|
||||
*/
|
||||
const clickPrev = async () => {
|
||||
userEvent.click(el.getByLabelText('Load previous submission'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the prev and next values to be populated based on the current selection index
|
||||
*/
|
||||
const waitForNeighbors = async (currentIndex) => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { prev, next } = getState().grading;
|
||||
expect(prev).toEqual(
|
||||
(currentIndex > 0) ? { response: responses[currentIndex - 1] } : null,
|
||||
);
|
||||
expect(next).toEqual(
|
||||
(currentIndex < 4) ? { response: responses[currentIndex + 1] } : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for neighbors, and then verify prev, current, and next grading fields have the appropriate
|
||||
* data. Also ensure that the app is "grading" iff the "current" response's lockStatus is inProgress.
|
||||
*/
|
||||
const checkLoadedResponses = async (currentIndex) => {
|
||||
await waitForNeighbors(currentIndex);
|
||||
const { prev, current, next } = state.grading;
|
||||
expect({ prev, current, next }).toEqual({
|
||||
prev: currentIndex > 0 ? ({ response: responses[currentIndex - 1] }) : null,
|
||||
current: {
|
||||
submissionId: submissionIds[currentIndex],
|
||||
response: submissions[currentIndex].response,
|
||||
...statuses[currentIndex],
|
||||
},
|
||||
next: currentIndex < 4 ? ({ response: responses[currentIndex + 1] }) : null,
|
||||
});
|
||||
expect(state.app.showReview).toEqual(true);
|
||||
const shouldBeGrading = statuses[currentIndex].lockStatus === gradingStatuses.inProgress;
|
||||
expect(state.app.isGrading).toEqual(shouldBeGrading);
|
||||
};
|
||||
|
||||
describe('ESG app integration tests', () => {
|
||||
beforeAll(() => mockApi());
|
||||
|
||||
test('initialState', async () => {
|
||||
await renderEl();
|
||||
expect(state.app).toEqual(jest.requireActual('data/reducers/app').initialState);
|
||||
expect(state.submissions).toEqual(
|
||||
jest.requireActual('data/reducers/submissions').initialState,
|
||||
);
|
||||
expect(state.grading).toEqual(jest.requireActual('data/reducers/grading').initialState);
|
||||
});
|
||||
|
||||
test('initialization', async () => {
|
||||
await renderEl();
|
||||
await initialize();
|
||||
expect(state.app.courseMetadata).toEqual(fakeData.courseMetadata);
|
||||
expect(state.app.oraMetadata).toEqual(fakeData.oraMetadata);
|
||||
expect(state.submissions.allSubmissions).toEqual(fakeData.submissions);
|
||||
});
|
||||
|
||||
describe('table selection', () => {
|
||||
beforeAll(async () => {
|
||||
await renderEl();
|
||||
await initialize();
|
||||
await makeTableSelections();
|
||||
});
|
||||
it('loads selected submission ids', () => {
|
||||
expect(state.grading.selected).toEqual(submissionIds);
|
||||
});
|
||||
test('app flags, { showReview: true, isGrading: false, showRubric: false }', () => {
|
||||
expect(state.app.showReview).toEqual(true);
|
||||
expect(state.app.isGrading).toEqual(false);
|
||||
expect(state.app.showRubric).toEqual(false);
|
||||
});
|
||||
it('loads current submission', () => {
|
||||
const submissionId = fakeData.ids.submissionId(0);
|
||||
expect(state.grading.current).toEqual({
|
||||
submissionId,
|
||||
...fakeData.mockSubmission(submissionId),
|
||||
});
|
||||
});
|
||||
it('loads response for next submission', () => {
|
||||
expect(state.grading.next).toEqual({
|
||||
response: fakeData.mockSubmission(submissionIds[1]).response,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('review navigation', () => {
|
||||
test('loads full submission for current, and response for neighbors when navigating', async () => {
|
||||
await renderEl();
|
||||
await initialize();
|
||||
await makeTableSelections();
|
||||
await clickNext();
|
||||
await checkLoadedResponses(1);
|
||||
await clickNext();
|
||||
await checkLoadedResponses(2);
|
||||
await clickPrev();
|
||||
await checkLoadedResponses(1);
|
||||
await clickNext();
|
||||
await checkLoadedResponses(2);
|
||||
await clickNext();
|
||||
await checkLoadedResponses(3);
|
||||
await clickNext();
|
||||
await checkLoadedResponses(4);
|
||||
await clickPrev();
|
||||
await checkLoadedResponses(3);
|
||||
await clickPrev();
|
||||
await checkLoadedResponses(2);
|
||||
await clickPrev();
|
||||
await checkLoadedResponses(1);
|
||||
await clickPrev();
|
||||
await checkLoadedResponses(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
src/test/utils.js
Normal file
7
src/test/utils.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const mockSuccess = (returnValFn) => (...args) => (
|
||||
new Promise((resolve) => resolve(returnValFn(...args)))
|
||||
);
|
||||
|
||||
export const mockFailure = (returnValFn) => (...args) => (
|
||||
new Promise((resolve, reject) => reject(returnValFn(...args)))
|
||||
);
|
||||
Reference in New Issue
Block a user