Testing Library学習中ということで、今回はContextの状態を使用しているコンポーネントでテストを行う方法についてまとめました。
実装
以下のコンポーネントで構成された画面を考えます。
Scoopsで個数を入力するとScoops total(小計)、Toppingsを選択するとToppings total(小計)、それぞれの合計がGrand totalとして表示されます。
選択した個数を更新する関数や小計や合計の状態をContextで管理します。

Contextの実装
Contextオブジェクト(OrderDetails)とContext.Provider(OrderDetails.Provider)を以下のように作成します。
formatCurrencyは金額を小数2桁(ex. 0.00, 2.00)で表示するようにフォーマットする関数です。
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
import { pricePerItem } from '../constants';
import { formatCurrency } from '../utilities';
const OrderDetails = createContext();
export function useOrderDetails() {
const context = useContext(OrderDetails);
if (!context) {
throw new Error(
'useOrderDetails must be used within an OrderDetailsProvider'
);
}
return context;
}
function calculateSubtotal(optionType, optionCounts) {
let optionCount = 0;
for (const count of optionCounts[optionType].values()) {
optionCount += count;
}
return optionCount * pricePerItem[optionType];
}
export function OrderDetailsProvider(props) {
const [optionCounts, setOptionCounts] = useState({
scoops: new Map(),
toppings: new Map(),
});
const zeroCurrency = formatCurrency(0);
const [totals, setTotals] = useState({
scoops: zeroCurrency,
toppings: zeroCurrency,
grandTotal: zeroCurrency,
});
useEffect(() => {
const scoopsSubtotal = calculateSubtotal('scoops', optionCounts);
const toppingsSubtotal = calculateSubtotal('toppings', optionCounts);
const grandTotal = scoopsSubtotal + toppingsSubtotal;
setTotals({
scoops: formatCurrency(scoopsSubtotal),
toppings: formatCurrency(toppingsSubtotal),
grandTotal: formatCurrency(grandTotal),
});
}, [optionCounts]);
const value = useMemo(() => {
function updateItemCount(itemName, newItemCount, optionType) {
const newOptionCounts = { ...optionCounts };
// update option count for this item with the new value
const optionCountsMap = optionCounts[optionType];
optionCountsMap.set(itemName, parseInt(newItemCount));
setOptionCounts(newOptionCounts);
}
return [{ ...optionCounts, totals }, updateItemCount];
}, [optionCounts, totals]);
return <OrderDetails.Provider value={value} {...props} />;
}
App以下のコンポーネントで状態と更新関数を使うために、作成したProviderを以下のようにラッピングします。
function App() {
return (
<Container>
<OrderDetailsProvider>
<OrderEntry />
</OrderDetailsProvider>
</Container>
);
}
ラッピングされたコンポーネント内でContextを使用するために、const [orderDetails, updateItemCount] = useOrderDetails()でorderDetailsとupdateItemCountを読み込みます。
更新された小計はtotal: {orderDetails.totals[optionType]}のように表示されます。
export default function Options({ optionType }) {
const [items, setItems] = useState([]);
const [error, setError] = useState(false);
const [orderDetails, updateItemCount] = useOrderDetails();
// optionType is 'scoop' or 'toppings'
useEffect(() => {
axios
.get(`http://localhost:3030/${optionType}`)
.then((response) => setItems(response.data))
.catch((error) => setError(true));
}, [optionType]);
if (error) {
return <AlertBanner />;
}
// TODO: replace `null` with ToppingOption when available
const ItemComponent = optionType === 'scoops' ? ScoopOption : ToppingOption;
const title = optionType[0].toUpperCase() + optionType.slice(1).toLowerCase(); //頭文字だけ大文字
const optionItems = items.map((item) => (
<ItemComponent
key={item.name}
name={item.name}
imagePath={item.imagePath}
updateItemCount={(itemName, newItemCount) =>
updateItemCount(itemName, newItemCount, optionType)
}
/>
));
return (
<>
<h2>{title}</h2>
<p>{formatCurrency(pricePerItem[optionType])} each</p>
<p>
{title} total: {orderDetails.totals[optionType]}
</p>
<Row>{optionItems}</Row>
</>
);
}
テストの実装
Contextの状態を利用するコンポーネントをテストする際、renderで仮想DOMを生成するときに第2引数に{ wrapper: OrderDetailsProvider }を与えてあげる必要があります。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Options from '../Options';
import { OrderDetailsProvider } from '../../../contexts/OrderDetails';
import OrderEntry from '../OrderEntry';
test('update scoop subtotal when scoops change', async () => {
render(<Options optionType="scoops" />, { wrapper: OrderDetailsProvider });
// make sure total starts out $0.00
const scoopsSubtotal = screen.getByText('Scoops total: $', { exact: false });
expect(scoopsSubtotal).toHaveTextContent('0.00');
// update vanilla scoops to 1 and check the subtotal
const vanillaInput = await screen.findByRole('spinbutton', {
name: 'Vanilla',
});
userEvent.clear(vanillaInput);
userEvent.type(vanillaInput, '1');
expect(scoopsSubtotal).toHaveTextContent('2.00');
// update chocolate scoops to 2 and check subtotal
const chocolateInput = await screen.findByRole('spinbutton', {
name: 'Chocolate',
});
userEvent.clear(chocolateInput);
userEvent.type(chocolateInput, '2');
expect(scoopsSubtotal).toHaveTextContent('6.00');
});
renderを別ファイルでカスタムすることで、Contextがない場合と同様にrender(<Options optionType="scoops" />として使用することもできます。
import { render } from '@testing-library/react';
import { OrderDetailsProvider } from '../contexts/OrderDetails';
const renderWithContext = (ui, options) =>
render(ui, { wrapper: OrderDetailsProvider }, ...options);
// re-export everything
export * from '@testing-library/react';
// override render method
export { renderWithContext as render };
参考資料