こんにちは。
寝かしつけにはスクワットが効くので、絶賛足腰鍛え中のはこだてたろうです。
さて、前回の記事では、Perplexityに指示して入力フォームに文字数制限を追加しました。

このプロジェクトにはTestingLibraryが組み込んであります。
せっかくなので、この入力フォームの機能をテストしてみましょう。
入力欄をコンポーネント化する
現状のアプリはコンポーネント化しておらずフラットな状態です。
テストをしやすくするために、入力欄をコンポーネント化しておきましょう。
もちろん、この作業もPerplexityにお願いします。
import React, { useState, useMemo } from ‘react’;
import { TextField, Button, Box, Typography, LinearProgress } from ‘@mui/material’;
import “./App.css”
import reactLogo from ‘./assets/react.svg’
import useApi from ‘./hooks/useApi’;
function App() {
const [name, setName] = useState(”);
const [mail, setMail] = useState(”);
const { post } = useApi();
const handleNameChange = (e) => {
setName(e.target.value);
};
const handleMailChange = (e) => {
setMail(e.target.value);
};
const handleClick = () => {
post(name, mail);
};
const getProgressColor = (value, max) => {
return value > max ? ‘error’ : ‘primary’;
};
const renderProgress = (value, max) => {
const progress = (value / max) * 100;
return (
<Box sx={{ width: ‘100%’, mt: 1 }}>
<LinearProgress
variant=”determinate”
value={Math.min(progress, 100)}
color={getProgressColor(value, max)}
/>
<Typography variant=”caption” color={getProgressColor(value, max)}>
{value}/{max}
</Typography>
</Box>
);
};
const isFormValid = useMemo(() => {
return name.length <= 50 && mail.length <= 255;
}, [name, mail]);
return (
<Box sx={{ display: ‘flex’, flexDirection: ‘column’, alignItems: ‘center’, gap: 2, maxWidth: 300, margin: ‘auto’ }}>
<Box sx={{ display: ‘flex’, alignItems: ‘center’, gap: 2, marginBottom: 2 }}>
<img src={reactLogo} alt=”React Logo” style={{ width: 50, height: 50 }} />
<Typography variant=”h5″ component=”h1″>Sample System</Typography>
</Box>
<TextField
label=”Name”
variant=”outlined”
fullWidth
value={name}
onChange={handleNameChange}
/>
{renderProgress(name.length, 50)}
<TextField
label=”Mail Address”
variant=”outlined”
type=”email”
fullWidth
value={mail}
onChange={handleMailChange}
/>
{renderProgress(mail.length, 255)}
<Button
variant=”contained”
color=”primary”
onClick={handleClick}
disabled={!isFormValid}
>
送信
</Button>
</Box>
);
}
export default App;
上記のアプリには、入力欄(TextField)が2つあり、コンポーネント化する余地があると思います。
この入力欄には文字数制限があり、すぐ下には現在の入力文字数をインジケータで示す機能があります。
この部分をコンポーネント化してください。
なお、作成したコンポーネントは src/components ディレクトリ以下に配置するようお願いします。
ファイル名、コンポーネント名は、適した名前を提案してください。
入力値の state、labelの値、最大文字数は、親コンポーネントから渡す形が望ましいです。
この入力で LimitedTextField
コンポーネントが作成され、コンポーネント化することができました。
名前は LimitedTextField
なので「制限された TextField」という感じでしょうか。
いまある機能は文字数制限だけなので、LimitedLengthTextField
とかがふさわしいかもしれません。
ですが、そのうち文字種チェックなども入れるかもしれないので、ひとまずこれで良しとしておきます。
動作的には特に問題ありませんでしたが、型定義がされていなかったので指示を追加しました。
TypeScriptなので、型定義を追加してください
これらの指示で、最終的に以下の回答が得られました。
LimitedTextField コンポーネントに TypeScript の型定義を追加します。以下が修正後のコードです:
// src/components/LimitedTextField.tsx import React from 'react'; import { TextField, Box, Typography, LinearProgress } from '@mui/material'; interface LimitedTextFieldProps { label: string; value: string; onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; maxLength: number; } const LimitedTextField: React.FC<LimitedTextFieldProps> = ({ label, value, onChange, maxLength }) => { const getProgressColor = (value: number, max: number): 'error' | 'primary' => { return value > max ? 'error' : 'primary'; }; const renderProgress = (value: number, max: number) => { const progress = (value / max) * 100; return ( <Box sx={{ width: '100%', mt: 1 }}> <LinearProgress variant="determinate" value={Math.min(progress, 100)} color={getProgressColor(value, max)} /> <Typography variant="caption" color={getProgressColor(value, max)}> {value}/{max} </Typography> </Box> ); }; return ( <> <TextField label={label} variant="outlined" fullWidth value={value} onChange={onChange} /> {renderProgress(value.length, maxLength)} </> ); }; export default LimitedTextField;
App.tsx も以下のように修正します:
// App.tsx import React, { useState, useMemo } from 'react'; import { Button, Box, Typography } from '@mui/material'; import "./App.css" import reactLogo from './assets/react.svg' import useApi from './hooks/useApi'; import LimitedTextField from './components/LimitedTextField'; const App: React.FC = () => { const [name, setName] = useState(''); const [mail, setMail] = useState(''); const { post } = useApi(); const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { setName(e.target.value); }; const handleMailChange = (e: React.ChangeEvent<HTMLInputElement>) => { setMail(e.target.value); }; const handleClick = () => { post(name, mail); }; const isFormValid = useMemo(() => { return name.length <= 50 && mail.length <= 255; }, [name, mail]); return ( <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, maxWidth: 300, margin: 'auto' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, marginBottom: 2 }}> <img src={reactLogo} alt="React Logo" style={{ width: 50, height: 50 }} /> <Typography variant="h5" component="h1">Sample System</Typography> </Box> <LimitedTextField label="Name" value={name} onChange={handleNameChange} maxLength={50} /> <LimitedTextField label="Mail Address" value={mail} onChange={handleMailChange} maxLength={255} /> <Button variant="contained" color="primary" onClick={handleClick} disabled={!isFormValid} > 送信 </Button> </Box> ); } export default App;
これらの変更により、TypeScript の型定義が追加され、コードの型安全性が向上しました[1][3][5]。
Citations:
[1] https://qiita.com/dende-h/items/121e326f2c28dfc8bd91
[2] https://ja.react.dev/learn/typescript
[3] https://zenn.dev/manalink_dev/articles/react-typescript-for-beginner?redirected=1
[4] https://react-uncle-blog.netlify.app/blog/use-react-componentprops-type
[5] https://blog.stin.ink/articles/i-do-use-react-fc-type
[6] https://hireroo.io/journal/tech/extract-type-definitions-of-react-props-to-prevent-redefinition
前回までの記事で、「前提を伝えることが大事」と口酸っぱくお伝えしてきました。
このように「TypeScriptである」という前提を伝え漏れると、コードがJavaScriptで生成されてしまいます。
まぁ、この程度であれば指示を一つ追加することで軌道修正ができるので、特に問題にはなりませんね。
さて、生成されたコードはレビューするのを忘れないようにしましょう。
指示に入力した元々のコードがそうだったのですが、handleNameChange
とhandleMailChange
をわざわざ関数にする必要がないなと思ったので修正したいと思います。
handleNameChange、handleMailChange関数がありますが、これを除去して、onChange属性に無名関数で渡すよう変更してください
承知しました。handleNameChange と handleMailChange 関数を除去し、onChange 属性に直接無名関数を渡すように修正します。以下が更新された App.tsx の内容です:
// App.tsx import React, { useState, useMemo } from 'react'; import { Button, Box, Typography } from '@mui/material'; import "./App.css" import reactLogo from './assets/react.svg' import useApi from './hooks/useApi'; import LimitedTextField from './components/LimitedTextField'; const App: React.FC = () => { const [name, setName] = useState(''); const [mail, setMail] = useState(''); const { post } = useApi(); const handleClick = () => { post(name, mail); }; const isFormValid = useMemo(() => { return name.length <= 50 && mail.length <= 255; }, [name, mail]); return ( <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, maxWidth: 300, margin: 'auto' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, marginBottom: 2 }}> <img src={reactLogo} alt="React Logo" style={{ width: 50, height: 50 }} /> <Typography variant="h5" component="h1">Sample System</Typography> </Box> <LimitedTextField label="Name" value={name} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} maxLength={50} /> <LimitedTextField label="Mail Address" value={mail} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMail(e.target.value)} maxLength={255} /> <Button variant="contained" color="primary" onClick={handleClick} disabled={!isFormValid} > 送信 </Button> </Box> ); } export default App;
この修正により、handleNameChange と handleMailChange 関数が削除され、onChange 属性に直接無名関数が渡されるようになりました。これにより、コードがより簡潔になり、各入力フィールドの状態更新ロジックが直接的に表現されるようになりました。
LimitedTextFiled コンポーネントをテストする
入力欄のコンポーネント化ができたのでテストします。
// src/components/LimitedTextField.tsx
import React from ‘react’;
import { TextField, Box, Typography, LinearProgress } from ‘@mui/material’;
interface LimitedTextFieldProps {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
maxLength: number;
}
const LimitedTextField: React.FC<LimitedTextFieldProps> = ({ label, value, onChange, maxLength }) => {
const getProgressColor = (value: number, max: number): ‘error’ | ‘primary’ => {
return value > max ? ‘error’ : ‘primary’;
};
const renderProgress = (value: number, max: number) => {
const progress = (value / max) * 100;
return (
<Box sx={{ width: ‘100%’, mt: 1 }}>
<LinearProgress
variant=”determinate”
value={Math.min(progress, 100)}
color={getProgressColor(value, max)}
/>
<Typography variant=”caption” color={getProgressColor(value, max)}>
{value}/{max}
</Typography>
</Box>
);
};
return (
<>
<TextField
label={label}
variant=”outlined”
fullWidth
value={value}
onChange={onChange}
/>
{renderProgress(value.length, maxLength)}
</>
);
};
export default LimitedTextField;
上記のコンポーネントをテストするスクリプトを作成してください。
Vitest, TestingLibrary を使用しています。
テストファイルは、テスト対象のファイルと同じ階層に配置します。
すなわち、src/components/LimitedTextField.test.tsx となります。
describe や it に渡すテスト概要(第1引数)は日本語で作成してください。
承知しました。以下に、Vitest と Testing Library を使用して LimitedTextField コンポーネントをテストするスクリプトを作成しました。テストファイルは
src/components/LimitedTextField.test.tsx
に配置し、テストの概要は日本語で記述しています。// src/components/LimitedTextField.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import LimitedTextField from './LimitedTextField'; describe('LimitedTextField コンポーネント', () => { const mockOnChange = vi.fn(); beforeEach(() => { mockOnChange.mockClear(); }); it('正しいラベルでレンダリングされること', () => { render(<LimitedTextField label="テストラベル" value="" onChange={mockOnChange} maxLength={10} />); expect(screen.getByLabelText('テストラベル')).toBeDefined(); }); it('正しい文字数が表示されること', () => { render(<LimitedTextField label="テスト" value="こんにちは" onChange={mockOnChange} maxLength={10} />); expect(screen.getByText('5/10')).toBeDefined(); }); it('最大文字数を超えた場合にエラー色に変わること', () => { render(<LimitedTextField label="テスト" value="これは長すぎるテキストです" onChange={mockOnChange} maxLength={10} />); const progressBar = screen.getByRole('progressbar'); expect(progressBar.getAttribute('aria-valuenow')).toBe('100'); expect(screen.getByText('13/10')).toHaveStyle('color: rgb(211, 47, 47)'); // MUI エラー色 }); it('入力変更時に onChange 関数が呼び出されること', () => { render(<LimitedTextField label="テスト" value="" onChange={mockOnChange} maxLength={10} />); const input = screen.getByLabelText('テスト'); fireEvent.change(input, { target: { value: '新しい値' } }); expect(mockOnChange).toHaveBeenCalledTimes(1); }); it('LinearProgress が正しい値でレンダリングされること', () => { render(<LimitedTextField label="テスト" value="12345" onChange={mockOnChange} maxLength={10} />); const progressBar = screen.getByRole('progressbar'); expect(progressBar.getAttribute('aria-valuenow')).toBe('50'); }); it('最大文字数を超えていない場合にプライマリカラーが維持されること', () => { render(<LimitedTextField label="テスト" value="12345" onChange={mockOnChange} maxLength={10} />); const progressBar = screen.getByRole('progressbar'); expect(progressBar).toHaveClass('MuiLinearProgress-colorPrimary'); expect(screen.getByText('5/10')).toHaveStyle('color: rgb(25, 118, 210)'); // MUI プライマリ色 }); });
このテストスクリプトは以下の項目をテストします:
- コンポーネントが正しいラベルでレンダリングされるか
- 文字数が正しく表示されるか
- 最大文字数を超えた場合にエラー色に変わるか
- 入力変更時に onChange 関数が呼び出されるか
- LinearProgress が正しい値でレンダリングされるか
- 最大文字数を超えていない場合にプライマリカラーが維持されるか
各テストケースの概要は日本語で記述されており、コンポーネントの動作を明確に説明しています。このテストスクリプトを実行することで、LimitedTextField コンポーネントの主要な機能が正しく動作していることを確認できます。
くどいようですが、生成されたコードはレビューしましょう。
ラベルの表示やインジケータの表示、入力エラー時の表示などのテストが行われており、概ね問題なさそうです。
ただ一つだけ、レンダリングのテストをするマッチャー関数に toBeDefined()
が使われているのが気になります。toBeDefined()
だと、screen.getByLabelText('テストラベル')
で取得した要素が「定義されているか」を検証することになります。
要素が取得できれば定義されたことになるのでテスト自体はパスしますが、正しくテストできているとはいえません。
画面に表示されていることを確認したいので、toBeInTheDocument()
が適しているでしょう。
Perplexity に指示をして変更してもらいます。
レンダリングのテストで、toBeDefined が使われていますが、toBeInTheDocument を使うよう変更してください
承知しました。レンダリングのテストで
toBeDefined()
の代わりにtoBeInTheDocument()
を使用するように変更します。以下は修正後のテストコードの例です:import { render, screen } from '@testing-library/react'; import LimitedTextField from './LimitedTextField'; describe('LimitedTextField コンポーネント', () => { const mockOnChange = vi.fn(); it('正しいラベルでレンダリングされること', () => { render(<LimitedTextField label="テストラベル" value="" onChange={mockOnChange} maxLength={10} />); expect(screen.getByLabelText('テストラベル')).toBeInTheDocument(); }); it('正しい文字数が表示されること', () => { render(<LimitedTextField label="テスト" value="こんにちは" onChange={mockOnChange} maxLength={10} />); expect(screen.getByText('5/10')).toBeInTheDocument(); }); // 他のテストケースも同様に修正 });
toBeInTheDocument()
は要素がDOMに存在することを確認するため、レンダリングのテストにより適しています。この変更により、テストがより明確になり、要素がDOMに実際に存在することを確認できます[1][3][4]。Citations:
[1] https://zenn.dev/iamtillmans/articles/171f41fbd03c89
[2] https://zenn.dev/jin237/articles/589a7187a98429
[3] https://qiita.com/10mi8o/items/98ea09d53d33b570b4b0
[4] https://ai-techblog.hatenablog.com/entry/react-testing-library-basic
[5] https://qiita.com/cheez921/items/da5d4570dec6d17e087c
[6] https://blog.cybozu.io/entry/2022/08/29/110000
toBeDefined()
が toBeInTheDocument()
に変更されていることが確認できました。
今回の回答では、特に変更のないコードが省略されています。
差分だけをコピペすればいいのですが、私はめんどくさいので Ctrl + A でまるごとコピペしたいです。
そのようなときは以下の指示をしてみてください。
コードは省略しないでください
コードの変更もテストコードの作成もおてのもの
いかがだったでしょうか?
テストケースを追加したい場合も「入力欄に◯◯を入力した場合のテストケースを追加してください。期待値は〜〜」というような形で指示すれば、一瞬で作成してくれます。
ちょいちょい手直しが必要なところはありますが、全部自分で手書きするのよりは格段に楽ですよね。
「完璧ではない」ということは頭に入れつつ、うまく使いこなしていきましょう!
コメント