【React】Contextを使用しているコンポーネントのテストを実装

Testing Library学習中ということで、今回はContextの状態を使用しているコンポーネントでテストを行う方法についてまとめました。

https://qiita.com/suzuki0430/items/656213ca221ad75aaee4

https://qiita.com/suzuki0430/items/dfec9532327f6d247a5c

https://qiita.com/suzuki0430/items/f82e7fe619d8e592b086

https://qiita.com/suzuki0430/items/8abb36b7bbd9a028f5c3

#実装
以下のコンポーネントで構成された画面を考えます。
Scoopsで個数を入力するとScoops total(小計)、Toppingsを選択するとToppings total(小計)、それぞれの合計がGrand totalとして表示されます。
選択した個数を更新する関数や小計や合計の状態をContextで管理します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/0646cf96-1692-629d-12d2-c93198c0b007.png" width=700>

##Contextの実装
Contextオブジェクト(OrderDetails)とContext.Provider(OrderDetails.Provider)を以下のように作成します。
formatCurrencyは金額を小数2桁(ex. 0.00, 2.00)で表示するようにフォーマットする関数です。

```jsx:OrderDetails.jsx
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を以下のようにラッピングします。

```jsx:App.js
function App() {
  return (
    <Container>
      <OrderDetailsProvider>
        <OrderEntry />
      </OrderDetailsProvider>
    </Container>
  );
}
```
ラッピングされたコンポーネント内でContextを使用するために、`const [orderDetails, updateItemCount] = useOrderDetails()`で`orderDetails`と`updateItemCount`を読み込みます。

更新された小計は`total: {orderDetails.totals[optionType]}`のように表示されます。

```jsx:Options.jsx
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>
    </>
  );
}
```

https://qiita.com/suzuki0430/items/6e5dec67eaf6f5faf56f

##テストの実装
Contextの状態を利用するコンポーネントをテストする際、`render`で仮想DOMを生成するときに第2引数に`{ wrapper: OrderDetailsProvider }`を与えてあげる必要があります。

```jsx:testUpdates.test.jsx
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" />`として使用することもできます。

```jsx:testing-library-utils.jsx
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 };
```

#参考資料

https://www.udemy.com/share/1040ZSCEEZd11VRno=/