I am learning React-testing library and struggling to understand how to validate error messages with onchange. I even simplified it so the form is disabled until both inputs are valid. It works perfectly during manual tests, but for some reason inside React Testing Library, It is just not working.
Either I am missing something basic that isn't documented because it's so obvious or there is a bug somewhere.
The inputs are not validating properly not on during the change event and not even when i am clicking the submit button.
When the form is clearly invalid, it says its valid.
I have two inputs:
First one, is required and has a min length of 5, Second input, has min and max length of 3, so exactly 3,
Here is the code showing the various tests that should easily pass but for some weird reason fail:
I tried with both controlled and uncontrolled forms and I am struggling to understand why this is happening. I feels like something that is right in front of me but I am missing it.
I am using Vitest, with jest-dom and RTL.
I have tried to implement hyperform based on the answer below, but it still does not work:
import * as matchers from '@testing-library/jest-dom/vitest';
import {expect, afterAll} from 'vitest';
import { cleanup } from '@testing-library/react';
import Hyperform from 'hyperform';
expect.extend(matchers);
globalThis.HTMLInputElement.prototype.checkValidity = function () {
return hyperform.checkValidity(this);
};
afterAll(() => {
cleanup()
})
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/Tests/Rtl/Setup.js'
}
})
export default function ErrorMessage(props) {
return (
<span data-testid={props.testId} ref={props.ref} style={{color: 'red'}}>{props.text}</span>
)
}
export default function ControlledForm() {
console.log('Component form rendered!');
const [inputOne, setInputOne] = useState({
value: "",
isValid: false,
errorMessage: ""
});
const [inputTwo, setInputTwo] = useState({
value: "",
isValid: false,
errorMessage: ""
});
const isValid = inputOne.isValid && inputTwo.isValid;
console.log('Form isValid: ', isValid);
function handleSubmit(e) {
e.preventDefault();
console.log('Form Submitted!', e.target.elements);
}
return (
<div>
<h3>Controlled Form</h3>
<p>
In this component, all state for inputs is in the top component!
</p>
<form
action=""
method='POST'
onSubmit={e => handleSubmit(e)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '1em'
}}
>
<div>
<input
className='practice-inputs'
type="text"
name="inputOne"
placeholder='Input One:'
value={inputOne.value}
minLength={5}
maxLength={7}
required
onChange={(e) => {
//Validate and check input on change here
console.log('Input 1 Validity on change: ', e.target.validity);
const isValid = e.target.checkValidity();
console.log('Is valid: ',isValid);
setInputOne({
value: e.target.value,
isValid: isValid,
errorMessage: (!isValid) ? 'Error happenned' : ''
})
}}
/>
<ErrorMessage testId='cErrorMessage1' text={inputOne.errorMessage} />
</div>
<div>
<input
className='practice-inputs'
type="text"
name="inputTwo"
placeholder='Input Two: '
value={inputTwo.value}
minLength={3}
maxLength={3}
required
onChange={(e) => {
//Validate and check input on change here
console.log('Input 2 Validity on change: ', e.target.validity);
setInputTwo({
value: e.target.value,
isValid: e.target.checkValidity(),
errorMessage: (!e.target.checkValidity()) ? 'Error happenned' : ''
})
}}
/>
<ErrorMessage testId='cErrorMessage2' text={inputTwo.errorMessage} />
</div>
<SubmitButton disabled={!isValid} text='Submit' />
</form>
</div>
)
}
Tests:
describe('Controlled Form basic tests', () => {
let inputOne; let inputTwo; let submitButton; let user;
beforeEach(() => {
render(<ControlledForm />)
inputOne = screen.getByPlaceholderText(/Input One:/);
inputTwo = screen.getByPlaceholderText(/Input Two:/);
submitButton = screen.getByText(/submit/i);
})
it('Renders', () => {
})
it('Should be able to show an input by placeholder text', () => {
expect(inputOne).toBeInTheDocument()
})
/**
* Note, when looking for something that doesn't exist, I should use queryby
*/
it('Should not be able to show inputs by an incorrect placeholder', () => {
expect(screen.queryByPlaceholderText('placeholder that doesnt exist')).not.toBeInTheDocument()
})
/**
* Here I am learning how to interact with inputs,
* I need to wait for the type to finish, as it can take a bit of time to type the input,
* Otherwise it would go to the next line without waiting and the input takes a bit of time
* to be there
*/
it('Just shows value of the input', async () => {
await userEvent.type(inputOne, 'abc');
expect(inputOne).toHaveValue('abc');
})
/**
* ok
*/
it('Should have the error component in the document', async () => {
await userEvent.type(inputOne, 'abc');
expect(screen.getByTestId('cErrorMessage1')).toBeInTheDocument();
})
//Okay
it('Should have css style ?', async () => {
await userEvent.type(inputOne, 'abc');
expect(screen.getByTestId('cErrorMessage1')).toHaveStyle('color: rgb(255,0,0)');
})
//Okay
it('Expect submit button to be in the document', async () => {
expect(submitButton).toBeInTheDocument();
})
//Okay
it('Its submit button should be disabled', () => {
expect(submitButton).toBeDisabled();
})
/**
* Why is this test failing ??
*/
it('Expect submit button to be disabled when inputs are not valid', async () => {
await userEvent.type(inputOne, 'a');
await userEvent.type(inputTwo, 'a');
expect(submitButton).toBeDisabled();
})
it('Should be valid', async () => {
await userEvent.type(inputTwo, 'abc');
expect(inputTwo).toBeValid()
})
//This is invalid but for some reason fails, because it's valid ?
it('Should be valid', async () => {
await userEvent.type(inputTwo, 'ab');
expect(inputTwo).toBeInvalid()
})
/**
* Fails
*/
it('Should be invalid', async () => {
const user = userEvent.setup();
await user.type(inputOne, 'abc');
expect(inputOne).toBeInvalid();
})
/**
* Fails
* Error text does not have value,
* But It clearly can be seen on browser
*/
it('Should display error message', async () => {
const user = userEvent.setup();
await user.type(inputOne, 'abc');
expect(screen.getByTestId('cErrorMessage1')).toHaveValue(/error/i);
})
Its perfect during a manual test:
I have also logged the output in the console with manual tests, and it works perfectly:
I looked at the docs here for the matchers: https://github.com/testing-library/jest-dom?tab=readme-ov-file#tobeinvalid
It clearly says if check validity returns false, which it clearly does
Here is my manual browser test showing it clearly works:
Here is the other failed test that receives empty values:
Here once again, in the manual test the error message is visible:
Here it works perfectly in manual testing:
I am struggling to truly understand how this works, Because onChange the specific Component would re-render, Maybe this is why it is not able to capture the new values ?
I can't see much documentation explaining this, I feel scared now to post questions here, Any advice would be appreciated.