20230524 - RN, Jest, E2E Test, Unit Test

Sol·2023년 5월 24일
0

Sol의 아카이빙

목록 보기
26/29
post-thumbnail

사실 큰 착각을 하고 있던 것 아닐까
평소에 테스트 코드를 제대로 작성도 해본 적 없는 인간이
고작 환경설정만 마무리한다고, 테스트가 잘 작동할 리가 없는데...

필요한 환경을 만들기 위해 수 없이 많은 모킹을 생성하고, 랩핑하며 깨달았다.
가장 작은 단위 Unit으로 나눠서 테스트하고,
부족한 부분이 있거나 유저의 행동을 기반한 엣지 케이스가 필요하면,
시나리오를 쓰고 그에 맞게 통합 테스트를 진행해야 하는데

그냥 무식하게 페이지 컴포넌트를 그대로 렌더링하고,
무식하게 모킹함수를 때려 박으며 테스트하고 있었다.

// jestSetupFile.js
jest.mock('@react-native-async-storage/async-storage', () =>
  require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);

require('./node_modules/react-native-gesture-handler/jestSetup.js');

jest.mock('react-native-sound');

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

export const mockedNavigate = jest.fn();
export const mockedSetOptions = jest.fn();
jest.mock('@react-navigation/native', () => ({
  ...jest.requireActual('@react-navigation/native'),
  useNavigation: () => ({
    setOptions: mockedSetOptions,
    navigate: mockedNavigate,
  }),
  useRoute: () => jest.fn(),
  useIsFocused: () => jest.fn(() => true),
}));

jest.mock('react-native-keyboard-aware-scroll-view', () => {
  const KeyboardAwareScrollView = require('react-native').ScrollView;
  return {KeyboardAwareScrollView};
});

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

수북히 쌓여버린 모킹들...

내가 작성한 코드도 아니고, 외주개발의 손에서 태어난 코드라 물어볼 사람도 없는데
어거지로 테스트 코드를 작성하다보니 작동은 하지만 영 맘에 들지는 않았다.

const ModifyPassword = () => {
  const navigation =
    useNavigation<NativeStackNavigationProp<AppStackParamList, 'MyInfo'>>();

  const [currentPW, setCurrentPW] = useState<string>('');
  const [newPW, setNewPW] = useState<string>('');
  const [checkPW, setCheckPW] = useState<string>('');

  const isValid = useMemo(() => {
    if (checkPW !== '' && newPW !== checkPW) {
      return false;
    }

    return true;
  }, [newPW, checkPW]);

  const canSubmit = useMemo(() => {
    if (currentPW === '' || newPW === '' || checkPW === '') {
      return false;
    }

    if (!isValid) {
      return false;
    }

    return true;
  }, [currentPW, newPW, checkPW, isValid]);

  const submit = async () => {
    try {
      await userService.changePassword(currentPW, newPW);
      Toast.show({
        type: 'success',
        text1: '비밀번호를 변경하였습니다.',
        visibilityTime: 1500,
      });
      navigation.goBack();
    } catch (err) {
      // @ts-ignore
      console.error(err.response);
      Toast.show({
        type: 'error',
        text1: '비밀번호 변경 실패',
        visibilityTime: 1500,
      });
    }
  };

  useCustomHeader({
    title: '비밀번호 변경',
  });

  return (
    <Layout>
      <View>
        <Block>
          <BlackTitleText>현재 비밀번호</BlackTitleText>
          <LMTextInput
            placeholder="현재 비밀번호 입력"
            value={currentPW}
            onChangeText={(text) => setCurrentPW(text)}
          />
        </Block>
        <Block>
          <BlackTitleText>변경할 비밀번호</BlackTitleText>
          <LMTextInput
            placeholder="변경할 비밀번호 입력"
            value={newPW}
            onChangeText={(text) => setNewPW(text)}
          />
        </Block>
        <Block>
          <BlackTitleText>비밀번호 확인</BlackTitleText>
          <LMTextInput
            placeholder="비밀번호 재입력"
            error={!isValid}
            value={checkPW}
            onChangeText={(text) => setCheckPW(text)}
          />
          <DefaultErrorMessage
            hasError={!isValid}
            message={'비밀번호가 일치하지 않습니다.'}
          />
        </Block>
      </View>
      <Button
        title="완료"
        size="large"
        disabled={!canSubmit}
        onPress={submit}
      />
    </Layout>
  );
};

export { ModifyPassword };

const navigationTheme = {
  ...DefaultTheme,
  colors: {
    ...DefaultTheme.colors,
    background: 'white',
  },
};

jest.mock('../../services/userService', () => ({
  changePassword: jest.fn(),
}));

describe('ModifyPassword 페이지 테스트', () => {
  it('최초 렌더링 성공', async () => {
    const {getByText, getByPlaceholderText} = render(
      <ThemeProvider theme={theme}>
        <NavigationContainer theme={navigationTheme}>
          <ModifyPassword />
        </NavigationContainer>
      </ThemeProvider>,
    );

    expect(getByPlaceholderText('현재 비밀번호 입력')).toBeTruthy();
    expect(getByPlaceholderText('변경할 비밀번호 입력')).toBeTruthy();
    expect(getByPlaceholderText('비밀번호 재입력')).toBeTruthy();
    expect(getByText('완료')).toBeTruthy();
  });

  it('모든 Input 올바르게 입력할 경우 SubmitButton able', async () => {
    const {getByText, getByPlaceholderText} = render(
      <ThemeProvider theme={theme}>
        <NavigationContainer theme={navigationTheme}>
          <ModifyPassword />
        </NavigationContainer>
      </ThemeProvider>,
    );
    const currentPWInput = getByPlaceholderText('현재 비밀번호 입력');
    const newPWInput = getByPlaceholderText('변경할 비밀번호 입력');
    const checkPWInput = getByPlaceholderText('비밀번호 재입력');
    const submitButton = getByText('완료');

    fireEvent.changeText(currentPWInput, 'currentPassword');
    fireEvent.changeText(newPWInput, 'newPassword');
    fireEvent.changeText(checkPWInput, 'newPassword');

    expect(submitButton.props.disabled).toBe(false);
  });

  it('비어있는 Input 있을 경우 SubmitButton disabled', async () => {
    const {getByText, getByPlaceholderText} = render(
      <ThemeProvider theme={theme}>
        <NavigationContainer theme={navigationTheme}>
          <ModifyPassword />
        </NavigationContainer>
      </ThemeProvider>,
    );
    const currentPWInput = getByPlaceholderText('현재 비밀번호 입력');
    const newPWInput = getByPlaceholderText('변경할 비밀번호 입력');
    const checkPWInput = getByPlaceholderText('비밀번호 재입력');
    const submitButton = getByText('완료');

    fireEvent.changeText(currentPWInput, 'currentPassword');
    fireEvent.changeText(newPWInput, 'newPassword');
    fireEvent.changeText(checkPWInput, '');

    expect(submitButton.props.disabled).toBe(true);
  });

  it('변경할 비밀번호와 재입력 비밀번호 일치하지 않는 경우 SubmitButton disabled', async () => {
    const {getByText, getByPlaceholderText} = render(
      <ThemeProvider theme={theme}>
        <NavigationContainer theme={navigationTheme}>
          <ModifyPassword />
        </NavigationContainer>
      </ThemeProvider>,
    );
    const currentPWInput = getByPlaceholderText('현재 비밀번호 입력');
    const newPWInput = getByPlaceholderText('변경할 비밀번호 입력');
    const checkPWInput = getByPlaceholderText('비밀번호 재입력');
    const submitButton = getByText('완료');

    fireEvent.changeText(currentPWInput, 'currentPassword');
    fireEvent.changeText(newPWInput, 'newPassword1');
    fireEvent.changeText(checkPWInput, 'newPassword2');

    const errorMessage = getByText('비밀번호가 일치하지 않습니다.');

    expect(submitButton.props.disabled).toBe(true);
    expect(errorMessage).toBeDefined();
  });

  it('SubmitButton able일 때 Submit', async () => {
    const {getByText, getByPlaceholderText} = render(
      <ThemeProvider theme={theme}>
        <NavigationContainer theme={navigationTheme}>
          <ModifyPassword />
        </NavigationContainer>
      </ThemeProvider>,
    );
    const currentPWInput = getByPlaceholderText('현재 비밀번호 입력');
    const newPWInput = getByPlaceholderText('변경할 비밀번호 입력');
    const checkPWInput = getByPlaceholderText('비밀번호 재입력');
    const submitButton = getByText('완료');

    const submitSpy = jest.spyOn(userService, 'changePassword');

    fireEvent.changeText(currentPWInput, 'currentPassword');
    fireEvent.changeText(newPWInput, 'newPassword');
    fireEvent.changeText(checkPWInput, 'newPassword');

    fireEvent.press(submitButton);

    expect(submitSpy).toHaveBeenCalledWith('currentPassword', 'newPassword');
  });
});

ModifyPassword 컴포넌트는 mobX의 store를 사용하지 않음에도,
테스트 코드를 작성하면 꽤 내용이 많다.
하지만 로직을 완전히 테스트하지 못한 것 같고,
api 연결에 대한 테스트가 없으니 이 점도 아쉽다.
그리고 store의 action 함수 등을 사용하거나
react-native-navigation의 Hooks,
useRef(), useEffect() 등 react-hooks를 사용하는 등
복잡한 로직이 얽혀있는 컴포넌트의 경우
이러한 단점들이 더욱 눈에 아른거린다.

아무래도 내가 작성한 코드도 아니고,
테스트 코드 작성도 처음이다 보니 첫 단추를 헤맨 것 같다.

우선 확실하게 로직 기반, 유저 액션 기반의 테스트를 나눠야겠다.
jest로 store의 테스트 코드를 작성하고,
Detox를 사용해서 E2E 테스트에 대해 공부해 봐야겠다.
필요한 경우 지금처럼 @testing-library/react-native를 사용해
통합? 테스트도 진행

profile
야호

0개의 댓글