I am testing a react component which has a simple Material-ui switch that updates a boolean field. The component works without any issues when I run the app locally.
In my test, I am mocking the graphql calls via MockedProvider. The mocked provider works as expected, and I can see that the initial response and the update response arrive and they update the state. However, when I find the switch and check it on the screen, it stays unchecked. My test fails with:
Received element is checked: <input checked="" class="PrivateSwitchBase-input-40 MuiSwitch-input" name="callbackEnabled" type="checkbox" value="" />
My first guess is that React doesn't rerender this state change. Do I need to somehow force rerender? Or what is the correct way of testing this kind of behaviour?
The test:
it('should update boolean field', async () => {
const mocks = [
{
request: {
query: myQuery,
variables: {
clientId: 'cl_0',
},
},
result: {
data: {
myQuery: {
callbackEnabled: true
},
},
},
},
{
request: {
query: myMutation,
variables: {
clientId: 'cl_0',
callbackEnabled: false,
},
},
result: {
data: {
myMutation: {
callbackEnabled: false,
},
},
},
},
];
let base = null;
await act(async () => {
const { baseElement, } = render(
<MockedProvider mocks={mocks} addTypename={false}>
<MyComponent clientId="cl_0" error={undefined} />
</MockedProvider>
);
base = baseElement;
});
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 0));
// check for info: https://www.apollographql.com/docs/react/development-testing/testing/#testing-the-success-state
// testing initial state: these pass
expect(base).toBeTruthy();
expect(screen.getByRole('checkbox', { name: /callbacks/i })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /callbacks/i })).toBeChecked();
// simulate a switch click
await act(async () => {
userEvent.click(screen.getByRole('checkbox', { name: 'Callbacks' }));
});
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 0)); // wait for response
// fails here
expect(screen.getByRole('checkbox', { name: /callbacks/i })).not.toBeChecked();
});
First add a test-id to your checkbox component like this :
<input
type="checkbox"
checked={checkboxState}
data-testid="checkboxID" />
Then in ur test to test if checkbox status was changed :
const input = getByTestId("checkboxID");
// Assuming we set initial status for checkbox component to be false
expect(input.checked).toEqual(false);
fireEvent.click(input);
// check handling click in checkbox
expect(input.checked).toEqual(true);
Related
The code below try to check if an url is reachable or not.
The urls to check are stored in a state called trackedUrls
I update this state with an async function checkAll.
The object just before being updated seems fine, but when the component rerender, it contains a promise !
Why ?
What I should change to my code ?
import React from "react"
export default function App() {
const [trackedUrls, setTrackedUrls] = React.useState([])
// 1st call, empty array, it's ok
// 2nd call, useEffect populate trackedUrls with the correct value
// 3rd call, when checkAll is called, it contains a Promise :/
console.log("trackedUrls :", trackedUrls)
const wrappedUrls = trackedUrls.map(urlObject => {
return (
<div key={urlObject.id}>
{urlObject.label}
</div>
)
})
// check if the url is reachable
// this works well if cors-anywhere is enable, click the button on the page
async function checkUrl(url) {
const corsUrl = "https://cors-anywhere.herokuapp.com/" + url
const result = await fetch(corsUrl)
.then(response => response.ok)
console.log(result)
return result
}
// Checks if every url in trackedUrls is reachable
// I check simultaneously the urls with Promise.all
async function checkAll() {
setTrackedUrls(async oldTrackedUrls => {
const newTrackedUrls = await Promise.all(oldTrackedUrls.map(async urlObject => {
let isReachable = await checkUrl(urlObject.url)
const newUrlObject = {
...urlObject,
isReachable: isReachable
}
return newUrlObject
}))
// checkAll works quite well ! the object returned seems fine
// (2) [{…}, {…}]
// { id: '1', label: 'google', url: 'https://www.google.Fr', isReachable: true }
// { id: '2', label: 'whatever', url: 'https://qmsjfqsmjfq.com', isReachable: false }
console.log(newTrackedUrls)
return newTrackedUrls
})
}
React.useEffect(() => {
setTrackedUrls([
{ id: "1", label: "google", url: "https://www.google.Fr" },
{ id: "2", label: "whatever", url: "https://qmsjfqsmjfq.com" }
])
}, [])
return (
<div>
<button onClick={checkAll}>Check all !</button>
<div>
{wrappedUrls}
</div>
</div>
);
}
Konrad helped me to grasp the problem.
This works and it's less cumbersome.
If anyone has a solution with passing a function to setTrackedUrls, I'm interested just for educational purpose.
async function checkAll() {
const newTrackedUrls = await Promise.all(trackedUrls.map(async urlObject => {
let isReachable = await checkUrl(urlObject.url)
const newUrlObject = {
...urlObject,
isReachable: isReachable
}
return newUrlObject
}))
setTrackedUrls(newTrackedUrls)
}
You can only put data into setState.
I have the following custom render function for my component.
It has two modes Create and Edit.
Create is synchronous and Edit is asynchronous.
The function is as follows:
const renderComponent = async (
scheduleId = "",
dialogMode = DialogMode.CREATE,
cohorts = MOCK_COHORT_LIST,
jobs = JOB_LIST,
availableForSchedule = domain === Domain.COHORT ? jobs : cohorts,
) => {
render(
<AddEditScheduleDialog
cohorts={cohorts}
jobs={jobs}
availableForSchedule={availableForSchedule}
scheduleToEdit={scheduleId}
handleToggleDialog={mockToggleDialog}
isDialogVisible={true}
domain={domain}
/>,
{
wrapper: queryWrapper,
},
);
if (dialogMode === DialogMode.EDIT) {
expect(screen.getByRole("progressbar")).toBeInTheDocument();
}
await waitFor(() =>
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(),
);
return {
header: screen.getByRole("heading", {
level: 1,
name: `schedules:addEditDialog.${domain}.${dialogMode}.title`,
}),
name: {
field: screen.getByTestId(`${domain}-name-field`),
button: within(screen.getByTestId(`${domain}-name-field`)).getByRole(
"button",
),
},
frequency: {
field: screen.getByTestId("schedule-frequency-field"),
input: within(
screen.getByTestId("schedule-frequency-field"),
).getByRole("textbox"),
helperText: `schedules:addEditDialog.form.frequency.helperText`,
},
};
};
Sometimes I get intermittent problems finding elements on the screen. Is this because the function returns before the progressbar has been awaited?
Is there a way i can wait for everything to be rendered prior to returning the screen elements that I need?
If your Edit mode is causing the progress bar to be displayed asynchronously you should wait for it by making use of findByRole e.g.
if (dialogMode === DialogMode.EDIT) {
expect(await screen.findByRole("progressbar")).toBeInTheDocument());
}
This is because find* queries use waitFor "under the hood".
I am populating the form fields from a configuration and after user updates the fields, I compare the updated fields with configuration fetched earlier using "isEqual". If "isEqual = false" I enable the submit button. I am having little trouble to simulate this behavior using jest. Could anyone help with this test case?
Below is my sample code snippets:
const [areValuesEqual, setAreValuesEqual] = React.useState(true);
const onSubmit = React.useCallback(values => {
if (!isEqual(originalValues, values)) {
console.log('submitted.');
props.handleNext(values);
}
console.log("didn't submit.");}, [props, originalValues]);
useEffect(() => setAreValuesEqual(isEqual(originalValues, formik.values)), [formik.values, originalValues]);
<div>
<Button
type="submit"
variant="contained"
color="primary"
className={classes.button}
data-testid="next"
disabled={areValuesEqual}
>
Next
</Button>
</div>
Here is my test:
describe('FormComponent', () => {
const Spec = {
appName: 'somerepo',
repo: 'project-somerepo',
proxies: [
{
env: {
dev: {
basepath: '/somerepo-dev.net',
target: 'https://somerepo-dev.net',
},
test: {
basepath: '/somerepo-test.net',
target: 'https://somerepo-test.net',
}
},
},
],
};
function renderFormComponent() {
return render(
<ManageFormComponent metadata={metadata} handleNext={() => { }} />
);
}
it.only('should render form', async () => {
await act(async () => {
renderFormComponent();
await Promise.resolve();
});
expect(screen).toBeDefined();
expect(screen.getByText('Project Repo')).toBeInTheDocument();
const Repo = await screen.findByTestId('Repo');
const RepoInput = Repo.querySelector('input')!;
RepoInput.focus();
fireEvent.change(document.activeElement!, {
target: { value: 'project-somerepo' },
});
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(await screen.findByText('I want to')).toBeInTheDocument();
const deploy = await screen.findByTestId('deployment');
const deployInput = deploy.querySelector('input')!;
deployInput.focus();
fireEvent.change(document.activeElement!, {
target: { value: 'promote service' },
});
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(
await screen.findByText('Select an environment to deploy'),
).toBeInTheDocument();
const env = await screen.findByLabelText('TEST');
userEvent.click(env);
const Target = await screen.findByText('Target Endpoint')
expect(Target).toBeInTheDocument();
expect(await screen.findByTestId('target')).toHaveValue(
Spec.proxies[0].env.dev.target,
);
const Basepath = await screen.findByText('BasePath')
expect(Basepath).toBeInTheDocument();
expect(await screen.findByTestId('basepath')).toHaveValue(
Spec.proxies[0].env.dev.basepath,
);
const TargetInput = Target.querySelector('input')
TargetInput?.focus();
fireEvent.change(document.activeElement!, {
target: { value: 'https://somerepo-test.net' }
})
const BasepathInput = Basepath.querySelector('input')
BasepathInput?.focus();
fireEvent.change(document.activeElement!, {
target: { value: '/somerepo-test.net' }
})
const nextButton = await screen.findByRole('button', { name: /Next/ });
expect(nextButton).toBeEnabled();
});
}); ```
I get below error, which i believe is happening due to useEffect hook, where I compare the values of fetched values and updated values. If they are not equal, then submit button is enabled. For example below are the JSON objects are being compared:
expect(element).toBeEnabled()
Received element is not enabled:
<button class="MuiButtonBase-root MuiButton-root MuiButton-contained makeStyles-button-6 MuiButton-containedPrimary Mui-disabled Mui-disabled" data-testid="next" disa
bled="" tabindex="-1" type="submit" />
336 |
337 | // expect(nextButton).toBeTruthy();
> 338 | expect(nextButton).toBeEnabled();
| ^
339 | });
Below is the test for ag-grid. Documentaion can be found at https://www.ag-grid.com/javascript-grid-testing-react/
Few of my tests are failing in CI when tests are asynchronous as test1. Is there any solution to make it consistent? I tried test2 approach make it synchronous but that is also failing. Is there any better way to run tests with consistency?
describe('ag grid test 1', () => {
let agGridReact;
let component;
const defaultProps = {
//.....
}
beforeEach((done) => {
component = mount(<CustomAGGridComp {...defaultProps} />);
agGridReact = component.find(AgGridReact).instance();
// don't start our tests until the grid is ready
ensureGridApiHasBeenSet(component).then(() => done(), () => fail("Grid API not set within expected time limits"));
});
it('stateful component returns a valid component instance', async () => {
expect(agGridReact.api).toBeTruthy();
//..use the Grid API...
var event1 = {
type: 'cellClicked', rowIndex: 0, column: { field: "isgfg", colId: "isgfg", headerName: "Property 2" },
event: {
ctrlKey: false,
shiftKey: false
}
}
await agGridReact.api.dispatchEvent(event1)
//some expect statements
})
});
describe('ag grid test 2', () => {
let agGridReact;
let component;
const defaultProps = {
//.....
}
beforeEach((done) => {
component = mount(<CustomAGGridComp {...defaultProps} />);
agGridReact = component.find(AgGridReact).instance();
// don't start our tests until the grid is ready
ensureGridApiHasBeenSet(component).then(() => done(), () => fail("Grid API not set within expected time limits"));
});
it('stateful component returns a valid component instance', () => {
expect(agGridReact.api).toBeTruthy();
//..use the Grid API...
var event1 = {
type: 'cellClicked', rowIndex: 0, column: { field: "isgfg", colId: "isgfg", headerName: "Property 2" },
event: {
ctrlKey: false,
shiftKey: false
}
}
agGridReact.api.dispatchEvent(event1);
setTimeout(() => {
//some expect statements
}, 500);
})
});
I am having a ReactJS component which does two things:
- on ComponentDidMount it will retrieve a list of entries
- on Button click it will submit the select entry to a backend
The problem is that i need to mock both requests (made with fetch) in order to test it properly. In my current testcase i want to test a failure in the submit on the button click. However due some odd reason the setState is triggered however the update from that is received after i want to compare it.
Dumps i did for the test. First one is the state as listen in the test. The second is from the code itself where it is setting state().error to the error received from the call
FAIL react/src/components/Authentication/DealerSelection.test.jsx (6.689s)
● Console
console.log react/src/components/Authentication/DealerSelection.test.jsx:114
{ loading: true,
error: null,
options: [ { key: 22, value: 22, text: 'Stationstraat 5' } ] }
console.log react/src/components/Authentication/DealerSelection.jsx:52
set error to: my error
The actual test code:
it('throws error message when dealer submit fails', done => {
const mockComponentDidMount = Promise.resolve(
new Response(JSON.stringify({"data":[{"key":22,"value":"Stationstraat 5"}],"default":22}), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
const mockButtonClickFetchError = Promise.reject(new Error('my error'));
jest.spyOn(global, 'fetch').mockImplementation(() => mockComponentDidMount);
const element = mount(<DealerSelection />);
process.nextTick(() => {
jest.spyOn(global, 'fetch').mockImplementation(() => mockButtonClickFetchError);
const button = element.find('button');
button.simulate('click');
process.nextTick(() => {
console.log(element.state()); // state.error null even though it is set with setState but arrives just after this log statement
global.fetch.mockClear();
done();
});
});
});
This is the component that i actually use:
import React, { Component } from 'react';
import { Form, Header, Select, Button, Banner } from '#omnius/react-ui-elements';
import ClientError from '../../Error/ClientError';
import { fetchBackend } from './service';
import 'whatwg-fetch';
import './DealerSelection.scss';
class DealerSelection extends Component {
state = {
loading: true,
error: null,
dealer: '',
options: []
}
componentDidMount() {
document.title = "Select dealer";
fetchBackend(
'/agent/account/dealerlist',
{},
this.onDealerListSuccessHandler,
this.onFetchErrorHandler
);
}
onDealerListSuccessHandler = json => {
const options = json.data.map((item) => {
return {
key: item.key,
value: item.key,
text: item.value
};
});
this.setState({
loading: false,
options,
dealer: json.default
});
}
onFetchErrorHandler = err => {
if (err instanceof ClientError) {
err.response.json().then(data => {
this.setState({
error: data.error,
loading: false
});
});
} else {
console.log('set error to', err.message);
this.setState({
error: err.message,
loading: false
});
}
}
onSubmitHandler = () => {
const { dealer } = this.state;
this.setState({
loading: true,
error: null
});
fetchBackend(
'/agent/account/dealerPost',
{
dealer
},
this.onDealerSelectSuccessHandler,
this.onFetchErrorHandler
);
}
onDealerSelectSuccessHandler = json => {
if (!json.error) {
window.location = json.redirect; // Refresh to return back to MVC
}
this.setState({
error: json.error
});
}
onChangeHandler = (event, key) => {
this.setState({
dealer: event.target.value
});
}
render() {
const { loading, error, dealer, options } = this.state;
const errorBanner = error ? <Banner type='error' text={error} /> : null;
return (
<div className='dealerselection'>
<Form>
<Header as="h1">Dealer selection</Header>
{ errorBanner }
<Select
label='My dealer'
fluid
defaultValue={dealer}
onChange={this.onChangeHandler}
maxHeight={5}
options={options}
/>
<Button
primary
fluid
onClick={this.onSubmitHandler}
loading={loading}
>Select dealer</Button>
</Form>
</div>
);
}
}
export default DealerSelection;
Interesting, this one took a little while to chase down.
Relevant parts from the Node.js doc on Event Loop, Timers, and process.nextTick():
process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop.
...any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues.
In other words, Node starts processing the nextTickQueue once the current operation is completed, and it will continue until the queue is empty before continuing with the event loop.
This means that if process.nextTick() is called while the nextTickQueue is processing, the callback is added to the queue and it will be processed before the event loop continues.
The doc warns:
This can create some bad situations because it allows you to "starve" your I/O by making recursive process.nextTick() calls, which prevents the event loop from reaching the poll phase.
...and as it turns out you can starve your Promise callbacks as well:
test('Promise and process.nextTick order', done => {
const order = [];
Promise.resolve().then(() => { order.push('2') });
process.nextTick(() => {
Promise.resolve().then(() => { order.push('7') });
order.push('3'); // this runs while processing the nextTickQueue...
process.nextTick(() => {
order.push('4'); // ...so all of these...
process.nextTick(() => {
order.push('5'); // ...get processed...
process.nextTick(() => {
order.push('6'); // ...before the event loop continues...
});
});
});
});
order.push('1');
setTimeout(() => {
expect(order).toEqual(['1','2','3','4','5','6','7']); // ...and 7 gets added last
done();
}, 0);
});
So in this case the nested process.nextTick() callback that logs element.state() ends up running before the Promise callbacks that would set state.error to 'my error'.
It is because of this that the doc recommends the following:
We recommend developers use setImmediate() in all cases because it's easier to reason about
If you change your process.nextTick calls to setImmediate (and create your fetch mocks as functions so Promise.reject() doesn't run immediately and cause an error) then your test should work as expected:
it('throws error message when dealer submit fails', done => {
const mockComponentDidMount = () => Promise.resolve(
new Response(JSON.stringify({"data":[{"key":22,"value":"Stationstraat 5"}],"default":22}), {
status: 200,
headers: { 'content-type': 'application/json' }
})
);
const mockButtonClickFetchError = () => Promise.reject(new Error('my error'));
jest.spyOn(global, 'fetch').mockImplementation(mockComponentDidMount);
const element = mount(<DealerSelection />);
setImmediate(() => {
jest.spyOn(global, 'fetch').mockImplementation(mockButtonClickFetchError);
const button = element.find('button');
button.simulate('click');
setImmediate(() => {
console.log(element.state()); // state.error is 'my error'
global.fetch.mockClear();
done();
});
});
});
There are several asynchronous calls required to update the state, so your process.nextTick() isn't sufficient. To update the state, this needs to happen:
your test code clicks, and the event handler callback is queued
the event handler callback runs, runs fetch, gets a promise rejection, and runs the error handler
the error handler runs setState, which queues the state update (setState is asynchronous!)
your test code runs, checking the element's state
the state update runs
In short, you need to wait longer before asserting on the state.
A useful idiom to "wait" without nested process.nextTick() calls is to define a test helper
function wait() {
return new Promise((resolve) => setTimeout(resolve));
}
and then do
await wait();
as many times as required in your test code. Note that this requires you to define test functions as
test(async () => {
})
rather than
test(done => {
})