React testing Library form validation onChange not working as expected
Asked Answered
S

1

2

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:

It works perfectly during manual tests

Why is this test failing when it is clearly invalid

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:

Manual test shows it is invalid and error is showing

Here is the other failed test that receives empty values:

Empty value received even though it is visible

Here once again, in the manual test the error message is visible:

Error message shows on manual test

How can this test fail, when clearly the values themselves fail

Both input values are invalid so why is the button not disabled?

Here it works perfectly in manual testing:

Works perfectly during manual tests

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.

Swipe answered 21/3, 2024 at 10:33 Comment(0)
K
1

HTML5 native HTMLInputElement.checkValidity() will always return true if the input value is set by JavaScript rather than the user interaction. See related question:

And the HTML standard spec Setting minimum input length requirements: the minlength attribute:

Constraint validation: If an element has a minimum allowed value length, its dirty value flag is true, its value was last changed by a user edit (as opposed to a change made by a script), its value is not the empty string, and the length of the element's API value is less than the element's minimum allowed value length, then the element is suffering from being too short.

This is why test cases related to input validation fail.

One solution is to use Hyperform takes over the input validation:

It features a complete implementation of the HTML5 form validation API in JavaScript, replaces the browser’s native methods (if they are even implemented…), and enriches your toolbox with custom events and hooks.

You can use Hyperform package only for testing purposes. You can still use the HTML5 native validation methods such as .checkValidity() when you release your web application.

jest.setup.js:

import hyperform from 'hyperform';

globalThis.HTMLInputElement.prototype.checkValidity = function () {
  return hyperform.checkValidity(this);
};

jest.config.js:

module.exports = {
  testEnvironment: 'jsdom',
  setupFiles: ['<rootDir>/jest.setup.js'],
};

For the sake of demonstration, I simplified your code:

index.tsx:

import React, { useState } from 'react';

const ErrorMessage = (props) => (
  <span data-testid={props.testId} style={{ color: 'red' }}>
    {props.text}
  </span>
);

export default function ControlledForm() {
  const [inputOne, setInputOne] = useState({ value: '', isValid: false, errorMessage: '' });
  const [inputTwo, setInputTwo] = useState({ value: '', isValid: false, errorMessage: '' });
  const isValid = inputOne.isValid && inputTwo.isValid;

  return (
    <form action="" method="POST" 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) => {
            const isValid = e.target.checkValidity();
            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) => {
            setInputTwo({
              value: e.target.value,
              isValid: e.target.checkValidity(),
              errorMessage: !e.target.checkValidity() ? 'Error happenned' : '',
            });
          }}
        />
        <ErrorMessage testId="cErrorMessage2" text={inputTwo.errorMessage} />
      </div>

      <button disabled={!isValid} type="submit">
        Submit
      </button>
    </form>
  );
}

index.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import React from 'react';
import ControlledForm from '.';

describe('Controlled Form basic tests', () => {
  let inputOne: HTMLInputElement;
  let inputTwo: HTMLInputElement;
  let submitButton: HTMLButtonElement;

  beforeEach(() => {
    render(<ControlledForm />);
    inputOne = screen.getByPlaceholderText(/Input One:/);
    inputTwo = screen.getByPlaceholderText(/Input Two:/);
    submitButton = screen.getByText(/submit/i);
  });

  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();
  });

  it('Should be valid', async () => {
    await userEvent.type(inputTwo, 'ab');
    expect(inputTwo).toBeInvalid();
  });

  it('Should be invalid', async () => {
    const user = userEvent.setup();
    await user.type(inputOne, 'abc');
    expect(inputOne).toBeInvalid();
  });

  it('Should display error message', async () => {
    const user = userEvent.setup();
    await user.type(inputOne, 'abc');
    expect(screen.getByTestId('cErrorMessage1')).toHaveTextContent('Error happenned');
  });
});

Test result:

 PASS  stackoverflow/78199219/index.test.tsx
  Controlled Form basic tests
    √ Expect submit button to be disabled when inputs are not valid (204 ms)                                                                                                                                                                                 
    √ Should be valid (108 ms)                                                                                                                                                                                                                               
    √ Should be valid (94 ms)                                                                                                                                                                                                                                
    √ Should be invalid (94 ms)                                                                                                                                                                                                                              
    √ Should display error message (95 ms)                                                                                                                                                                                                                   
                                                                                                                                                                                                                                                             
Test Suites: 1 passed, 1 total                                                                                                                                                                                                                               
Tests:       5 passed, 5 total                                                                                                                                                                                                                               
Snapshots:   0 total
Time:        1.888 s, estimated 2 s
Ran all test suites related to changed files.

Besides, you should use .toHaveTextContent() to check the element.textContent rather than .toHaveValue()

Kiehl answered 26/3, 2024 at 4:48 Comment(2)
Thank you for this, I have a day off but will check this out tomorrow, I found that the CSS psuedo :valid, :invalid work fine, So generally speaking, if I wanted to test inputs, the only option would be to use HyperForm ? What if I did not want to use this additional dependency, Is there a workaround, or something else I could test ?Swipe
I managed to get it to work thanks, I accepted the answer, Is there any workaround without hyperform ? I used it as a dev dependency, But since this is such a ubiquitous aspect of client side dev, I would have thought the testing frameworks would have taken care of this out of the boxSwipe

© 2022 - 2025 — McMap. All rights reserved.