1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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.)

Last updated at Posted at 2024-12-30

はじめに

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

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

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

問題

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

image.png

テスト内容としては以下の内容です。

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

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

エラー内容

エラー内容
 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.

問題のテストコード

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);
    });
  });
});
src/App.tsx
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;

解決方法

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

解決方法1:userEvnet.typeinitialSelectionStartinitialSelectionEndオプションを指定する

まずは、userEvnet.typeinitialSelectionStartinitialSelectionEndオプションを指定する方法です。

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)があらかじめ指定されていると、想定された内容でのテストができずエラーになります。

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

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

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

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

  • 置換のサンプル
サンプルテスト
  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');
  });
  • 挿入のサンプル
サンプルテスト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

解決方法2:userEvenet.clearを使用する

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

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

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);
    });
  });
});
  • 参考:

おわりに

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

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

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

参考

  • userEvent.type

  • userEvent.clear

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?