본문 바로가기

테스트

MSW의 delay infinite는 테스트 성능에 이상이 없을까?

리액트 테스팅 라이브러리와 MSW를 이용하여 다음과 같은 hook을 테스팅 하고 다음과 같은 리뷰를 받았다.

import { useQuery, useSuspenseQuery } from '@apollo/client';
import { MeQuery } from '@generated/graphql';
import { useErrorBoundary } from 'react-error-boundary';

type MeQueryProps = {
  id: string;
};

export type useMeQueryReturnValue = {
  isLoading: boolean;
  isError: boolean;
  Name: string | null;
  };

const useMeQuery = ({
  id,
}: PresentationPageQueryProps): usePresentationPageQueryReturnValue => {
  const { showBoundary } = useErrorBoundary();


  const {
    data: { me } = {},
    loading: MeQueryLoading,
    error
  } = useQuery<MeQuery, PMeQueryVariables>(
    MeQueryQueryDocument,
    {
      variables: { id },
      skip: !id,
      fetchPolicy: 'cache-first',
    }
  );
  
  const isError = typeof Error === 'object';

  if (typeof Error === 'object') {
    showBoundary({
      code: 500,
      message: 'error!',
    });
  }

  return {
    isError,
    isLoading: loading,
    me
};

export default useMeQuery;

 

//useMeloading.spec.tsx

describe('데이터 조회가 진행중인 경우', () => {
    it('유저 정보 조회가 진행중이면 isLoading 값이 true로 반환되어야 한다.', async () => {
      const { result } = renderHook(
        () => useMeQuery({ id: 'me-loading' }),
        {}
      );
      await waitFor(() => {
        const { isLoading } =
          result.current as useMeQueryReturnValue;
        expect(isLoading).toEqual(true);
      });
    });
  });

 

//msw handlers

...

if (classroomId === 'me-loading') {
  await delay('infinite');
  return HttpResponse.json({
    data: {
      me: ClassroomMemberFactory.build(),
    },
  });
}
...

 

서버의 응답을 모킹해서 테스트 해야하고, API가 아직 도착하지 않은 시점을 테스트 하기에 MSW 공식문서에 나와있는 것 같이 delay 함수를 사용해서 테스트를 작성했었다.

 

그런데..

 

"await delay('infinite')은 언제까지 delay되는 건가요?"
"테스트 성능에는 문제가 없나요?"

 

라는 리뷰가 달렸다.

 

한 번도 생각해 본적 없었고 당연하게 저렇게 썼어서 막상 저런 리뷰가 달리니 궁금해지기 시작했다!

 

먼저 가볍게 msw의 delay 함수를 확인해보기로 했다.

 

//msw-delay.ts

export async function delay(
  durationOrMode?: DelayMode | number,
): Promise<void> {
  let delayTime: number

  if (typeof durationOrMode === 'string') {
    switch (durationOrMode) {
      case 'infinite': {
        // Using `Infinity` as a delay value executes the response timeout immediately.
        // Instead, use the maximum allowed integer for `setTimeout`.
        delayTime = SET_TIMEOUT_MAX_ALLOWED_INT
        break
      }
      ...
    }
  } 
  
  ...

  return new Promise((resolve) => setTimeout(resolve, delayTime))
}

 

해당 코드를 보면 delay('infinite')값을 주게 된다면 SET_TIMEOUT_MAX_ALLOWED_INT 값 만큼 지연을 주는 Promise를 리턴하는 것이다. (SET_TIMEOUT_MAX_ALLOWED_INT: 2147483647)

 

시간으로 환산해보면 대충 596.5시간 정도가 나온다.

 

테스트 코드를 작성해서 통과하는걸 기다리는데 596시간이라면...  MSW 공식문서에도 나온것 처럼 await delay('infinite') 이후에는 절대 실행 안될 것 같긴하다.

 

그러면 컨텍스트 상 delay함수가 리턴하게 되는 Promise 객체는 마이크로 테스크 큐에 계속 담겨져 있게 되는데 메모리 낭비가 아닐까..?

 

궁금하여 검색하기 시작했고 곧 다음과 같은 코드를 확인할 수 있었다!

 

//msw/core/utils/internal/Disposable.ts

export type DisposableSubscription = () => Promise<void> | void

export class Disposable {
  protected subscriptions: Array<DisposableSubscription> = []

  public async dispose() {
    await Promise.all(this.subscriptions.map((subscription) => subscription()))
  }
}

 

내부적으로 더 많은 로직이 있지만 가장 핵심이 되는 로직을 가져와 보았다. msw의 server.close() 함수를 실행하게 되면 Disposable 클래스의 dispose 메서드를 실행하게 되는데, this.subscriptions 배열에 등록된 Promise.all을 통해 구독들을 순회하면서 결과값을 await을 통해 기다리게 된다. 그러면 비동기적인 작업이 모두 완료되는 것을 보장하여 구독들을 해제시키게된다.

 

그런데.. 테스트 코드는... 성공이던.. 실패던.. 완료가 되었다는 전제하에 close() 함수가 호출이 될텐데 그러면 테스트코드가 성공하지 못하면 596시간동안 메모리에 setTimeout 함수가 남아 있는게 아닐까?

 

이 때 등장하는게 바로 React Testing Library의 waitFor 함수이다..!

 

다음은 waitFor의 설명에 대한 원어이다.

 

waitFor may run the callback a number of times until the timeout is reached. Note that the number of calls is constrained by the timeout and interval options.

This can be useful if you have a unit test that mocks API calls and you need to wait for your mock promises to all resolve.

If you return a promise in the waitFor callback (either explicitly or implicitly with the async syntax), then the waitFor utility does not call your callback again until that promise rejects. This allows you to waitFor things that must be checked asynchronously.

The default container is the global document. Make sure the elements you wait for are descendants of container.
The default interval is 50ms. However it runs your callback immediately before starting the intervals.
The default timeout is 1000ms.

The default onTimeout takes the error and appends the container's printed state to the error message which should hopefully make it easier to track down what caused the timeout.

 

해석하자면 기본값 1000(ms)가 될 때 까지 50ms 마다 콜백을 실행한다는 것이다.

 

즉, waitFor로 설정한 타임아웃 설정한 시간 안에 테스트코드를 실행하여 테스트 코드가 통과된다면 AfterAll로 설정된 server.close() 함수가 호출되어 msw가 설정된 구독들이 전부 해제가 된다는 것이다. 그러면 가비지컬렉터를 통해 메모리 정리가 된다는 이야기가 된다.

 

결론적으로, waitFor과 함께 infinite delay를 실행한다면 1~2초 안에 실행결과가 나오고 바로 메모리 정리가 들어가게 되어 누수를 방지하게 될 수 있게 된다!

 

 

출처

https://testing-library.com/docs/dom-testing-library/api-async/#waitfor

 

Async Methods | Testing Library

Several utilities are provided for dealing with asynchronous code. These can be

testing-library.com

https://github.com/mswjs/msw