はじめに
お疲れ様です、りつです。
Chakra UI v3で実装した新規登録モーダルのバリデーションテストを行っているのですが、マイナス値のバリデーションテストでかなりハマってしまいました。
今回はその解消方法を共有します。
問題
以下のような新規登録モーダルを実装しています。
テスト内容としては以下の内容です。
- 学習時間の入力値が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.
問題のテストコード
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
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.type
にinitialSelectionStart
とinitialSelectionEnd
オプションを指定する
まずは、userEvnet.type
にinitialSelectionStart
とinitialSelectionEnd
オプションを指定する方法です。
By default,
type
appends to the existing text. To prepend text, reset the element's selection range and provide theinitialSelectionStart
andinitialSelectionEnd
options:
userEvnet.type
は、既に存在する入力値に追加でテキスト入力を行います。
そのため、今回の学習時間の項目のように、デフォルト値(0
)があらかじめ指定されていると、想定された内容でのテストができずエラーになります。
上記のオプションを指定することで、カーソルによるテキストの範囲選択を行うことができ、テキストを挿入したり、置換することができます。
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');
});
- 挿入のサンプル
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!');
});
- テスト結果
解決方法2:userEvenet.clear
を使用する
2つ目は、以下のようにuserEvent.type
の前にuserEvenet.clear
を実行する方法です。
userEvnet.clear
で、input要素の値をクリアすることができます。
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