Browser api 테스트하기

김동하·2023년 3월 30일
1

jest

목록 보기
4/6
post-thumbnail
post-custom-banner

들어가며

당장 사용할 일은 없겠지만, 테스트를 하다보면 Browser api를 테스트해야 하는 경우도 있다. 예를들어 geolocation도 있을 수 있고 뭐 기타등등.. 하지만 이러한 api는 jsdom에서 지원하지 않기 때문에 다른 방식으로 테스트를 해야 한다. 그렇다면 어떻게 해야하는지 알아보자!

예제

유저의 location을 제공하는 window.navigator를 사용한다고 가정하자.

test("유저의 현재 location이 나와야 함", async () => {
  render(<Location />);
  screen.debug();
});

window.navigator.geolocation에서 제공하는 getCurrentposition이 없다고 나온다. 이를 위해서 몇 가지 코드를 추가해야 한다.

beforeAll(() => {
  window.navigator.geolocation = {
    getCurrentPosition: jest.fn(),
  };
});

이렇게 일단 getCurrentPosition에 mock 함수를 할당하고 테스트를 해보면

테스트에는 통과한다! 이제 test용 coords를 주입해보자

test("유저의 현재 location이 나와야 함", async () => {
  const testPosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  };

  window.navigator.geolocation.getCurrentPosition.mockImplementation(
    (callback) => {
      callback(testPosition);
    }
  );

  render(<Location />);
  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument();
  screen.debug();
});

아까 만들어 놓은 window.navigator(mock으로 만든)에 mockImplementation 라는 메서드를 호출한다.

인자로 콜백의 인자로 하드코딩으로 만들어 놓은testPosition를 준다.

이렇게 잘 나오는 것을 알 수 있다.

function Location() {
  const [position, error] = useCurrentPosition()

  if (!position && !error) {
    return <Spinner />
  }

  if (error) {
    return (
      <div role="alert" style={{color: 'red'}}>
        {error.message}
      </div>
    )
  }

  return (
    <div>
      <p>Latitude: {position.coords.latitude}</p>
      <p>Longitude: {position.coords.longitude}</p>
    </div>
  )
}

하지만 여기서 생각해야하는 것이 위의 코드처럼 UI에 먼저 로딩이 나오고 성공적으로 api가 호출이 되면 그때 사용자의 coords가 나온다. 그래서 테스트 상에서도 타이밍의 조정이 필요하다.

이때 필요한 것이 sleep과 같은 유틸 함수다.

function deferred() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

이렇게 promise와 reslove, reject를 던지는 유틸 함수를 만들고 테스트 환경 내부에 선언해준다.

const { promise, resolve } = deferred();

그리고 mockImplementation에 인자로 주었던 callback 함수를 promise로 받는다. 즉 callback은 resolved된 후 실행될 수 있다.

promise.then(() => callback(testPosition));

그리고 로딩이 끝나면 resolved해서 받아둔 callback을 실행하면 된다.

 resolve();
 await promise;

와 promise를 잘 쓰고 싶다...

아무튼 전체 코드로 보자면

test("유저의 현재 location이 나와야 함", async () => {
  const testPosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  };

  const { promise, resolve } = deferred();

  window.navigator.geolocation.getCurrentPosition.mockImplementation(
    (callback) => {
      promise.then(() => callback(testPosition));
    }
  );

  render(<Location />);
  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument();

 await act(async () => {
  resolve();
  await promise;
 });

  screen.debug();
});

비동기로 UI 업데이트를 해야 한다면 act를 사용한다.

act() 는 함수를 인자로 받는데, 이 함수를 실행시켜서 가상의 DOM(jsdom)에 적용하는 역할을 합니다. act()함수가 호출된 뒤, DOM에 반영되었다고 가정하고 그 다음 코드를 쓸 수 있게 되서 React가 브라우저에서 실행될 때와 최대한 비슷한 환경에서 테스트할 수 있습니다.

정상적으로 coords가 화면에 나오고 테스트도 통과하였다.

이제 정상적으로 api 호출되고 coords가 화면에 나왔다면 loading이 지워졌나를 테스트 해야한다.

expect(screen.getByLabelText(/loading/i)).not.toBeInTheDocument();

기존의 테스트코드에 not을 추가하면 되는데 테스트를 돌려보면

label을 찾을 수 없다고 한다. loading이 지워졌으니 정상적으로 작동하는 것 같지만, 테스트를 하기 위해서 getByLabelText를 했을 때 해당 label 자체가 없으니 에러가 나는 것이다(없는 사람 손들어봐 같은 느낌...)

그래서 getByLabelText 대신 queryByLabelText를 사용한다.

성공적으로 호출 후 loading 이 사라지는 것도 테스트 완료!

라이브러리 모킹해서 테스트하기

사용자의 location을 찾아 position과 error를 return하는react-use-geolocation이란 라이브러리를 사용한다고 가정하고 테스트를 시작하자.

jest.mock("react-use-geolocation");

먼저 해당 라이브러리 mock 함수로 사용하여 mock화(?)
를 한다.

import { useCurrentPosition } from "react-use-geolocation";

useCurrentPosition.mockImplementation(useMockCurrentPostion);

useCurrentPosition를 직접 가져와 아까와 동일하게 mockImplementation를 호출한다.

그리고 useMockCurrentPostion 를 만든다.

 let setReturnValue;
  function useMockCurrentPostion() {
    const state = React.useState([]);
    setReturnValue = state[1];
    return state[0];
  }

useMockCurrentPostion는 useState를 사용하여 실제 컴포넌트의 생명주기를 흉내낸다.

실제 컴포넌트에서 useCurrentPosition 가 호출되면 useMockCurrentPostion가 실행되는 것이다.

이제 act에서 state를 업데이트 해주는 함수를 추가해주면 된다.

act(() => {
    // 리렌더링 발생
    setReturnValue([testPosition]);
  });

setReturnValue에 위에 작성했던 testPosition을 주입하는 것으로 끝!


jest.mock("react-use-geolocation");

test("유저의 현재 location이 나와야 함", async () => {
  const testPosition = {
    coords: {
      latitude: 35,
      longitude: 139,
    },
  };

  let setReturnValue;
  function useMockCurrentPostion() {
    const state = React.useState([]);
    setReturnValue = state[1];
    return state[0];
  }

  useCurrentPosition.mockImplementation(useMockCurrentPostion);

  render(<Location />);
  expect(screen.getByLabelText(/loading/i)).toBeInTheDocument();

  act(() => {
    // 리렌더링 발생
    setReturnValue([testPosition]);
  });

  screen.debug();
  expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument();

  expect(screen.getByText(/latitude/i)).toHaveTextContent(
    `Latitude: ${testPosition.coords.latitude}`
  );
  expect(screen.getByText(/longitude/i)).toHaveTextContent(
    `Longitude: ${testPosition.coords.longitude}`
  );
});

coords도 잘 나오고

테스트도 통과하였다.

와... 진짜 어렵네...

정리

  • 먼저 실제 컴포넌트가 마운트되면서 useCurrentPosition를 호출하고 그러면 test 환경에서 mocking했던 useCurrentPosition.mockImplementation()가 호출된다.

  • useState를 사용한 useMockCurrentPostion가 실행된다.

  • 첫 마운트엔 useState의 초기값이 빈 배열이라서 로딩이 돌테고

  • act가 실행되면서 setReturnValue()에 하드코딩으로 넣었던 coords가 화면에 렌더링되면서 테스트를 통과하게 되는 것이다.

참고

profile
프론트엔드 개발
post-custom-banner

0개의 댓글