React-native + Apollo-Client + Jest

이승훈·2023년 4월 13일
post-thumbnail

참조

Jest를 이용한 React Native 테스트
Jest를 이용한 React Native 테스트
Jest로 스냅샷(snapshot) 테스트하기
Testing Apollo Components using react-testing-library
[React Native] Jest를 이용한 단위 테스트 해보기 (feat. TDD)
jest test에서 import 를 못 쓰네요?
Unit tests with react-native testing library and Apollo Graphql
Testing React Native + Apollo Apps
Jest - Code Transformation Jest는 프로젝트의 코드를 JavaScript로 실행하지만 기본적으로 Node에서 지원하지 않는 일부 구문(예: JSX, TypeScript, Vue 템플릿)을 사용하면

환경 세팅

  • react-native testing library를 사용할것이기에 아래의 커멘드로 테스팅라이브러리 모듈 설치
    → $ yarn add @testing-library/react-native
  • package.json의 jest configuration에 하기의 명령어 추가
"transformIgnorePatterns": [
  "node_modules/(?!@react-native|react-native)"
],
"setupFiles": [
  "./node_modules/react-native-gesture-handler/jestSetup.js"
],

transformIgnorePatterns

jest는 기본적으로 javascript로 실행되지만 기본적으로 NodeJS에서 지원하지 않는 구문을 사용하는 경우(ex. jsx, typescript, vue 템플릿 등) 일반 javascript로 변환시켜줘야한다.

transfromIgnorePatterns는 이러한 변환을 수행하지 않을 패턴을 지정해주는 명령어이다.

이 명령어를 통해 node_modules 내부의 파일들은 transfrom에서 제외시키지만 react-native 또는 @react-native로 시작하는 이름의 패키지는 변환대상에 포함시킨다.

setupFiles

테스트 프레임워크가 환경에 설치되기전에 실행되는 파일들을 지정하는 옵션이다.

전역 설정이나 테스트 환경을 구성하기 위해 사용된다.

일반적으로 테스트에서 사용되는 설정이나 모듈을 불러오기 위해 사용된다.

테스트 실행까지의 문제 및 해결 방안

아래부터는 위의 코드에서 yarn test를 실행할 때 발생하는 문제들과 그에 따른 해결방안들에 대해 적어놓은 내용

  • 초기 테스트 코드
import {fireEvent, render} from '@testing-library/react-native';
import React from 'react';
import SignupEmailVerificationScreen from '../screens/SignupEmailVerificationScreen';

describe('회원가입', () => {
  it('이메일 주소를 입력하지 않으면 올바른 이메일 주소를 입력하라는 알림을 제대로 띄우는가?', () => {
    const screen = render(<SignupEmailScreen />);
  });
});

문제

FAIL  __tests__/App-test.js
  ● Test suite failed to run

    Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.

      1 | import * as Keychain from 'react-native-keychain';
    > 2 | import OneSignal from 'react-native-onesignal';
        | ^
      3 |
      4 | export const reducer = (prevState, action) => {
      5 |   switch (action.type) {

      at invariant (node_modules/invariant/invariant.js:40:15)
      at new NativeEventEmitter (node_modules/react-native/Libraries/EventEmitter/NativeEventEmitter.js:45:16)
      at new EventManager (node_modules/react-native-onesignal/dist/events/EventManager.js:26:38)
      at Object.<anonymous> (node_modules/react-native-onesignal/dist/index.js:50:20)
      at Object.<anonymous> (contexts/User/reducer.js:2:1)

해결

굳이 jest.setup.js가 아니더라도 test를 실행하는 파일의 상단에 아래의 코드를 추가해줘도 됨.

jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter');

문제


FAIL  __tests__/App-test.js
  회원가입
    ✕ 이메일 인증 스크린 렌더링 (152 ms)

  ● 회원가입 › 이메일 인증 스크린 렌더링

    Invariant Violation: Could not find "client" in the context or passed in as an option. Wrap the root component in an <ApolloProvider>, or pass an ApolloClient instance in via options.

      31 | function SignupEmailVerificationScreen({ navigation, route }) {
      32 |   const [verificationCode, setVerificationCode] = useState('');
    > 33 |   const [requestVerification] = useMutation(RequestVerificationMutation);
         |                                            ^
      34 |   const [confirmEmailVerification] = useMutation(
      35 |     ConfirmEmailVerificationMutation,
      36 |   );

      at new InvariantError (node_modules/@apollo/client/node_modules/ts-invariant/lib/invariant.js:11:28)
      at Object.invariant (node_modules/@apollo/client/node_modules/ts-invariant/lib/invariant.js:24:15)
      at useApolloClient (node_modules/@apollo/client/react/hooks/useApolloClient.js:7:15)
      at useMutation (node_modules/@apollo/client/react/hooks/useMutation.js:9:18)
      at SignupEmailVerificationScreen (screens/SignupEmailVerificationScreen.js:33:44)
      at renderWithHooks (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:6256:18)
      at mountIndeterminateComponent (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:10702:13)
      at beginWork (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:12151:16)
      at performUnitOfWork (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15801:12)
      at workLoopSync (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15735:5)

  console.error
    Warning: Failed prop type: The prop `navigation` is marked as required in `SignupEmailVerificationScreen`, but its value is `undefined`.
        at SignupEmailVerificationScreen (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/screens/SignupEmailVerificationScreen.js:31:42)
        at HocComponent (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/components/basics/KeyboardAvoidingHOC.js:13:35)

      18 |         <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
      19 |           <View style={styles.inner}>
    > 20 |             <WrappedComponent {...props} />
         |             ^
      21 |           </View>
      22 |         </TouchableWithoutFeedback>
      23 |       </KeyboardAvoidingView>

      at printWarning (node_modules/react/cjs/react-jsx-runtime.development.js:97:30)
      at error (node_modules/react/cjs/react-jsx-runtime.development.js:71:7)
      at checkPropTypes (node_modules/react/cjs/react-jsx-runtime.development.js:629:11)
      at validatePropTypes (node_modules/react/cjs/react-jsx-runtime.development.js:1162:7)
      at jsxWithValidation (node_modules/react/cjs/react-jsx-runtime.development.js:1282:7)
      at jsxWithValidationDynamic (node_modules/react/cjs/react-jsx-runtime.development.js:1299:12)
      at HocComponent (components/basics/KeyboardAvoidingHOC.js:20:13)
      at renderWithHooks (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:6256:18)
      at mountIndeterminateComponent (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:10702:13)
      at beginWork (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:12151:16)
      at performUnitOfWork (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15801:12)
      at workLoopSync (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15735:5)
      at renderRootSync (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15707:7)
      at performSyncWorkOnRoot (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:15412:20)
      at flushSyncCallbacks (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:2594:22)
      at flushActQueue (node_modules/react/cjs/react.development.js:2665:24)
      at Object.act (node_modules/react/cjs/react.development.js:2519:11)
      at renderWithAct (node_modules/@testing-library/react-native/src/render-act.ts:13:16)
      at render (node_modules/@testing-library/react-native/src/render.tsx:48:33)
      at Object.<anonymous> (__tests__/App-test.js:28:27)

  console.error
    The above error occurred in the <SignupEmailVerificationScreen> component:
    
        at SignupEmailVerificationScreen (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/screens/SignupEmailVerificationScreen.js:31:42)
        at View
        at Component (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/node_modules/react-native/jest/mockComponent.js:28:18)
        at TouchableWithoutFeedback (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/node_modules/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js:93:31)
        at View
        at Component (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/node_modules/react-native/jest/mockComponent.js:28:18)
        at KeyboardAvoidingView (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js:69:29)
        at HocComponent (/Users/tesser/Desktop/ihd0628/ontol-poc/mobile-app/components/basics/KeyboardAvoidingHOC.js:13:35)
    
    Consider adding an error boundary to your tree to customize error handling behavior.
    Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.

      26 | describe('회원가입', () => {
      27 |   it('이메일 인증 스크린 렌더링', () => {
    > 28 |     const wrapper = render(<SignupEmailVerificationScreen />);
         |                           ^
      29 |     expect(wrapper).toBeTruthy();
      30 |   });
      31 | });

해결

아마 apollo component인데 왜 client가 없냐

ApolloProvider로 감싸줘라라는 의미인듯해서 mockedProvider로 감싸줌

describe('회원가입', () => {
  it('이메일 인증 스크린 렌더링', () => {
    const wrapper = render(
      <MockedProvider addTypename={false} mocks={mocks}>
        <SignupEmailVerificationScreen />
      </MockedProvider>,
    );
    expect(wrapper).toBeTruthy();
  });
});

문제

FAIL  __tests__/App-test.js
  회원가입
    ✕ 이메일 인증 스크린 렌더링 (62 ms)

  ● 회원가입 › 이메일 인증 스크린 렌더링

    TypeError: Invalid attempt to destructure non-iterable instance.
    In order to be iterable, non-array objects must have a [Symbol.iterator]() method.

      40 |     EMAIL_VERIFICATION_MINUTES * 60,
      41 |   );
    > 42 |   const [, dispatch] = useContext(UserContext);
         |                                               ^
      43 |   const minutes = Math.floor(leftSeconds / 60);
      44 |   const seconds = Math.floor(leftSeconds - minutes * 60);
      45 |

해결

뭐 이런저런 Context들을 사용하는데 왜 Provider가 없어서 내가 못알아먹게하냐라는 내용인것같아서 그냥 기존 어플리케이션과 똑같은 ContextProvider들로 모두 감싸줌

그리고 MockedProvider를 사용하지않고 일단 ApolloProvider로 다시 바꿔줌

어차피 서버와의 통신은 진행하지 않을것이고 MockedProvider를 사용하려면 그 사용법을 익히는 시간을 현재 할애 할 여유가 없기 떄문.

describe('회원가입', () => {
  it('이메일 인증 스크린 렌더링', () => {
    const { userState, client } = useSetClientAndUserContext();
    const wrapper = render(
      <UserProvider>
        <ConsultationProvider>
          <TranslationProvider>
            <ApolloProvider client={client.client}>
              <SafeAreaProvider>
                <SignupEmailVerificationScreen />
              </SafeAreaProvider>
            </ApolloProvider>
          </TranslationProvider>
        </ConsultationProvider>
      </UserProvider>,
    );
    expect(wrapper).toBeTruthy();
  });
});

문제

FAIL  __tests__/App-test.js
  ● Test suite failed to run

    [@RNC/AsyncStorage]: NativeModule: AsyncStorage is null.

    To fix this issue try these steps:

      • Rebuild and restart the app.

      • Run the packager with `--reset-cache` flag.

      • If you are using CocoaPods on iOS, run `pod install` in the `ios` directory and then rebuild and re-run the app.

      • If this happens while testing with Jest, check out docs how to integrate AsyncStorage with it: https://react-native-async-storage.github.io/async-storage/docs/advanced/jest

    If none of these fix the issue, please open an issue on the Github repository: https://github.com/react-native-async-storage/async-storage/issues

해결

[React Native] Jest 실행 시 [@RNC/AsyncStorage]: NativeModule: AsyncStorage is null. 에러

문제

FAIL  __tests__/App-test.js
  회원가입
    ✕ 이메일 인증 스크린 렌더링 (21 ms)

  ● 회원가입 › 이메일 인증 스크린 렌더링

    TypeError: Cannot read properties of null (reading 'useContext')

      24 |
      25 | export const useSetClientAndUserContext = () => {
    > 26 |   const [userState, dispatch] = React.useContext(UserContext);
         |                                       ^
      27 |   const [client, setClient] = React.useState({});
      28 |   const [logout] = useLogout();
      29 |

      at Object.useContext (node_modules/react/cjs/react.development.js:1616:21)
      at useSetClientAndUserContext (hooks/use-set-client-and-usercontext.js:26:39)
      at Object.<anonymous> (__tests__/App-test.js:35:61)

원인

일단 위의 에러의 원인은 아래의 코드에서 useSet ClientAndUserContext()를 사용했기 때문이다.

이것은 커스텀훅이고 그 안에 useContext Hook을 사용하는데 이것을 그냥 냅다 테스트코드안에서 사용하니 당연히 에러가 발생한것이다.

react hook은 오직 컴포넌트안에서만 사용이 가능하다는 사실을 잊지 말자.

import { fireEvent, render } from '@testing-library/react-native';
import React from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { RequestVerificationMutation } from '../graphql/auth/mutation';
import SignupEmailVerificationScreen from '../screens/SignupEmailVerificationScreen';
import { UserProvider } from '../contexts/User';
import { ConsultationProvider } from '../contexts/Consultation';
import { TranslationProvider } from '../contexts/Translation';
import { ApolloProvider } from '@apollo/client';
import { useSetClientAndUserContext } from '../hooks/use-set-client-and-usercontext';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import MockAsyncStorage from 'mock-async-storage';

const mocks = [
  {
    request: {
      query: RequestVerificationMutation,
      variables: {
        input: {
          email: 'shl906@naver.com',
          type: 'email',
        },
      },
    },
    result: {
      data: {
        ok: true,
      },
    },
  },
];

describe('회원가입', () => {
  it('이메일 인증 스크린 렌더링', () => {
    const { userState, client } = useSetClientAndUserContext();
    const wrapper = render(
      <UserProvider>
        <ConsultationProvider>
          <TranslationProvider>
            <ApolloProvider client={client.client}>
              <SafeAreaProvider>
                <SignupEmailVerificationScreen />
              </SafeAreaProvider>
            </ApolloProvider>
          </TranslationProvider>
        </ConsultationProvider>
      </UserProvider>,
    );
    expect(wrapper).toBeTruthy();
  });
});

해결

일단 접근자체가 잘못되었다 판단

현재 의도에서 서버와의 통신을 하고 그것에 대한 응답은 필요없다.

스크린을 잘 렌더링하는것과 그 내부의 텍스트 및 컴포넌트 자체의 동작여부만을 테스트하고자 한다.

ApolloProvider의 client를 실제 처럼 작성해줄 필요가 없다. 그냥 mock데이터로 간소화된 데이터를 입력해주면 된다.

그리하여 간소화된 테스트 코드는 아래와 같다.

import { fireEvent, render } from '@testing-library/react-native';
import React from 'react';
import { RequestVerificationMutation } from '../graphql/auth/mutation';
import SignupEmailVerificationScreen from '../screens/SignupEmailVerificationScreen';

import { ApolloClient, ApolloProvider } from '@apollo/client';
import { InMemoryCache } from 'apollo-boost';
import { UserProvider } from '../contexts/User';

const mocks = [
  {
    request: {
      query: RequestVerificationMutation,
      variables: {
        input: {
          email: 'shl906@naver.com',
          type: 'email',
        },
      },
    },
    result: {
      data: {
        ok: true,
      },
    },
  },
];

describe('회원가입', () => {
  it('이메일 인증 스크린 렌더링 테스트', () => {
    const screen = render(
      <ApolloProvider
        client={
          new ApolloClient({
            link: '',
            cache: new InMemoryCache(),
          })
        }>
        <UserProvider>
          <SignupEmailVerificationScreen
            navigation={() => {}}
            route={{ params: { email: 'shl906@naver.com' } }}
          />
        </UserProvider>
      </ApolloProvider>,
    );
    expect(screen).toBeTruthy();
  });
});

위와 같이 테스트하고자 하는 컴포넌트를 감싸는 ApolloProvider의 client와 cache에 간소화된 데이터들을 입력해준 후 유닛 테스트를 진행하고자 하는 SignupEmailVerificationScreen 컴포넌트가 잘 렌더링 되는지 toBeTruthy로 테스트한 결과 아래처럼 잘 렌더링 되는것을 확인하였다.

테스트 작성 예시

위의 문제와 해결방안들을 통해 테스트를 실행할 수 있는 환경을 구성하였고 아래는 실제 컴포넌트를 테스트하는 예시

올바른 타이틀이 들어있는지 테스트

올바른 타이틀인 '이메일 인증'이 보여지고 있는지 테스트를 진행한다.

const text = screen.getByText

→ ‘이메일 인증’이라는 텍스트를 가지는 노드를 얻은 뒤

expect(text).toBeDefined();

→ ‘이메일 인증’이라는 텍스트를 가지는 노드가 존재하는지 체크한다.

import { fireEvent, render } from '@testing-library/react-native';
import React from 'react';
import { RequestVerificationMutation } from '../graphql/auth/mutation';
import SignupEmailVerificationScreen from '../screens/SignupEmailVerificationScreen';

import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ApolloClient, ApolloProvider } from '@apollo/client';
import { InMemoryCache } from 'apollo-boost';
import { UserProvider } from '../contexts/User';

const mocks = [
  {
    request: {
      query: RequestVerificationMutation,
      variables: {
        input: {
          email: 'shl906@naver.com',
          type: 'email',
        },
      },
    },
    result: {
      data: {
        ok: true,
      },
    },
  },
];

describe('이메일 인증 스크린', () => {
it('이메일 인증 스크린 올바른 타이틀이 보여지는가', () => {
    const screen = render(
      <ApolloProvider
        client={
          new ApolloClient({
            link: '',
            cache: new InMemoryCache(),
          })
        }>
        <UserProvider>
          <SignupEmailVerificationScreen
            navigation={() => {}}
            route={{ params: { email: 'shl906@naver.com' } }}
          />
        </UserProvider>
      </ApolloProvider>,
    );
    const text = screen.getByText('이메일 인증');
    expect(text).toBeDefined();
  });
});

이메일을 입력하지 않을 시 올바른 Alert를 띄워주며 다음화면으로

SignupEmailScreen은 회원가입 시 본인의 이메일을 입력 후 인증번호를 요청하는 기능을 한다.

아무런 값을 입력하지 않고 이메일 인증번호 요청 버튼을 클릭할 경우 올바른 Alert 창을 띄워주고

다음 화면으로 넘어가지 않는지 확인하는 테스트를 진행한다.

const button = screen.getByText('이메일 인증하기');

→ 인증번호 요청 버튼을 가져온다.

fireEvent(button, 'press');

→ 버튼에 ‘press’라는 이벤트를 실행시킨다.

const json = screen.toJSON();

→ 버튼을 누른 후 모습을 json으로 만든다.

expect(Alert.alert).toHaveBeenCalledWith(title, message, alertConfirmText);

→ React-native 의 Alert 메소드가 title, message, alertConfrimText와 같은 매개변수를 넘겨받고 실행 된 적이 있는지 테스트한다.

expect(json).toMatchSnapshot();

→ 초기에 촬영한 스냅샷과 현재 스크린을 비교한다. 만약 입력하지 않아서 경고창은 띄웠는데 실제 화면은 이동하게 되면, 의도하지 않은 노드가 하나 더 생겼을테니 초기 스냅샷과 달라졌기 때문에 오류가 난다.

describe('인증번호 전송 스크린', () => {
  it('인증번호 전송 스크린 이메일입력 안할 시 올바른 Alert를 발생시키는가', () => {
    const screen = render(
      <ApolloProvider
        client={
          new ApolloClient({
            link: '',
            cache: new InMemoryCache(),
          })
        }>
        <UserProvider>
          <SignupEmailScreen navigation={() => {}} />
        </UserProvider>
      </ApolloProvider>,
    );
    const title = '잘못된 이메일';
    const message = '올바른 이메일 주소를 입력해주세요.';
    const alertConfirmText = [{ text: '확인' }];
    const button = screen.getByText('이메일 인증하기');
    expect(button).toBeDefined(); // 이메일 인증하기 버튼이 올바르게 존재하는가?
    fireEvent(button, 'press');
    const json = screen.toJSON();
    expect(Alert.alert).toHaveBeenCalledWith(title, message, alertConfirmText);
    expect(json).toMatchSnapshot();
  });
});
profile
Beyond the wall

0개의 댓글