Chakra UI v3のNumberInputでuserEvent.typeを用いて未入力テストを行うとエラーになる（Expected key descriptor but found "" in ""）

# はじめに

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

Chakra UI v3で実装した新規登録モーダルのバリデーションテストを行っているのですが、未入力テストを実装中にエラーが発生しました。

今回は発生したエラーとその解消方法について共有します。

# 問題

以下のような学習内容を登録できるモーダルがあります。
このモーダルでは、学習内容を未入力で登録すると「時間の入力が必須です」とエラーメッセージが表示される仕様です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/c4dff5e8-79f1-bcfe-05e1-b794d1969c5e.png)

今回、学習時間を未入力で登録した際に、上記のエラーメッセージが表示されて登録がされないことを確認するためのテスト実装をしていましたが、以下のエラーが発生してしまいました。

## エラー内容

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

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

    Expected key descriptor but found "" in ""
        See https://testing-library.com/docs/user-event/keyboard
        for more information about how userEvent parses your input.

      at assertDescriptor (node_modules/@testing-library/user-event/dist/cjs/utils/keyDef/readNextDescriptor.js:75:15)
      at readPrintableChar (node_modules/@testing-library/user-event/dist/cjs/utils/keyDef/readNextDescriptor.js:31:5)
      at Object.readNextDescriptor (node_modules/@testing-library/user-event/dist/cjs/utils/keyDef/readNextDescriptor.js:26:26)
      at Object.parseKeyDef (node_modules/@testing-library/user-event/dist/cjs/keyboard/parseKeyDef.js:23:118)
      at Object.keyboard (node_modules/@testing-library/user-event/dist/cjs/keyboard/index.js:14:33)
      at Object.type (node_modules/@testing-library/user-event/dist/cjs/utility/type.js:23:16)
      at Object.asyncWrapper (node_modules/@testing-library/react/dist/pure.js:88:22)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        5.129 s, estimated 9 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('学習時間が未入力で登録するとエラーが表示され、登録がされないこと', 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'), '');
    await userEvent.click(screen.getByTestId('create-submit-button'));

    await waitFor(() => {
      const errorMessage = screen.getByText('時間の入力は必須です');
      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>

# 解決方法

学習記録の入力値が空である状態をシミュレートするため`await userEvent.type(screen.getByTestId('input-time'), '');`と記載していたのですが、これが誤りのようです。

このメソッドは、指定された文字列を「入力」する動作をシミュレートするのですが、第2引数に空の文字列を渡すとエラーになります。

今回、学習内容のinput要素はデフォルト値が`0`として入力されているため、未入力であることを確認したい場合は`userEvent.clear`を使用します。

```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('学習時間が未入力で登録するとエラーが表示され、登録がされないこと', 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'), '');
+   await userEvent.clear(screen.getByTestId('input-time')); // userEvent.typeではなくuserEvent.clearを使用
    await userEvent.click(screen.getByTestId('create-submit-button'));

    await waitFor(() => {
      const errorMessage = screen.getByText('時間の入力は必須です');
      expect(errorMessage).toBeInTheDocument();
    });

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

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

# おわりに

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

# 参考

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