0

im trying some adventures in the testing area, especially i wanted to test some basic react components but i get stuck at the first step. This is a simplified form that i am using in my application.

The form is working. Im struggling with the test of the view atm.

What i want:

I would like to test the view without the container and they container with the view.

I wrote some basic test, the way i think they should look like e.g.

  • view: test if change gets called with the correct data
  • presenter: test after change gets called what the value of the input is <-this is working, thus not in the code but in the gist

What i expect to happen:

If i call fireEvent in the test, i would like he test to pass.

What is happening:

The first test is working(obviously), because the component gets initialized with empty values. onChange test in the container component is working aswell. The onChange test in my view is broken, the types of the events don't match.

How can i test this or achieve the correct type?

Code:

LoginView.ts (Presenter)

import { ChangeEvent, createElement, FunctionComponent } from "react";

export interface LoginViewProps {
    username: string;
    password: string;
    onChange: (event: ChangeEvent<HTMLInputElement>) => void;
    onSubmit: () => void;
}

export const LoginView: FunctionComponent<LoginViewProps> = (props: LoginViewProps) => {

    return createElement("div", {},
        createElement("label", {
            htmlFor: "username",
        },
            "Username:",
        ),
        createElement("input", {
            "data-testid": "username",
            type: "text",
            name: "username",
            id: "username",
            value: props.username,
            onChange: props.onChange,
        }),
        createElement("label", {
            htmlFor: "password",
        },
            "Password:",
        ),
        createElement("input", {
            "data-testid": "password",
            type: "password",
            name: "password",
            id: "password",
            value: props.password,
            onChange: props.onChange,
        }),
        createElement("button", {
            type: "button",
            "data-testid": "submit",
            onClick: props.onSubmit,
        },
            "Sign in",
        ),
    );
};

export default LoginView;

LoginView.test.ts - Test for the View

import "@testing-library/jest-dom";
import { createElement } from "react";
import { render, fireEvent, cleanup } from "@testing-library/react";
import LoginView, { LoginViewProps } from "./LoginView";

afterEach(cleanup);

describe("Login Presenter", () => {

    /**
     * This works, more coincidence than knowledge
     */
    it("should display div with blank values", async () => {
        const { findByTestId } = renderLoginForm();

        const username = await findByTestId("username");
        const password = await findByTestId("password");

        expect(username).toHaveValue("");
        expect(password).toHaveValue("");
    });

    /**
     * This is not working
     */
    it("should allow entering a username", async () => {
        const onChange = jest.fn();
        const { findByTestId } = renderLoginForm({
            onChange,
        });
        const username = await findByTestId("username");

        fireEvent.change(username, {
            target: {
                id: "username",
                value: "test",
            },
        });

        /**
         * This expect is wrong,
         * received: Object {...}
         * expected: SyntheticBaseEvent {...}
         */
        expect(onChange).toHaveBeenCalledWith({
            target: {
                id: "username",
                value: "test",
            },
        });
    });

    /**
     * This is not working
     */
    it("should allow entering a password", async () => {
        const onChange = jest.fn();
        const { findByTestId } = renderLoginForm({
            onChange,
        });
        const password = await findByTestId("password");

        fireEvent.change(password, {
            target: {
                id: "password",
                value: "test",
            },
        });

        /**
         * This expect is wrong,
         * received: Object {...}
         * expected: SyntheticBaseEvent {...}
         */
        expect(onChange).toHaveBeenCalledWith({
            target: {
                id: "password",
                value: "test",
            },
        });
    });

    it("should submit the form with username, password", async () => {
        /**
         * What to write here?
         *
         * How can i test the values that i provided
         */
    });
});


function renderLoginForm(props: Partial<LoginViewProps> = {}) {
    const defaultProps: LoginViewProps = {
        username: "",
        password: "",
        onChange() {
            return;
        },
        onSubmit() {
            return;
        },
    };
    return render(createElement(LoginView, {
        ...defaultProps,
        ...props,
    }));
}

Error:

> jest

 FAIL  src/react/LoginForm/LoginView.test.ts
  ● Login Presenter › should allow entering a username

    expect(jest.fn()).toHaveBeenCalledWith(...expected)

    - Expected
    + Received

    - Object {
    -   "target": Object {
    -     "id": "username",
    -     "value": "test",
    + SyntheticBaseEvent {
    +   "_reactName": "onChange",
    +   "_targetInst": null,
    +   "bubbles": true,
    +   "cancelable": false,
    +   "currentTarget": null,
    +   "defaultPrevented": false,
    +   "eventPhase": 3,
    +   "isDefaultPrevented": [Function functionThatReturnsFalse],
    +   "isPropagationStopped": [Function functionThatReturnsFalse],
    +   "isTrusted": false,
    +   "nativeEvent": Event {
    +     "isTrusted": false,
        },
    +   "target": <input
    +     data-testid="username"
    +     id="username"
    +     name="username"
    +     type="text"
    +     value=""
    +   />,
    +   "timeStamp": 1640165302072,
    +   "type": "change",
      },

    Number of calls: 1

      31 |              });
      32 |
    > 33 |              expect(onChange).toHaveBeenCalledWith({
         |                               ^
      34 |                      target: {
      35 |                              id: "username",
      36 |                              value: "test",

      at _callee2$ (src/react/LoginForm/LoginView.test.ts:33:20)
      at tryCatch (node_modules/regenerator-runtime/runtime.js:63:40)
      at Generator.invoke [as _invoke] (node_modules/regenerator-runtime/runtime.js:294:22)
      at Generator.next (node_modules/regenerator-runtime/runtime.js:119:21)
      at asyncGeneratorStep (node_modules/@babel/runtime/helpers/asyncToGenerator.js:3:24)
      at _next (node_modules/@babel/runtime/helpers/asyncToGenerator.js:25:9)

  ● Login Presenter › should allow entering a password

    expect(jest.fn()).toHaveBeenCalledWith(...expected)

    - Expected
    + Received

    - Object {
    -   "target": Object {
    -     "id": "password",
    -     "value": "test",
    + SyntheticBaseEvent {
    +   "_reactName": "onChange",
    +   "_targetInst": null,
    +   "bubbles": true,
    +   "cancelable": false,
    +   "currentTarget": null,
    +   "defaultPrevented": false,
    +   "eventPhase": 3,
    +   "isDefaultPrevented": [Function functionThatReturnsFalse],
    +   "isPropagationStopped": [Function functionThatReturnsFalse],
    +   "isTrusted": false,
    +   "nativeEvent": Event {
    +     "isTrusted": false,
        },
    +   "target": <input
    +     data-testid="password"
    +     id="password"
    +     name="password"
    +     type="password"
    +     value=""
    +   />,
    +   "timeStamp": 1640165302102,
    +   "type": "change",
      },

    Number of calls: 1

      53 |              });
      54 |
    > 55 |              expect(onChange).toHaveBeenCalledWith({
         |                               ^
      56 |                      target: {
      57 |                              id: "password",
      58 |                              value: "test",

      at _callee3$ (src/react/LoginForm/LoginView.test.ts:55:20)
      at tryCatch (node_modules/regenerator-runtime/runtime.js:63:40)
      at Generator.invoke [as _invoke] (node_modules/regenerator-runtime/runtime.js:294:22)
      at Generator.next (node_modules/regenerator-runtime/runtime.js:119:21)
      at asyncGeneratorStep (node_modules/@babel/runtime/helpers/asyncToGenerator.js:3:24)
      at _next (node_modules/@babel/runtime/helpers/asyncToGenerator.js:25:9)

 PASS  src/react/LoginForm/Login.test.ts

Test Suites: 1 failed, 1 passed, 2 total
Tests:       2 failed, 6 passed, 8 total
Snapshots:   0 total
Time:        4.035 s
Ran all test suites.
npm ERR! Test failed.  See above for more details.

Gist:

https://gist.github.com/simann/9cbf01f28602d59ba988ef608df99bc0

Final remarks:

Besides the error above, i am still in need for some tests of the submit function, if anyone could provide me with some information i would be really grateful.

Any other hints or improvemets are welcome aswell.

EDIT

For clarification i will add the code of the container aswell

Login.ts

import { ChangeEvent, Component, createElement } from "react";
import LoginForm from "./LoginView";

interface LoginState {
    password: string;
    username: string;
}

export class Login extends Component<null, LoginState> {

    constructor(props: null) {
        super(props);
        this.state = {
            password: "",
            username: "",
        };

        this.onChange = this.onChange.bind(this);
        this.onSubmit = this.onSubmit.bind(this);
    }

    onChange(event: ChangeEvent<HTMLInputElement>) {
        const { id, value } = event.target;
        this.setState({
            ...this.state,
            [id]: value,
        });
    }


    onSubmit() {
        console.log(this.state.username, this.state.password);
        /**
         * Do a websocket request with the values
         */
    }

    render() {
        return createElement(LoginForm, {
            password: this.state.password,
            username: this.state.username,
            onChange: this.onChange,
            onSubmit: this.onSubmit,
        });
    }

}

export default Login;

Login.test.ts

import "@testing-library/jest-dom";
import { createElement } from "react";
import { render, fireEvent, cleanup } from "@testing-library/react";
import Login from "./Login";

afterEach(cleanup);

describe("Login Container", () => {
    it("should display a blank login form with blank values", async () => {
        const { findByTestId } = renderLogin();

        const username = await findByTestId("username");
        const password = await findByTestId("password");

        expect(username).toHaveValue("");
        expect(password).toHaveValue("");
    });

    it("should allow entering a username", async () => {
        const { findByTestId } = renderLogin();
        const username = await findByTestId("username");

        fireEvent.change(username, {
            target: {
                id: "username",
                value: "test",
            },
        });

        expect(username).toHaveValue("test");
    });

    it("should allow entering a password", async () => {
        const { findByTestId } = renderLogin();
        const password = await findByTestId("password");

        fireEvent.change(password, {
            target: {
                id: "password",
                value: "test",
            },
        });

        expect(password).toHaveValue("test");
    });

    it("should submit the form with username, password", async () => {
        /**
         * What to write here?
         *
         * How do i test the values that are in my state?
         */
    });
});


function renderLogin() {
    return render(createElement(Login));
}

2 Answers 2

1

The tests are mainly failing because you're comparing two different objects.

The object passed by React to the event handler is an instance of SyntheticEvent which is a wrapper around the browser's native event, and is more complex than the object you're comparing it to.

Despite having a similar interface to the native event, the SyntheticEvent class is an implementation detail of React and you should avoid implementing your tests around its structure, especially for object matching tests.

A better approach is it to wrap your onChange prop inside another function which will call it passing only the event value.

...
createElement("input", {
    "data-testid": "username",
    type: "text",
    name: "username",
    id: "username",
    value: props.username,
    onChange: (event) => {
        props.onChange(event.target.value);
    },
})
...

Then, on your test you can write:

...
fireEvent.change(password, {
    target: {
        value: "test",
    },
});

expect(onChange).toHaveBeenCalledWith("test");
...

To test submitted values, use React useState to save the values of your input fields.

...
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
...
createElement("input", {
    "data-testid": "username",
    type: "text",
    name: "username",
    id: "username",
    value: props.username,
    onChange: (event) => {
        setUsername(event.target.value);
        props.onChange(event.target.value);
    },
}),
createElement("input", {
    "data-testid": "password",
    type: "password",
    name: "password",
    id: "password",
    value: props.password,
    onChange: () => {
        setPassword(event.target.value);
        props.onChange(event.target.value);
    },
}),
createElement("button", {
    type: "button",
    "data-testid": "submit",
    onClick: () => {
        props.onSubmit(username, password)
    },
},
    "Sign in",
)

Then in your test you ca

it("should submit the form with username, password", async () => {
    const onSubmit = jest.fn();
    const { findByTestId } = renderLoginForm({
        onSubmit,
    });

    const username = await findByTestId("username");
    const password = await findByTestId("password");
    const submit = await findByTestId("submit");

    fireEvent.change(username, { target: { value: "username" } });
    fireEvent.change(password, { target: { value: "password" } });
    fireEvent.click(submit);

    expect(onSubmit).toHaveBeenCalledWith("username", "password");
});

Aside from that, you should use JSX to create components instead of createElement. 🙂

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for your response. I got that part with the onChange function. The part i dont get is about const [username, setUsername] = React.useState("");. I would introduce a second variable for the same data and use that in the login function. That would make the variables in the container obsolete?
the other way is to use refs to keep a reference to the input field and you access its value inside the submit handler.
0

By your current component code and test in

expect(onChange).toHaveBeenCalledWith({
  target: {
    id: "username",
    value: "test",
  },
});

You actually want to check "there is object that contains target: { id: "username", value: "test" } while currently you check "it's exactly that object". Here expect.objectContaining helps:

expect(onChange).toHaveBeenCalledWith(
  expect.objectContaining({
    target: {
      id: "username",
      value: "test",
    },
  })
);

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.