[test]jestとreact testing libraryを学ぶ

84133 ワード

コンセプト
テストが必要な場合
  • コードを作成した後、予想通りに動作するかどうかをテストします.
  • コードにエラーがある場合は、エラーが発生する状況をテストします.
  • コードが再符号化された場合、そのコードがそのまま動作しているかどうかをテストします.
    テストの構成
    1) setup
    テスト環境を作成します.(mock data、mock function等の準備)
    2) expectation
    必要なテスト結果を生成するためにコードを作成します.
    3) assertion
    必要な結果が得られたかどうかを確認します.
    ホワイトボックステスト、ブラックボックステスト
    ホワイトボックステスト
    コンポーネントの内部構造を事前に理解し、テストコードを記述すると仮定します.
    ブラックボックステスト
    構成部品の内部構造が分からない場合は、その動作にテストコードを記述します.
    テスト範囲別に分類
    Unit Testing

  • 他の部分から分離された小さなコードを作成してテストします(小さなコードはfunction、module、classなどを表します)

  • すべての部分がお客様の希望通りに機能していることを確認します.

  • テストは相互に分離する必要があります
  • ex)特定の構成部品がデータに基づいて良好にレンダリングできるかどうかをテストします.
    ex)特定の関数が正常に動作しているかどうかをテストする
    Integration Testing
  • アプリケーションの特定の部分が動作するかどうかをテストする
  • ex)複数の構成部品が同時に動作するか、どのページ部分が正常に動作するかをテストします.
    ex)react-router、reduxなどが特定の部品とよく配合されているかどうかをテストします.
    End-to-end Testing

  • ユーザがどのような場合にそのシーンのend-to-endをうまく実行できるかをテストする

  • 必要に応じてWebサーバ、データベースを実行

  • 範囲が大きすぎるため、エラーが発生した場合、一部の機能が使用できないことを知っているかもしれませんが、どの部分に問題が発生しているかを正確に知るのは難しいです.
  • ex)ユーザが会員に加入した後、ログインしてユーザ情報ページを表示できるかどうかをテストする.
    実習
    ユーザー情報切り替えアプリケーション
    ボタンをクリックしてユーザー情報を表示するアプリケーションをテストします
    構成部品
    function ShowUserInfo() {
      const [show, setShow] = useState(false);
      
      const handleClick = () => setShow(b => !b);
      
      return (
        <div>
          {!show && <div>유저 정보를 보려면 버튼을 누르세요.</div>}
          {
            show && (
              <ul>
                <li>Email - elice@elicer.com</li>
                <li>Address - 서울시 강남구 테헤란로 401</li>
              </ul>
            )
          }
          <button onClick={handleClick}>{!show ? "유저정보 보기" : "유저정보 가리기"}</button>
        </div>
      )
    }
    テスト
    import { screen, render } from "@testing-library/react";
    import userEvent from '@testing-library/user-event';
    import SimpleToggle from "./App";
    
    // describe: 테스트를 그룹화하는 함수
    describe("앱을 렌더링합니다.", () => {
      test("버튼이 있습니다.", () => {
        render(<SimpleToggle />);
               
        // "유저정보 보기" 버튼을 찾습니다.
        const button = screen.getByRole('button',{
          name: '유저정보 보기'
        });
      
        // 버튼이 존재하는지 체크합니다.
        expect(button).toBeInTheDocument();
      });
    
      test("버튼을 누르지 않았을 시, 유저 정보 안내문이 보입니다.", () => {
        render(<SimpleToggle />);
               
        // 텍스트를 찾습니다.
        const text = screen.getByText("유저 정보를 보려면 버튼을 누르세요.");
        
        // 텍스트가 존재하는지 체크합니다.
        expect(text).toBeInTheDocument();
      });
    });
    
    
    
    describe("토글 기능을 테스트합니다.", () => {
      test("버튼을 눌렀을 시, 유저 정보가 보입니다.", () => {
        render(<SimpleToggle />);
        
        const infoText = /유저 정보를 보려면 버튼을 누르세요./i
        
    
        // 텍스트를 찾습니다.
        // 텍스트 - "유저 정보를 보려면 버튼을 누르세요."
        // 텍스트가 존재하는지 체크합니다.
        const text = screen.getByText(infoText);
        expect(text).toBeInTheDocument();
    
        // Toggle 버튼을 찾습니다.
        const button = screen.getByRole('button',{
          name: '유저정보 보기'
        });
        
        // 버튼을 클릭합니다.
        userEvent.click(button);
        
        // 위에서 찾은 텍스트가 보이지 않는지 체크합니다.
        // 여기서 주의할점은 보이지 않아야해서 queryByText를 통해 없어도 오류가 나지 않도록
        expect(
          screen.queryByText(infoText)
        ).not.toBeInTheDocument();
    
        // 이메일 정보를 찾습니다.
        // 이메일 정보 - "Email - [email protected]"
        // 이메일 정보가 문서에 존재하는지 체크합니다.
        const email = screen.getByText("Email - [email protected]");
        expect(email).toBeInTheDocument();
        
    
        // 주소 정보를 찾습니다.
        // 주소 정보 - "Address - 서울시 강남구 테헤란로 401"
        // 주소 정보가 문서에 존재하는지 체크합니다.
        const address = screen.getByText("Address - 서울시 강남구 테헤란로 401");
        expect(address).toBeInTheDocument();
    
        // 버튼의 텍스트가 "유저정보 가리기" 로 바뀌는지 체크합니다.
        expect(button).toHaveTextContent("유저정보 가리기");
      });
    
      test("버튼을 두번 누르면, 유저 정보가 보이지 않습니다.", () => {
        render(<SimpleToggle />);
    
        // 버튼을 찾습니다.
        // 버튼을 클릭합니다.
        // 이메일 정보가 문서에 있는지 체크합니다.
        const button = screen.getByRole('button',{
          name: '유저정보 보기'
        });
        userEvent.click(button, {clickCount: 1});
        const email = screen.getByText("Email - [email protected]");
        expect(email).toBeInTheDocument();
    
        // Toggle 버튼을 클릭합니다.
        // 이메일 정보가 문서에서 사라졌는지 체크합니다.
        userEvent.click(button, {clickCount: 1});
        expect(
          email
        ).not.toBeInTheDocument()
      });
    });
    カートアプリケーション
    ShoppingCartコンポーネントは、カートのリストを受信して、画像、数量、商品価格、および合計価格を表示し、これらの値が表示に適しているかどうかをテストします.
    構成部品
    import React from "react";
    
    const getDiscountPrice = (price, quantity, discount) => (price - price * discount) * quantity;
    
    
    const getTotalPrice = carts =>
    carts
      .map(({price, quantity, discount}) => 
        getDiscountPrice(price, quantity, discount)
      ).reduce((acc, cur) => acc+cur, 0)
    
    function ShoppingCart({ carts }) {
      return (
        <div>
          <h2>쇼핑 목록</h2>
    
          <ul>
            {carts.map(({id, image, name, quantity, price, discount}) => (
              <Cart
                key={id}
                image={image}
                name={name}
                quantity={quantity}
                price={getDiscountPrice(price, quantity, discount)}
              />
            ))}
    
    
          </ul>
    
          <div>총 가격 : {getTotalPrice(carts)}</div>
        </div>
      );
    }
    
    export default ShoppingCart;
    
    function Cart({image, name, quantity, price}){
      return (
          <li>
            <div>
              <img src={image} alt={name} />
            </div>
    
            <div>
              <div>개수 : {quantity}</div>
              <p>상품 가격 : {price}</p>
            </div>
          </li>
      )
    }
    テスト
    import { screen, render } from "@testing-library/react";
    import ShoppingCart from "./App";
    
    const mockCarts = [
      {
        id: 1,
        name: "강아지 신발 사이즈 xs",
        price: 14000,
        discount: 0.1,
        quantity: 1,
        image: "https://via.placeholder.com/150.png",
      },
    
      {
        id: 2,
        name: "베이비 물티슈 200매",
        price: 2000,
        discount: 0.2,
        quantity: 10,
        image: "https://via.placeholder.com/150.png",
      },
    
      {
        id: 3,
        name: "강아지 사료 4kg",
        price: 40000,
        discount: 0.3,
        quantity: 3,
        image: "https://via.placeholder.com/150.png",
      },
    ];
    
    describe("ShoppingCart 컴포넌트를 렌더링합니다.", () => {
      test("헤더가 있습니다.", () => {
        render(<ShoppingCart carts={mockCarts} />);
    
        // 헤더를 찾습니다.
        const header = screen.getByRole('heading', {
          name: '쇼핑 목록'
        });
      
        // 헤더가 화면에 있는지 테스트합니다.
        expect(header).toBeInTheDocument();
      });
    
      test("아이템 3개를 보여줍니다.", () => {
        render(<ShoppingCart carts={mockCarts} />);
    
        // 모든 리스트 아이템을 찾습니다.
        const lis = screen.getAllByRole('listitem');
        // 모두 총 3개인지 체크합니다.
        expect(lis.length).toBe(3);
      });
    
      test("아이템의 이미지를 노출합니다.", () => {
        render(<ShoppingCart carts={mockCarts} />);
    
        // "강아지 사료 4kg"란 텍스트로 이미지를 찾으세요.
        // 이미지의 src attribute가 mockCarts의 데이터와 같은지 체크하세요.
        const image = screen.getByAltText('강아지 사료 4kg');
        expect(image).toHaveAttribute('src', mockCarts[2].image);
      });
    });
    
    describe("계산된 값을 노출합니다.", () => {
      test("할인된 값을 보여줍니다.", () => {
        render(<ShoppingCart carts={mockCarts} />);
    
        // 상품 가격에 할인가가 반영되었는지 체크하세요.
        // 상품 가격 - (price - price * discount) * quantity
        const {price, discount, quantity} = mockCarts[0];
        const discountPrice = (price - price * discount) * quantity
        const prices = screen.getAllByText(/상품 가격 :/i);
        expect(prices[0]).toHaveTextContent(`상품 가격 : ${discountPrice}`);
      });
    
      test("총 가격을 보여줍니다.", () => {
        render(<ShoppingCart carts={mockCarts} />);
    
        // 직접 mockCarts의 totalPrice를 계산해보세요.
        // 총 가격 - 모든 카트 상품 가격의 합
        
        const getDiscountPrice = (price, quantity, discount) => (price - price * discount) * quantity;
        
        const getTotalPrice = carts =>
        carts
          .map(({price, quantity, discount}) => 
            getDiscountPrice(price, quantity, discount)
          ).reduce((acc, cur) => acc+cur, 0)
        
        const totalPrice = getTotalPrice(mockCarts);
          
        expect(screen.getByText(`총 가격 : ${totalPrice}`)).toBeInTheDocument();
        
      });
    });
    ユーザー情報入力フォーム
    ユーザー名を受信してコミットする簡単なフォームのテスト
    構成部品
    import React, { useState } from "react";
    
    function validateInput(value) {
      if(!value) {
        return '유저명을 필수로 입력해주세요.';
      }
      
      if(value.length > 20) {
        return '20자 이하의 문자열을 입력해주세요.';
      }
      
      return '';
    }
    
    export default function UsernameForm() {
      const [value, setValue] = useState("");
      const [error, setError] = useState("");
      const [submitted, setSubmitted] = useState(false);
      const [success, setSuccess] = useState('');
    
      const handleChange = (e) => {
        const input = e.target.value;
        setValue(input);
        setError(validateInput(input));
      };
    
      const handleSubmit = (e) => {
        e.preventDefault();
        setSubmitted(true);
        
        
        const error = validateInput(value);
        setError(error);
        if(error) return;
        setSuccess('유저명 생성에 성공했습니다.');
      };
    
      return (
        <div>
          <form onSubmit={handleSubmit}>
            <input
              id="username"
              type="text"
              name="username"
              placeholder="유저명을 입력하세요"
              value={value}
              onChange={handleChange}
            />
    
            <button type="submit">제출</button>
            {submitted && <div data-testid='error-box'>{error}</div>}
          </form>
    
          {success && <div data-testid='success-box'>{success}</div>}
        </div>
      );
    }
    テスト
    import { screen, render } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import UsernameForm from "./App";
    
    describe("유저명 폼을 렌더링합니다.", () => {
      test("유저명 폼에는 input이 있습니다.", () => {
        render(<UsernameForm />);
    
        // input을 찾고, placeholder가 제대로 들어있는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toBeInTheDocument();
        expect(input).toHaveAttribute('placeholder', '유저명을 입력하세요');
      });
    
      test("유저명 폼에는 button이 있습니다.", () => {
        render(<UsernameForm />);
    
        // 제출 버튼이 제대로 렌더링되는지 확인합니다.
        const button = screen.getByRole('button', {
          name: '제출'
        })
        expect(button).toBeInTheDocument();
      });
    });
    
    describe("유저명 폼을 검증합니다.", () => {
      test("빈 인풋 제출시, 화면에 오류를 보여줍니다.", () => {
        render(<UsernameForm />);
    
        // 먼저 인풋이 빈 값을 가지는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toHaveValue('');
        
        // 버튼을 클릭합니다.
        const button = screen.getByRole('button', {
          name: '제출'
        })
        userEvent.click(button)
        
        // "유저명을 필수로 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
        const errorBox = screen.getByText('유저명을 필수로 입력해주세요.');
        expect(errorBox).toBeInTheDocument();
      });
    
      test("21자 이상의 문자열 입력시, 화면에 오류를 보여줍니다.", () => {
        render(<UsernameForm />);
    
        // 먼저 인풋이 빈 값을 가지는지 확인합니다.
        // 인풋에 21자 이상의 문자를 입력합니다.
        // 제출 버튼을 클릭합니다.
        // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toHaveValue('');
        userEvent.type(input, '1234512345123451235123451');
        const button = screen.getByRole('button', {
          name: '제출',
        });
        userEvent.click(button);
        expect(
          screen.getByText('20자 이하의 문자열을 입력해주세요.')
        ).toBeInTheDocument()
      });
    
      test("21자 이상의 문자열을 입력했으나 제출하지 않으면, 에러가 보이지 않습니다.", () => {
        render(<UsernameForm />);
        // 먼저 인풋이 빈 값을 가지는지 확인합니다.
        // 인풋에 21자 이상의 문자를 입력합니다.
        // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이지 않는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toHaveValue('');
        userEvent.type(input, '12345121453513531513531513531515');
        
        // non click
        expect(
          screen.queryByText('20자 이하의 문자열을 입력해주세요.')
        ).not.toBeInTheDocument()
      });
    
      test("21자 이상의 문자열 입력시 에러를 보여주며, 1글자를 지우면 에러가 사라집니다.", () => {
        render(<UsernameForm />);
    
    
        const errorMessage = '20자 이하의 문자열을 입력해주세요.';
        // 먼저 인풋이 빈 값을 가지는지 확인합니다.
        // 에러 메시지가 보이지 않는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toHaveValue('');
        expect(
          screen.queryByText(errorMessage)
        ).not.toBeInTheDocument()
    
        // 인풋에 21자 이상의 문자를 입력합니다.
        // 제출 버튼을 클릭합니다.
        userEvent.type(input, '123451234512345123451');
        const button = screen.getByRole('button', {
          name: '제출',
        });
        userEvent.click(button);
    
        // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이는지 확인합니다.
        // "{backspace}" 를 인풋에 입력합니다.
        // "20자 이하의 문자열을 입력해주세요." 란 에러 메시지가 보이지 않는지 확인합니다.
        expect(
          screen.getByText(errorMessage)
        ).toBeInTheDocument()
        
        // 실제 백스페이스 입력한것과 동일
        userEvent.type(input, "{backspace}");
        
        expect(
          screen.queryByText(errorMessage)
        ).not.toBeInTheDocument()
      });
    
      test("성공적으로 폼을 제출시 성공 메시지를 보여줍니다.", () => {
        render(<UsernameForm />);
    
        const errorMessage = '20자 이하의 문자열을 입력해주세요.';
        const successMessage = '유저명 생성에 성공했습니다.';
        // 먼저 인풋이 빈 값을 가지는지 확인합니다.
        // 에러 메시지가 보이지 않는지 확인합니다.
        // 성공 메시지가 보이지 않는지 확인합니다.
        const input = screen.getByRole('textbox');
        expect(input).toHaveValue('');
        expect(
          screen.queryByTestId('error-box')
        ).not.toBeInTheDocument()
            expect(
          screen.queryByTestId('success-box')
        ).not.toBeInTheDocument()
    
        // 정상적인 Username을 입력합니다.
        // 제출 버튼을 클릭합니다.
        userEvent.type(input, "정상네임");
        const button = screen.getByRole('button', {
          name: '제출'
        })
        
        userEvent.click(button);
        
        expect(
          screen.getByText(successMessage)
        ).toBeInTheDocument()
    
        // "유저명 생성에 성공했습니다." 라는 성공메시지가 보이는지 확인합니다.
      });
    });
    理解する内容
  • とToEqualの違い
    toBeは同じ内容のオブジェクトですが、メモリが異なる場合はfalseです.
    上記の問題をtoEqualで解決する
  • expect({ name: 'son' }).toEqual({ name: 'son' }); // true

  • it、testは同じ動作をしますが、itを英語で書くときは、話の一貫性がいいです.

  • 値がない場合はgetByではなくqueryByを作成することに注意してください.
  • queryByに関連するクエリーはgetByに似た要素を検索して返し、見つからない場合はnullを返してエラーを投げ出さない.
    複数の要素を見つけたときにエラーを投げ出す
  • actは、UIテストの作成時に、レンダリングなどの操作、ユーザイベント、データの読み込みがユーザインタフェースと対話する「単位」
  • である.
    you don't need to use act by yourself. It's wrapped by render function.
    reactテストライブラリを使用している場合は、render関数の内部で呼び出されます.
      act(() => {
        if (hydrate) {
          ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
        } else {
          ReactDOM.render(wrapUiIfNeeded(ui), container)
        }
      })
    好奇心のある場所
    import { render, unmountComponentAtNode } from "react-dom";
    import { screen, render } from "@testing-library/react";
    レンダーの違い(Render Difference)
    https://stackoverflow.com/questions/59935545/react-testing-library-render-vs-reactdom-render
    解決のためのstackoveflowを参照してください