Chakra UI v3のNumberInputでuserEvent.typeを用いてマイナス値のテストを行うとエラーになる（Unable to find an element with the text: xxx. This could be because the text is broken up by multiple elements.）

# はじめに

お疲れ様です、りつです。

Chakra UI v3で実装した新規登録モーダルのバリデーションテストを行っているのですが、マイナス値のバリデーションテストでかなりハマってしまいました。

今回はその解消方法を共有します。

# 問題

以下のような新規登録モーダルを実装しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/bf5c586e-78ef-f34f-186f-a7705d45947f.png)

テスト内容としては以下の内容です。
- 学習時間の入力値が0以上でない場合は
    - 「時間は0以上である必要があります」というエラーメッセージが表示されること
    - データが登録されないこと（登録前後で学習記録一覧の件数が変わっていないこと）

上記テストの実装中に以下のエラーが発生しました。

## エラー内容

```terminal:エラー内容
 FAIL  src/__tests__/App.spec.tsx (5.797 s)
  App
    ✕ 学習時間が0未満で登録するとエラーが表示され、登録がされないこと (2304 ms)

  ● App › 学習時間が0未満で登録するとエラーが表示され、登録がされないこと

    Unable to find an element with the text: 時間は0以上である必要があります. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    Ignored nodes: comments, script, style
    <body
      style="padding-right: 1024px;"
    >
      <div>
        <div
          class="css-190ipwd"
        >
          <div
            class="chakra-container css-zc1oi5"
          >
            <div
              class="css-1lekzkb"
            >
              <h1
                class="chakra-heading css-knndju"
                data-testid="title"
              >
                学習記録アプリ
              </h1>
              <button
                aria-label="Search database"
                class="chakra-button css-5ix368"
                data-testid="create-button"
                type="button"
              >
                <svg
                  fill="none"
                  height="1em"
                  stroke="currentColor"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  viewBox="0 0 24 24"
                  width="1em"
                  xmlns="http://www.w3.org/2000/svg"
                >
                  <circle
                    cx="12"
                    cy="12"
                    r="10"
                  />
                  <line
                    x1="12"
                    x2="12"
                    y1="8"
                    y2="16"
                  />
                  <line
                    x1="8"
                    x2="16"
                    y1="12"
                    y2="12"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>
        <div
          class="chakra-container css-zc1oi5"
        >
          <table
            class="chakra-table__root css-1feks01"
            data-testid="study-record-list"
          >
            <thead
              class="chakra-table__header css-0"
            >
              <tr
                class="chakra-table__row css-11ot24d"
              >
                <th
                  class="chakra-table__columnHeader css-kim7sg"
                >
                  タイトル
                </th>
                <th
                  class="chakra-table__columnHeader css-kim7sg"
                >
                  時間
                </th>
                <th
                  class="chakra-table__columnHeader css-1nyjmsg"
                />
              </tr>
            </thead>
            <tbody
              class="chakra-table__body css-go1ol1"
            >
              <tr
                class="chakra-table__row css-11ot24d"
              >
                <td
                  class="chakra-table__cell css-1luypx0"
                >
                  テスト記録
                </td>
                <td
                  class="chakra-table__cell css-1luypx0"
                >
                  20
                </td>
                <td
                  class="chakra-table__cell css-g0u61"
                >
                  <button
                    class="chakra-button css-1x8pfrc"
                    type="button"
                  >
                    <svg
                      fill="currentColor"
                      height="1em"
                      stroke="currentColor"
                      stroke-width="0"
                      viewBox="0 0 24 24"
                      width="1em"
                      xmlns="http://www.w3.org/2000/svg"
                    >
                      <path
                        d="M0 0h24v24H0z"
                        fill="none"
                      />
                      <path
                        d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a.996.996 0 0 0 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
                      />
                    </svg>
                    編集
                  </button>
                  <button
                    class="chakra-button css-c1i8nm"
                    type="button"
                  >
                    <svg
                      fill="currentColor"
                      height="1em"
                      stroke="currentColor"
                      stroke-width="0"
                      viewBox="0 0 24 24"
                      width="1em"
                      xmlns="http://www.w3.org/2000/svg"
                    >
                      <path
                        d="M0 0h24v24H0V0z"
                        fill="none"
                      />
                      <path
                        d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5-1-1h-5l-1 1H5v2h14V4z"
                      />
                    </svg>
                    削除
                  </button>
                </td>
              </tr>
              <tr
                class="chakra-table__row css-11ot24d"
              >
                <td
                  class="chakra-table__cell css-1luypx0"
                >
                  テスト記録
                </td>
                <td
                  class="chakra-table__cell css-1luypx0"
                >
                  30
                </td>
                <td
                  class="chakra-table__cell css-g0u61"
                >
                  <button
                    class="chakra-button css-1x8pfrc"
                    type=...

      23 |     await userEvent.click(screen.getByTestId('create-submit-button'));
      24 |
    > 25 |     await waitFor(() => {
         |                  ^
      26 |       const errorMessage = screen.getByText('時間は0以上である必要があります');
      27 |       expect(errorMessage).toBeInTheDocument();
      28 |     });

      at waitForWrapper (node_modules/@testing-library/dom/dist/wait-for.js:163:27)
      at src/__tests__/App.spec.tsx:25:18
      at fulfilled (src/__tests__/App.spec.tsx:5:58)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        6.389 s, estimated 7 s
Ran all test suites.
```

## 問題のテストコード

```react:src/__tests__/App.spec.tsx
import App from '../App';
import { render, screen, waitFor } from '@testing-library/react';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
import userEvent from '@testing-library/user-event';

describe('App', () => {
  beforeEach(() => {
    render(
      <ChakraProvider value={defaultSystem}>
        <App />
      </ChakraProvider>
    );
  });

  test('学習時間が0未満で登録するとエラーが表示され、登録がされないこと', async () => {
    const beforeLists = await screen.findAllByRole('row');

    await userEvent.click(screen.getByTestId('create-button'));

    await userEvent.type(screen.getByTestId('input-title'), 'テスト記録');
    await userEvent.type(screen.getByTestId('input-time'), '-1');

    await userEvent.click(screen.getByTestId('create-submit-button'));

    await waitFor(() => {
      const errorMessage = screen.getByText('時間は0以上である必要があります');
      expect(errorMessage).toBeInTheDocument();
    });

    await userEvent.click(screen.getByTestId('create-cancel-button'));

    await waitFor(() => {
      const afterLists = screen.getAllByRole('row');
      expect(afterLists).toHaveLength(beforeLists.length);
    });
  });
});
```

<details><summary>src/App.tsx</summary>

```react:src/App.tsx
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { MdEdit, MdDeleteOutline } from 'react-icons/md';
import { FiPlusCircle } from 'react-icons/fi';
import { Box, Center, Container, Flex, Heading, IconButton, Input, Spinner, Stack, Table } from '@chakra-ui/react';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { NumberInputField, NumberInputRoot } from '@/components/ui/number-input';
import { Toaster } from '@/components/ui/toaster';
import { Record } from '@/domain/record';
import { useMessage } from '@/hooks/useMessage';
import { fetchAllRecords, insertRecord, deleteRecord } from '@/utils/supabaseFunctions';
import {
  DialogActionTrigger,
  DialogBody,
  DialogCloseTrigger,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogRoot,
  DialogTitle,
} from '@/components/ui/dialog';

function App() {
  const [records, setRecords] = useState<Record[]>([]);
  const [deleteTargetId, setDeleteTargetId] = useState<string | undefined>(undefined);
  const [isLoading, setIsLoading] = useState(false);
  const [isCreating, setIsCreating] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);
  const [open, setOpen] = useState(false);
  const [openConfirm, setOpenConfirm] = useState(false);
  const { showMessage } = useMessage();
  const {
    control,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<Record>({
    defaultValues: {
      title: '',
      time: '0',
    },
  });

  useEffect(() => {
    getAllRecords();
  }, []);

  const onClickOpenModal = () => {
    setOpen(true);
  };

  const getAllRecords = () => {
    setIsLoading(true);
    fetchAllRecords()
      .then((data) => {
        setRecords(data);
      })
      .catch(() => {
        showMessage({ title: '一覧の取得に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsLoading(false);
      });
  };

  const onSubmit = handleSubmit((data: Record) => {
    setIsCreating(true);
    insertRecord(data)
      .then(() => {
        showMessage({ title: '学習記録の登録が完了しました', type: 'success' });
      })
      .catch(() => {
        showMessage({ title: '学習記録の登録に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsCreating(false);
        reset(); // 登録フォームの初期化
        setOpen(false);
        getAllRecords();
      });
  });

  const onClickDeleteConfirm = (id: string) => {
    setDeleteTargetId(id);
    setOpenConfirm(true);
  };

  const onClickDelete = () => {
    setIsDeleting(true);

    if (typeof deleteTargetId === 'undefined') {
      showMessage({ title: '削除対象のデータが見つかりません', type: 'error' });
      setIsDeleting(false);
      setOpenConfirm(false);
      setDeleteTargetId(undefined);
      return;
    }

    deleteRecord(deleteTargetId)
      .then(() => {
        showMessage({ title: '学習記録の削除が完了しました', type: 'success' });
      })
      .catch(() => {
        showMessage({ title: '学習記録の削除に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsDeleting(false);
        setOpenConfirm(false);
        setDeleteTargetId(undefined);
        getAllRecords();
      });
  };

  return (
    <>
      <Toaster />

      <Box bg="teal.500" py="4" color="gray.100">
        <Container maxW="6xl">
          <Flex justify="space-between" align="center">
            <Heading as="h1" textAlign="left" data-testid="title">
              学習記録アプリ
            </Heading>
            <IconButton
              aria-label="Search database"
              variant="ghost"
              size="lg"
              color="white"
              _hover={{ bg: 'teal.500', color: 'gray.200' }}
              onClick={onClickOpenModal}
              data-testid="create-button"
            >
              <FiPlusCircle />
            </IconButton>
          </Flex>
        </Container>
      </Box>

      {isLoading ? (
        <Center h="100vh">
          <Spinner data-testid="loading" />
        </Center>
      ) : (
        <Container maxW="6xl">
          <Table.Root size="md" variant="line" my={10} interactive data-testid="study-record-list">
            <Table.Header>
              <Table.Row>
                <Table.ColumnHeader>タイトル</Table.ColumnHeader>
                <Table.ColumnHeader>時間</Table.ColumnHeader>
                <Table.ColumnHeader textAlign="end"></Table.ColumnHeader>
              </Table.Row>
            </Table.Header>
            <Table.Body>
              {records.map((record) => (
                <Table.Row key={record.id}>
                  <Table.Cell>{record.title}</Table.Cell>
                  <Table.Cell>{record.time}</Table.Cell>
                  <Table.Cell textAlign="end">
                    <Button colorPalette="blue" variant="outline" mr="4">
                      <MdEdit />
                      編集
                    </Button>
                    <Button colorPalette="red" variant="outline" onClick={() => onClickDeleteConfirm(record.id)}>
                      <MdDeleteOutline />
                      削除
                    </Button>
                  </Table.Cell>
                </Table.Row>
              ))}
            </Table.Body>
          </Table.Root>
        </Container>
      )}

      <DialogRoot lazyMount open={open} onOpenChange={(e) => setOpen(e.open)} motionPreset="slide-in-bottom" trapFocus={false}>
        <DialogContent>
          <DialogCloseTrigger />
          <DialogHeader>
            <DialogTitle>新規登録</DialogTitle>
          </DialogHeader>
          <form onSubmit={onSubmit}>
            <DialogBody>
              <Stack gap={4}>
                <Field label="学習内容" invalid={!!errors.title} errorText={errors.title?.message}>
                  <Controller
                    name="title"
                    control={control}
                    rules={{
                      required: '内容の入力は必須です',
                    }}
                    render={({ field }) => <Input {...field} data-testid="input-title" />}
                  />
                </Field>
                <Field label="学習時間" invalid={!!errors.time} errorText={errors.time?.message}>
                  <Controller
                    name="time"
                    control={control}
                    rules={{
                      required: '時間の入力は必須です',
                      min: { value: 0, message: '時間は0以上である必要があります' },
                    }}
                    render={({ field }) => (
                      <NumberInputRoot width="100%" name={field.name} value={field.value} onValueChange={({ value }) => field.onChange(value)}>
                        <NumberInputField data-testid="input-time" />
                      </NumberInputRoot>
                    )}
                  />
                </Field>
              </Stack>
            </DialogBody>
            <DialogFooter mb="2">
              <DialogActionTrigger asChild>
                <Button variant="outline" data-testid="create-cancel-button">
                  キャンセル
                </Button>
              </DialogActionTrigger>
              <Button colorPalette="teal" loading={isCreating} type="submit" data-testid="create-submit-button">
                登録
              </Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </DialogRoot>

      <DialogRoot
        role="alertdialog"
        lazyMount
        open={openConfirm}
        onOpenChange={(e) => setOpenConfirm(e.open)}
        motionPreset="slide-in-bottom"
        trapFocus={false}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>削除の確認</DialogTitle>
          </DialogHeader>
          <DialogBody>
            <p>削除したデータは戻せません。削除してもよろしいですか？</p>
          </DialogBody>
          <DialogFooter mb="2">
            <DialogActionTrigger asChild>
              <Button variant="outline">キャンセル</Button>
            </DialogActionTrigger>
            <Button colorPalette="red" loading={isDeleting} onClick={onClickDelete}>
              削除
            </Button>
          </DialogFooter>
          <DialogCloseTrigger />
        </DialogContent>
      </DialogRoot>
    </>
  );
}

export default App;
```

</details>

# 解決方法

試したところ、テストが通るパターンが2種類ありましたのでそれぞれのテストコードをご紹介します。

## 解決方法1：`userEvnet.type`に`initialSelectionStart`と`initialSelectionEnd`オプションを指定する

まずは、`userEvnet.type`に`initialSelectionStart`と`initialSelectionEnd`オプションを指定する方法です。

https://testing-library.com/docs/user-event/v13#typeelement-text-options

> By default, `type` appends to the existing text. To prepend text, reset the element's selection range and provide the `initialSelectionStart` and `initialSelectionEnd` options:

`userEvnet.type`は、既に存在する入力値に追加でテキスト入力を行います。

そのため、今回の学習時間の項目のように、デフォルト値（`0`）があらかじめ指定されていると、想定された内容でのテストができずエラーになります。

上記のオプションを指定することで、カーソルによるテキストの範囲選択を行うことができ、テキストを挿入したり、置換することができます。

```diff_tsx:src/__tests__/App.spec.tsx
import App from '../App';
import { render, screen, waitFor } from '@testing-library/react';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
import userEvent from '@testing-library/user-event';

describe('App', () => {
  beforeEach(() => {
    render(
      <ChakraProvider value={defaultSystem}>
        <App />
      </ChakraProvider>
    );
  });

  test('学習時間が0未満で登録するとエラーが表示され、登録がされないこと', async () => {
    const beforeLists = await screen.findAllByRole('row');

    await userEvent.click(screen.getByTestId('create-button'));

    await userEvent.type(screen.getByTestId('input-title'), 'テスト記録');
-   await userEvent.type(screen.getByTestId('input-time'), '-1');
+   await userEvent.type(screen.getByTestId('input-time'), '-1', {
+     initialSelectionStart: 0, // initialSelectionStartオプションを指定
+     initialSelectionEnd: 1,   // initialSelectionEndオプションを指定
+   });

    await userEvent.click(screen.getByTestId('create-submit-button'));

    await waitFor(() => {
      const errorMessage = screen.getByText('時間は0以上である必要があります');
      expect(errorMessage).toBeInTheDocument();
    });

    await userEvent.click(screen.getByTestId('create-cancel-button'));

    await waitFor(() => {
      const afterLists = screen.getAllByRole('row');
      expect(afterLists).toHaveLength(beforeLists.length);
    });
  });
});
```

### 補足：オプションの挙動について

オプションの挙動については、以下の例もご参考にしてみてください。

- 置換のサンプル
```react:サンプルテスト
  test('initialSelectionStartとinitialSelectionEndのサンプル', async () => {
    render(<input defaultValue="0" />);

    const input = screen.getByRole('textbox');

    // "0" を選択して "-1" に置き換える
    await userEvent.type(input, '-1', {
      initialSelectionStart: 0,
      initialSelectionEnd: 1,
    });

    expect(input).toHaveValue('-1');
  });
```

- 挿入のサンプル
```react:サンプルテスト2
  test('initialSelectionStartとinitialSelectionEndのサンプル2', async () => {
    render(<input defaultValue="Hello, world!" />);

    const input = screen.getByRole('textbox');

    // 6文字目の後ろに "new" を挿入
    await userEvent.type(input, 'new', {
      initialSelectionStart: 6,
      initialSelectionEnd: 6,
    });

    expect(input).toHaveValue('Hello,new world!');
  });
```

- テスト結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/d9d733b2-f4db-98cb-082d-dd6429488781.png)

## 解決方法2：`userEvenet.clear`を使用する

2つ目は、以下のように`userEvent.type`の前に`userEvenet.clear`を実行する方法です。

`userEvnet.clear`で、input要素の値をクリアすることができます。

```diff_tsx:src/__tests__/App.spec.tsx
import App from '../App';
import { render, screen, waitFor } from '@testing-library/react';
import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
import userEvent from '@testing-library/user-event';

describe('App', () => {
  beforeEach(() => {
    render(
      <ChakraProvider value={defaultSystem}>
        <App />
      </ChakraProvider>
    );
  });

  test('学習時間が0未満で登録するとエラーが表示され、登録がされないこと', async () => {
    const beforeLists = await screen.findAllByRole('row');

    await userEvent.click(screen.getByTestId('create-button'));

    await userEvent.type(screen.getByTestId('input-title'), 'テスト記録');
-   await userEvent.type(screen.getByTestId('input-time'), '-1');
+   const timeInput = screen.getByTestId('input-time');
+   await userEvent.clear(timeInput); // userEvnet.clearを追加
+   await userEvent.type(timeInput, '-1');

    await userEvent.click(screen.getByTestId('create-submit-button'));

    await waitFor(() => {
      const errorMessage = screen.getByText('時間は0以上である必要があります');
      expect(errorMessage).toBeInTheDocument();
    });

    await userEvent.click(screen.getByTestId('create-cancel-button'));

    await waitFor(() => {
      const afterLists = screen.getAllByRole('row');
      expect(afterLists).toHaveLength(beforeLists.length);
    });
  });
});
```

- 参考：

https://qiita.com/ritsu21ctws/items/695609bc6861e615e823

# おわりに

上記修正により、テストが無事通るようになりました。

ちなみに、以下のように`fireEvent.change`でvalue値を指定する方法もあるようなのですが、自分の状況の場合だと解決に至りませんでした（エラーメッセージも変わらずでした）

状況によっては以下の方法で解決できる可能性があるため、同様のエラーでお困りの際は以下の記事もご参照ください。

https://qiita.com/yuuki4135/items/dcdaa305b2075026b08d

# 参考

- `userEvent.type`

https://testing-library.com/docs/user-event/v13#typeelement-text-options

- `userEvent.clear`

https://testing-library.com/docs/user-event/v13#clearelement