React Native 통합 테스트 가이드 feat: MSW, RNTL

2023년 5월 17일

이 글은 2023년 5월 17일 퍼블 팀 블로그에 먼저 게시되었습니다. 원문 보기

안녕하세요! 퍼블의 소프트웨어 엔지니어 정주형입니다. 퍼블은 기술 없이 나만의 플랫폼으로 수익을 창출할 수 있는 노코드 플랫폼 빌더입니다. 또한 퍼블은 나만의 브랜디드 앱을 만드는 멋진 서비스도 제공하고 있답니다.

저번 글에서 우리는 퍼블에서의 테스트 원칙을 알아보았습니다. 테스트 원칙은 다음과 같습니다. 첫번째, 자신감을 주는 테스트를 써라. 두번째, 기획서에 있는 사항만을 테스트 하라. 그리고 Detox라는 e2e 테스팅 툴을 사용해 간단하게 테스트 작성까지 해 보았습니다.

이번 글에서는 Intergration 테스트 환경을 구축하고, 모킹을 통해서 실제 작성되어 있는 코드에 테스트를 추가해 보도록 하겠습니다.

E2E의 한계

우리가 세운 테스트 원칙에 따르면 모든 테스트는 E2E로 작성되는게 가장 베스트입니다. 트로피 그림에서 볼 수 있듯이, 개발자에게 가장 많은 자신감을 부여하는 테스트가 E2E 테스트기 때문입니다.

그러나 E2E 테스트가 트로피의 꼭대기에 있다는 것은, 그만큼 작성 시간과 유지 비용이 높다는 것을 의미합니다. 거기에 E2E 테스트는 모듈 하나만 분리해서 테스트 하기 쉽지 않습니다. 테스트를 작성하는데 걸리는 시간 뿐 아니라 수행하는 시간마저 오래 걸립니다.

따라서 우리는 E2E 테스트 뿐만 아니라, 여러 모듈들을 통합해서 테스트하는 Integration Test를 적절하게 사용해야 합니다. 무엇을 테스트 하냐와 같은 원론적인 내용은 저번 글에서 충분히 설명 했으므로, 이번 글은 좀 더 실전에 가까운 부분을 설명하도록 하겠습니다. 글은 다음과 같이 이루어져 있습니다.

  • 테스트 환경의 구축
  • 실전 예제
  • 테스트 코드 작성

Before Get Started

테스팅 라이브러리로는 React Native 환경에서 가장 많이 쓰이는 React Native Testing Library를 사용하도록 하겠습니다.

해당 라이브러리에 대해 잠깐 설명을 하고 넘어가자면, React Testing Library의 React Native 버전으로써 둘은 같은 모티베이션을 공유합니다. 즉, 해당 컴포넌트의 구현에 대해서 신경쓰지 않고, 어떻게 실제로 보여지는지에 대해 신경씁니다. 이렇게 함으로써 테스트 코드를 유지 보수할 일을 줄이고 개발자에게는 더 많은 자신감을 부여할 수 있죠.

The more your tests resemble the way your software is used, the more confidence they can give you.

설치

yarn add --dev @testing-library/react-native

npm install --save-dev @testing-library/react-native

React Native Testing Library는 내부적으로 jest를 사용합니다. 그리고 jest는 테스트에서 값의 비교를 위해서 여러가지 matcher를 제공합니다. React Native는 native의 컴포넌트(UIView 등등)들을 사용하므로 matcher를 사용하기 위해서는 React Native용으로 custom 된 matcher를 사용해야 합니다.

yarn add --dev @testing-library/jest-native

npm install --save-dev @testing-library/jest-native

인스톨 후 jest가 해당 matcher들을 사용할 수 있도록 package.json jest 아래에 다음과 같은 항목을 추가합니다.

{
  "preset": "react-native",
  "setupFilesAfterEnv": ["@testing-library/jest-native/extend-expect"]
}

이렇게 해서 React Native에서 jest로 테스트할 준비가 완료 되었습니다.

Mocking

테스트 시나리오는 다음과 같습니다. 앱 구동 시의 유저 플로우 입니다. 원활한 설명을 위해 일부 디테일을 생략하고 플로우 차트를 만들어 보았습니다.

도메인 용어를 완벽히 이해하지 않더라도 어떠한 일이 일어나는지는 쉽게 이해할 수 있을 거라 생각해 관련 설명은 생략하도록 하겠습니다.

해당 테스트 시나리오에서 우리는 두가지 API와 소통해야 합니다. 채널 정보를 반환하는 Rest API와 유저의 로그인 여부를 판별할 수 있도록 토큰을 저장하는 AsyncStorage입니다.

테스트의 기본 전제조건은 멱등성입니다. 동일한 조건에서 동일한 결과값을 뱉어내야 테스트의 신뢰성을 확보할 수 있습니다. 그런데 해당 테스트 시나리오에서는 이 두가지 API라는 외부 요소에 의해 테스트의 결과가 좌우되게 됩니다.

이러한 경우를 생각해 봅시다. 채널의 상태가 정상임에도 불구하고 Rest API의 오류로 해당 채널이 비정상이라는 값이 오고 있습니다. 혹은 토큰 저장 과정에서 AsyncStorage의 에러로 인해 저장이 되지 않았습니다. 두가지 모두 코드에는 문제가 없음에도 불구하고 테스트는 실패할 것입니다.

이처럼 외부 요소의 문제로 인해 테스트의 신뢰성이 보장받지 못하는 상황을 예방하기 위해 우리는 외부 요소들을 원하는대로 동작할 수 있도록 모킹해야 합니다.

앗, 그러면 E2E 테스트에서도 외부 API를 모킹해서 사용해야 하지 않나요? 라고 질문하실 분들이 있을 것 같습니다. 실제로 E2E 테스트에서도 모킹을 해서 테스트 하는 경우가 있습니다.

그러나 원론적으로 E2E 테스트는 실제 유저가 실제 어플리케이션을 사용하는 것을 테스트 합니다. 따라서 외부적 요소로 인해 테스트가 깨지더라도 문제가 되지는 않습니다.

Mock AsyncStorage

우선 간단한 AsyncStorage 부터 모킹해 보도록 합시다. 해당 라이브러리는 목 라이브러리를 기본적으로 제공합니다. 즉 jest.fn()을 사용해서 내가 직접 모킹할 필요가 없습니다.

실제로 구현한다면 아래를 참고해서 구현해주세요. 해당 내용은 실제 라이브러리에서 공식으로 제공해주는 mock 입니다.

/**
 * @format
 */

const merge = require('merge-options').bind({
  concatArrays: true,
  ignoreUndefined: true,
});

const asMock = {
  __INTERNAL_MOCK_STORAGE__: {},

  setItem: jest.fn(async (key, value, callback) => {
    const setResult = await asMock.multiSet([[key, value]], undefined);

    callback && callback(setResult);
    return setResult;
  }),

  getItem: jest.fn(async (key, callback) => {
    const getResult = await asMock.multiGet([key], undefined);

    const result = getResult[0] ? getResult[0][1] : null;

    callback && callback(null, result);
    return result;
  }),

  removeItem: jest.fn((key, callback) => asMock.multiRemove([key], callback)),
  mergeItem: jest.fn((key, value, callback) =>
    asMock.multiMerge([[key, value]], callback)
  ),

  clear: jest.fn(_clear),
  getAllKeys: jest.fn(_getAllKeys),
  flushGetRequests: jest.fn(),

  multiGet: jest.fn(_multiGet),
  multiSet: jest.fn(_multiSet),
  multiRemove: jest.fn(_multiRemove),
  multiMerge: jest.fn(_multiMerge),
  useAsyncStorage: jest.fn((key) => {
    return {
      getItem: (...args) => asMock.getItem(key, ...args),
      setItem: (...args) => asMock.setItem(key, ...args),
      mergeItem: (...args) => asMock.mergeItem(key, ...args),
      removeItem: (...args) => asMock.removeItem(key, ...args),
    };
  }),
};

async function _multiSet(keyValuePairs, callback) {
  keyValuePairs.forEach((keyValue) => {
    const key = keyValue[0];

    asMock.__INTERNAL_MOCK_STORAGE__[key] = keyValue[1];
  });
  callback && callback(null);
  return null;
}

async function _multiGet(keys, callback) {
  const values = keys.map((key) => [
    key,
    asMock.__INTERNAL_MOCK_STORAGE__[key] || null,
  ]);
  callback && callback(null, values);

  return values;
}

async function _multiRemove(keys, callback) {
  keys.forEach((key) => {
    if (asMock.__INTERNAL_MOCK_STORAGE__[key]) {
      delete asMock.__INTERNAL_MOCK_STORAGE__[key];
    }
  });

  callback && callback(null);
  return null;
}

async function _clear(callback) {
  asMock.__INTERNAL_MOCK_STORAGE__ = {};

  callback && callback(null);

  return null;
}

async function _getAllKeys() {
  return Object.keys(asMock.__INTERNAL_MOCK_STORAGE__);
}

async function _multiMerge(keyValuePairs, callback) {
  keyValuePairs.forEach((keyValue) => {
    const [key, value] = keyValue;
    const oldValue = asMock.__INTERNAL_MOCK_STORAGE__[key];
    asMock.__INTERNAL_MOCK_STORAGE__[key] =
      oldValue != null
        ? JSON.stringify(merge(JSON.parse(oldValue), JSON.parse(value)))
        : value;
  });

  callback && callback(null);
  return null;
}

module.exports = asMock;

만약 해당 Mock의 모든 기능이 필요하지 않다거나, Mock중에 특정 동작을 추가하고 싶다면 메서드들을 다음과 같이 override 할 수 도 있습니다.

// somewhere in your configuration files
import AsyncStorageMock from '@react-native-async-storage/async-storage/jest/async-storage-mock';

AsyncStorageMock.multiGet = jest.fn(([keys], callback) => {
  // do something here to retrieve data
  callback([]);
});

export default AsyncStorageMock;

Mock Http Request

외부 API들은 jest.fn을 사용해서 모킹할 수 있습니다. 그런데 Http 요청은 어떻게 모킹할 수 있을까요? 가장 쉬운 방법은 Mock Service Worker (이하 MSW)를 사용하는 것입니다. MSW는 서비스 워커를 사용해서 Http 요청을 intercept 후 사용자가 원하는대로 변조해서 다시 돌려줍니다.

서비스 워커는 브라우저에서 작동하므로 아쉽지만 React Native는 실제 개발 중에 MSW를 사용할 수 없습니다. 그러나 다행히도 MSW는 node js 환경에서의 모킹을 지원합니다. 따라서 node 환경에서 작동하는 jest에서는 사용할 수 있습니다. (그러나 jest 환경에서만 작동이 가능합니다. E2E 환경에서는 모킹할 수 없습니다!)

MSW를 설정하는 방법은 간단합니다.

npm install msw --save-dev
# or
yarn add msw --dev

라이브러리를 설치한 후, 서버를 설정해 줍니다.

// touch src/mocks/server.ts

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

그리고 사용될 handler를 설정해 줍니다.

// touch src/mocks/handlers.ts

import {rest} from 'msw';
import {CHANNEL_API, CHANNEL_TYPE} from '../common/constants';

export const handlers = [
  rest.get(CHANNEL_API, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        channelInfo: CHANNEL_TYPE.PUBLIC,
      }),
    );
  }),
];

이러면 끝입니다. 정말 간단하죠. 이제 CHANNEL_API 주소로 요청이 오면, MSW가 해당 요청을 가로채서 handler에 명시된 응답을 반환합니다.

Fixture

이제 Async Storage와 MSW의 설정이 끝났습니다. 이제는 상황별로 모킹할 차례입니다. 정상인 채널, 비정상인 채널, 제한이 존재하는 채널 등 여러가지시나리오를 상정해서 Fixture들을 만들어 봅시다.

export const CHANNEL_API_ERROR_FIXTURE = {
  error: '500 error',
};

export const RESTRICTED_CHANNEL_API_FIXTURE = {
  channelInfo: CHANNEL_TYPE.PRIVATE,
};

export const PUBLIC_CHANNEL_API_FIXTURE = {
  channelInfo: CHANNEL_TYPE.PUBLIC,
};

이제 테스트 시나리오마다 사용될 Fixture등을 선택해주면 됩니다.

- 채널의 정보를 불러오기 전일 경우
  - 로딩 화면을 볼 수 있다

- 정상이 아닌 채널일 경우
  - 정상이 아닌 화면을 볼 수 있다

- 제한된 채널일 경우
  - 유저가 자동으로 로그인 한 경우
    - 메인 페이지가 보여진다
  - 유저가 자동으로 로그인 하지 않은 경우
    - 로그인 화면이 보여진다
  
- 제한되지 않은 채널일 경우
  - 유저가 자동으로 로그인 한 경우
    - 메인 페이지가 보여진다
  - 유저가 자동으로 로그인 하지 않은 경우
    - 메인 페이지가 보여진다

이미 코드는 구현된 상태라고 가정하겠습니다. 실제로 구현된 코드에서는 Redux와 Redux Saga를 이용해서 기획 사항을 구현하였습니다. 그러나 이 글에서는 해당 구현 사항을 의도적으로 생략하도록 하겠습니다.

구현된 디테일에 상관 없이 테스트를 만족할 수 있어야 제대로 된 테스트 코드라고 할 수 있기 때문입니다.

Before Test

beforeAll(() => {
  server.listen();
  AsyncStorage.clear();
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => server.close());

각 테스트간의 독립성을 보장하기 위해 테스트 전에 몇가지 설정을 해야 합니다. 모든 테스트 전에 MSW로 하여금 API 요청등을 인터셉트 할 수 있도록 함과 동시에 AsyncStorage를 비우 도록 합시다.

테스트 후에는 MSW에서 사용된 핸들러들을 초기화 시켜 주고 연결을 닫습니다. 구현된 코드는 Redux를 사용하고 있으므로 store도 매번 새로 생성해 주어야 합니다. (store의 상태를 직접 초기화 시켜주는 것은 안티패턴입니다!)

따라서 다음과 같이 render 함수를 wrapping 해 주어야 합니다.

import React, {PropsWithChildren} from 'react';
import {Provider} from 'react-redux';
import {render} from '@testing-library/react-native';
import type {RenderOptions} from '@testing-library/react-native';
import {configureStore} from '@reduxjs/toolkit';
import type {PreloadedState} from '@reduxjs/toolkit';
import sessionReducer from '../redux/modules/sessionSlice';

import type {AppStore, RootState} from '../redux/store';
import createSagaMiddleware from 'redux-saga';
import channelSaga from '../redux/sagas/sagas';
// As a basic setup, import your same slice reducers

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: PreloadedState<RootState>;
  store?: AppStore;
}

const sagaMiddleware = createSagaMiddleware();

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {
      session: {
        error: false,
        userAccesibility: null,
      },
    },
    // Automatically create a store instance if no store was passed in
    store = configureStore({
      reducer: {session: sessionReducer},
      middleware: getDefaultMiddleware =>
        getDefaultMiddleware().concat(sagaMiddleware),
      preloadedState,
    }),
    ...renderOptions
  }: ExtendedRenderOptions = {},
) {
  // If use saga, run here.
  sagaMiddleware.run(channelSaga);

  function Wrapper({children}: PropsWithChildren<{}>): JSX.Element {
    return <Provider store={store}>{children}</Provider>;
  }

  // Return an object with the store and all of RTL's query functions
  return {store, ...render(ui, {wrapper: Wrapper, ...renderOptions})};
}

자세한 설명은 해당 문서를 참조해 주세요.

기본 테스트 시작

describe('앱 구동 플로우 테스트', () => {}

우리는 앱 구동 플로우를 테스트 하고 있으므로 가장 먼저 전체 테스트 케이스의 이름을 짓도록 합시다. 첫번째 케이스는 다음과 같습니다.

- 채널의 정보를 불러오기 전일 경우
  - 로딩 화면을 볼 수 있다
  describe('채널의 정보를 불러오기 전일 경우', () => {
    it('로딩 화면이 보여진다', async () => {
      const {getByTestId} = renderWithProviders(<App />);

      await waitFor(() => {
        const LoadingComponent = getByTestId('Loading');
        expect(LoadingComponent).toBeOnTheScreen();
      });
    });
  });

첫번째 테스트의 경우 간단합니다. App이 렌더될 경우 로딩이 보여야 합니다. 이미 기획 사항에 따라 채널 정보를 불러오기 전까진 Loading 화면이 보여지고 있을 것입니다. 따라서 우리가 테스트 할 것은 App이 render 되었을때 Loading 화면이 보여졌는지만 테스트 하면 됩니다.

에러에 대응해야 하는 경우

두번째 테스트 케이스는 다음과 같습니다.

- 정상이 아닌 채널일 경우
  - 정상이 아닌 화면을 볼 수 있다

이미 우리의 코드는 CHANNEL_API 주소에 요청을 보내서 반환된 값으로 특정 UI를 보여주고 있을 것입니다. 해당 테스트 케이스를 위해서는 해당 주소에 보내지는 요청을 가로챈 뒤 정상이 아닌 채널이라는 값을 돌려주면 됩니다.

그런데 생각해 봅시다. 구현된 코드에서 요청을 보내는 주소는 CHANNEL_API 주소입니다. MSW의 CHANNEL_API 핸들러에 할당된 반환값은 다음과 같습니다.

import {rest} from 'msw';
import {CHANNEL_API, CHANNEL_TYPE} from '../common/constants';

export const handlers = [
  rest.get(CHANNEL_API, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        channelInfo: CHANNEL_TYPE.PUBLIC,
      }),
    );
  }),
];

해당 주소로 요청이 가면 CHANNEL_TYPE.PUBLIC 이 반환되게 됩니다. 그런데 해당 테스트 케이스의 경우 실패하는 값 (에러)를 보내주어야 합니다. 이렇게 핸들러를 케이스 별로 바꿔주려면 테스트 케이스의 런타임에 핸들러를 새로 세팅해 주어야 합니다.

export function overrideApi(url: string, status: number, body: any) {
  server.use(
    rest.get(url, (req, res, ctx) => {
      return res(ctx.status(status), ctx.json(body));
    }),
  );
}

따라서 다음과 같은 공용 함수를 생성해 주었습니다.

  describe('채널의 정보를 정상적으로 불러오지 못한 경우', () => {
    it('에러 화면이 보여진다', async () => {
      overrideApi(CHANNEL_API, 500, CHANNEL_API_ERROR_FIXTURE);

      const {getByTestId} = renderWithProviders(<App />);

      await waitFor(() => {
        const ErrorComponent = getByTestId('Error');
        expect(ErrorComponent).toBeOnTheScreen();
      });
    });
  });

이제 해당 테스트 케이스에서는 CHANNEL_API 로 보내지는 모든 요청은 500 에러를 반환하게 됩니다. 구현된 코드가 이러한 에러 케이스에 대해 기획서에 정의된 대로 잘 작동 하는지 테스트 할 수 있겠죠.

토큰으로 로그인을 체크하는 경우

- 제한된 채널일 경우
  - 유저가 자동으로 로그인 한 경우
    - 메인 페이지가 보여진다
  - 유저가 자동으로 로그인 하지 않은 경우
    - 로그인 화면이 보여진다

다음 케이스에서는 두가지 요소가 필요합니다. 우선 CHANNEL_API 요청이 해당 채널은 제한인 채널이라는 정보를 반환해야 합니다. 그리고 Async storage에 토큰이 있는 경우, 없는 경우가 필요하죠.

  describe('제한된 채널일 경우', () => {
    describe('유저가 로그인 하지 않은 경우', () => {
      it('로그인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, RESTRICTED_CHANNEL_API_FIXTURE);
        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const LoginComponent = getByTestId('Login');
          expect(LoginComponent).toBeOnTheScreen();
        });
      });
    });

    describe('유저가 로그인 한 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, RESTRICTED_CHANNEL_API_FIXTURE);
        await AsyncStorage.setItem('token', 'some jwt token');

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });
  });

실제로 로그인 여부의 판단은 이보다 복잡하지만, 설명을 위해 단순화하였습니다. 테스트 전에 AsyncStorage에 토큰을 설정합니다.

await AsyncStorage.setItem('token', 'some jwt token');

App 컴포넌트는 토큰의 여부에 따라 회원 가입 여부를 판단하고 있습니다. 이제 기획서에 적힌 대로 테스트 하면 해당 케이스는 완료입니다. 제한된 채널인데 로그인하지 않은 경우에는 로그인 화면이 보여져야 하고, 아닌 경우는 정상적으로 진입해야 합니다.

마지막 테스트 케이스

- 제한이 존재하지 않는 채널의 경우
  - 유저가 자동으로 로그인 한 경우
    - 메인 페이지가 보여진다
  - 유저가 자동으로 로그인 하지 않은 경우
    - 메인 페이지가 보여진다
  describe('제한되지 않은 채널일 경우', () => {
    describe('유저가 로그인 한 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, PUBLIC_CHANNEL_API_FIXTURE);
        await AsyncStorage.setItem('token', 'some jwt token');

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });

    describe('유저가 로그인 하지 않은 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, PUBLIC_CHANNEL_API_FIXTURE);

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });
  });

마지막 테스트 케이스의 경우 로그인 여부와 상관 없이 메인 페이지가 보여야 합니다.

앱 구동시 유저 플로우에 대한 전체 테스트 코드는 다음과 같습니다.

import React from 'react';
import {waitFor} from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import App from '../App';
import {server} from '../src/mocks/server';
import {CHANNEL_API} from '../src/common/constants';
import {
  CHANNEL_API_ERROR_FIXTURE,
  PUBLIC_CHANNEL_API_FIXTURE,
  RESTRICTED_CHANNEL_API_FIXTURE,
  overrideApi,
} from '../src/mocks/fixtures';
import {renderWithProviders} from '../src/util/test-utils';

beforeAll(() => {
  server.listen();
  AsyncStorage.clear();
});

afterEach(() => {
  server.resetHandlers();
});

afterAll(() => server.close());

describe('앱 구동 플로우 테스트', () => {
  describe('채널의 정보를 불러오기 전일 경우', () => {
    it('로딩 화면이 보여진다', async () => {
      const {getByTestId} = renderWithProviders(<App />);

      await waitFor(() => {
        const LoadingComponent = getByTestId('Loading');
        expect(LoadingComponent).toBeOnTheScreen();
      });
    });
  });

  describe('채널의 정보를 정상적으로 불러오지 못한 경우', () => {
    it('에러 화면이 보여진다', async () => {
      overrideApi(CHANNEL_API, 500, CHANNEL_API_ERROR_FIXTURE);

      const {getByTestId} = renderWithProviders(<App />);

      await waitFor(() => {
        const ErrorComponent = getByTestId('Error');
        expect(ErrorComponent).toBeOnTheScreen();
      });
    });
  });

  describe('제한된 채널일 경우', () => {
    describe('유저가 로그인 하지 않은 경우', () => {
      it('로그인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, RESTRICTED_CHANNEL_API_FIXTURE);
        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const LoginComponent = getByTestId('Login');
          expect(LoginComponent).toBeOnTheScreen();
        });
      });
    });

    describe('유저가 로그인 한 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, RESTRICTED_CHANNEL_API_FIXTURE);
        await AsyncStorage.setItem('token', 'some jwt token');

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });
  });

  describe('제한되지 않은 채널일 경우', () => {
    describe('유저가 로그인 한 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, PUBLIC_CHANNEL_API_FIXTURE);
        await AsyncStorage.setItem('token', 'some jwt token');

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });

    describe('유저가 로그인 하지 않은 경우', () => {
      it('메인 페이지가 보여진다', async () => {
        overrideApi(CHANNEL_API, 200, PUBLIC_CHANNEL_API_FIXTURE);

        const {getByTestId} = renderWithProviders(<App />);

        await waitFor(() => {
          const MainComponent = getByTestId('Home');
          expect(MainComponent).toBeOnTheScreen();
        });
      });
    });
  });
});

이제 yarn test를 돌려 봅시다.

Tested!

다행이 구현에는 문제가 없었는지, 모든 테스트 케이스가 성공하였습니다. 특정 상황일때 어떠한 값, UI가 나와야 한다라는 테스트 케이스를 다 작성하였으므로 이제 내부 로직을 안전하게 리팩토링 할 수 있게 되었습니다. Redux Saga 대신에 React Query를 사용한다던지 어떤 일을 하더라도 결과값만 같으면 되니까요!

물론 이번 글에서는 매우 간단한 플로우만 다루었습니다. 실제 도메인은 더 복잡하고, 테스트도 복잡해 질 것입니다. 그러나 원칙은 위에서도 말했듯이 간단합니다. 유저가 특정 행동을 했을 때 기획서에 적힌 내용대로 동작했는지, 그리고 해당 테스트를 씀으로써 개발자가 자신감 있게 코드를 배포할 수 있게 되었는지만 체크하면 됩니다.

그것이 E2E든, Intergration 테스트든 아니면 심지어 타입 체크만 해주는 테스트라도 상관 없습니다. 물론 타입 체크로는 자신감을 그리 얻기 힘들겠지만서두요. 그러면 해당 글이 테스트 도입을 고민하거나 테스트를 작성하시는 분들에게 도움이 되었기 바라며 글을 마치도록 하겠습니다. 감사합니다.


React Native 통합 테스트 가이드 feat: MSW, RNTL was originally published in 퍼블 팀 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.