测试时在 React 组件回调上使用 sinon fakes

Using sinon fakes on React component callbacks when testing

我遇到了无法解释的事情。我有以下测试:

import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect } from 'chai';
import * as sinon from 'sinon';
import TestApp from '../../views/App.test';
import ReplicationForm from './ReplicationForm';

describe('ReplicationForm component', () => {
  it('calls saveUrl on submit with new url', () => {
    const saveUrlFake = sinon.fake();
    const otherFake = (url: string) => console.log(url);

    render(<ReplicationForm saveUrl={otherFake} />, { wrapper: TestApp });
    (screen.getByLabelText('Hostname') as HTMLInputElement).value = 'https://example.com';
    (screen.getByLabelText('Database') as HTMLInputElement).value = 'poi';
    (screen.getByLabelText('Username') as HTMLInputElement).value = 'shaw';
    (screen.getByLabelText('Password') as HTMLInputElement).value = 'IMissYouR00t';
    userEvent.click(screen.getByRole('button', { name: 'Submit' }));

    console.log(saveUrlFake.callCount);
    expect(saveUrlFake.lastCall.firstArg).to.equal('https://shaw:IMissYouR00t@example.com/poi');
  });
});

当我使用 otherFake 时,回调被正确调用,所需的输出被打印到控制台。不幸的是,如果我使用 saveUrlFake 它永远不会被调用。 saveUrlFake.callCount0 并且没有要检查的参数。

奇怪的是,当我用 saveUrlFake('https://shaw:IMissYouR00t@example.com/poi') 调用 saveUrlFake 时,测试成功了。我的问题是,为什么我的 sinon fake 没有被 React 组件调用?

tl;博士

有问题的完整组件:

import styled from '@emotion/styled';
import { Button, TextField, withTheme } from '@material-ui/core';
import React from 'react';
import { useForm } from 'react-hook-form';
import { FormattedMessage, useIntl } from 'react-intl';

const StyledForm = withTheme(
  styled.form`
    display: flex;
    flex-direction: column;

    & > *:not(div:first-of-type) {
      margin-top: ${({ theme }) => theme.spacing(2)}px;
    }
  `
);

type FormModel = {
  origin: string;
  database: string;
  username: string;
  password: string;
};

type ReplicationFormProps = {
  url?: string;
  saveUrl: (url: string) => void;
};

export default function ReplicationForm({ url, saveUrl }: ReplicationFormProps) {
  const intl = useIntl();
  const getDefaultValuesFromURL = (inputUrl: string): FormModel => {
    const { origin, username, password, pathname } = new URL(inputUrl);
    return {
      origin,
      username,
      password,
      database: pathname.replaceAll('/', '')
    };
  };
  const defaultValues: FormModel = url
    ? getDefaultValuesFromURL(url)
    : {
        origin: '',
        database: '',
        username: '',
        password: ''
      };
  const { handleSubmit, formState, register } = useForm<FormModel>({ defaultValues });

  const onSubmit = (data: FormModel) => {
    const newUrl = new URL(data.origin);
    newUrl.username = data.username;
    newUrl.password = data.password;
    newUrl.pathname = `/${data.database}`;

    saveUrl(newUrl.toString());
  };

  return (
    <StyledForm onSubmit={handleSubmit(onSubmit)}>
      <TextField
        id="replication-form-origin"
        name="origin"
        inputRef={register}
        label={intl.formatMessage({ id: 'label.hostname', defaultMessage: 'Hostname' })}
        fullWidth
      />
      <TextField
        id="replication-form-database"
        name="database"
        inputRef={register}
        label={intl.formatMessage({ id: 'label.database', defaultMessage: 'Database' })}
        fullWidth
      />
      <TextField
        id="replication-form-username"
        name="username"
        inputRef={register}
        label={intl.formatMessage({ id: 'label.username', defaultMessage: 'Username' })}
        fullWidth
      />
      <TextField
        id="replication-form-password"
        name="password"
        type="password"
        inputRef={register}
        label={intl.formatMessage({ id: 'label.password', defaultMessage: 'Password' })}
        fullWidth
      />

      <Button type="submit" color="primary" disabled={formState.isDirty}>
        <FormattedMessage id="action.submit" defaultMessage="Submit" />
      </Button>
    </StyledForm>
  );
}

回调好像是异步调用的,需要等待。测试库提供 waitFor(),在这种情况下会有所帮助。

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect } from 'chai';
import * as sinon from 'sinon';
import TestApp from '../../views/App.test';
import ReplicationForm from './ReplicationForm';

describe('ReplicationForm component', () => {
  it('calls saveUrl on submit with new url', async () => {
    const saveUrlFake = sinon.fake();

    render(<ReplicationForm saveUrl={saveUrlFake} />, { wrapper: TestApp });
    userEvent.type(screen.getByLabelText('Hostname *') as HTMLInputElement, 'https://example.com');
    userEvent.type(screen.getByLabelText('Database *'), 'poi');
    userEvent.type(screen.getByLabelText('Username *'), 'shaw');
    userEvent.type(screen.getByLabelText('Password *'), 'IMissYouR00t');
    userEvent.click(screen.getByRole('button', { name: 'Submit' }));

    await waitFor(() => expect(saveUrlFake.called).to.be.true);
    expect(saveUrlFake.lastCall.firstArg).to.equal('https://shaw:IMissYouR00t@example.com/poi');
  });
});