react-native-web(ts) + jest + path alias 적용

2021. 11. 5. 17:39공부내용 공유하기

jest를 사용한 테스트 코드는 생각보다 쉽고 재밌었는데, 환경 구축이 엄청 빡셌다 ㅠ

 

path alias 적용

1.

 

tsconfig.json에 절대경로 추가

    "baseUrl": ".",
    "paths": {
      "@src/*": ["src/*"]
    },

2.

 

tsconfig-paths 설치

babel-plugin-module-resolver 설치

 

3.

 

babel.config.js - plugins에 module-resolver 설정 추가

    [
      "module-resolver",
      {
        root: ["."],
        extensions: [".ts", ".tsx", ".jsx", ".js", ".json"],
        alias: {
          "@src": "./src",
        },
      },
    ],

 

4.

 

webpack.config.js 내 alias 추가

        "@src": path.resolve(__dirname, "src"),

 

jest 세팅

 

1.

 

jest는 원래 설치되어 있었음. 추가적으로 사용하기 위해 아래 서드파티 설치

 

jest-environment-node

jest-environment-jsdom

jsdom

jest-enzyme

enzyme

enzyme-adapter-react-16

enzyme-to-json

설치

 

 

2.

 

setup-tests.js 추가

// setup-tests.js

import "react-native";
import "jest-enzyme";
import Adapter from "enzyme-adapter-react-16";
import Enzyme from "enzyme";
import { TextEncoder, TextDecoder } from "util";
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
/**
 * Set up DOM in node.js environment for Enzyme to mount to
 */
const { JSDOM } = require("jsdom");

const jsdom = new JSDOM("<!doctype html><html><body></body></html>");
const { window } = jsdom;

function copyProps(src, target) {
  Object.defineProperties(target, {
    ...Object.getOwnPropertyDescriptors(src),
    ...Object.getOwnPropertyDescriptors(target),
  });
}

global.window = window;
global.document = window.document;
global.navigator = {
  userAgent: "node.js",
};
copyProps(window, global);

/**
 * Set up Enzyme to mount to DOM, simulate events,
 * and inspect the DOM in tests.
 */
Enzyme.configure({ adapter: new Adapter() });

 

3.

 

jest.config.js 추가 (기존 package.json 설정도 이관)

const { pathsToModuleNameMapper } = require("ts-jest/utils");
const { compilerOptions } = require("./tsconfig");

module.exports = {
  preset: "ts-jest",
  globals: {
    __DEV__: true,
  },
  collectCoverageFrom: ["src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts"],
  setupFiles: ["react-app-polyfill/jsdom", "<rootDir>/src/testHelper/setup.js"],
  setupFilesAfterEnv: ["<rootDir>/src/testHelper/setup-tests.js"],
  snapshotSerializers: ["enzyme-to-json/serializer"],
  testMatch: [
    "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
    "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}",
  ],
  testEnvironment: "jsdom",
  testRunner: "<rootDir>/node_modules/jest-circus/runner.js",
  transform: {
    "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/config/jest/babelTransform.js",
    "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
    "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)":
      "<rootDir>/config/jest/fileTransform.js",
  },
  transformIgnorePatterns: ["^.+\\.module\\.(css|sass|scss)$"],
  moduleFileExtensions: [
    "web.js",
    "js",
    "web.ts",
    "ts",
    "web.tsx",
    "tsx",
    "json",
    "web.jsx",
    "jsx",
    "node",
  ],
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
  resetMocks: true,
  roots: ["<rootDir>"],
  modulePaths: ["<rootDir>"],
  moduleDirectories: [".", "src", "node_modules"],
  moduleNameMapper: {
    "^react-native$": "react-native-web",
    "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy",
    ...pathsToModuleNameMapper(compilerOptions.paths),
  },
};

 

jest 테스트 util 추가

 

1.

 

getters.ts

import { ReactWrapper } from "enzyme";

const propSelector = (
  propsKey: string | string[],
  value: string
): ((node: ReactWrapper<any, any>) => boolean) => {
  return (node: ReactWrapper<any, any>) => {
    if (typeof propsKey === "string") {
      return node.prop(propsKey) === value;
    } else {
      return !!propsKey.find((key) => node.prop(key) === value);
    }
  };
};

export function innerTextSelector(
  text: string
): (node: ReactWrapper<any, any>) => boolean {
  return (node: ReactWrapper<any, any>) => {
    return node.text() === text;
  };
}

export function getByBtnText(node: ReactWrapper<any, any>, buttonText: string) {
  return node.findWhere(propSelector(["label", "title"], buttonText)).first();
}

export function getByChildText(
  node: ReactWrapper<any, any>,
  childText: string
) {
  return node.findWhere(innerTextSelector(childText)).first();
}

export function getElementByText(node: ReactWrapper<any, any>, text: string) {
  const btnText = getByBtnText(node, text);
  if (btnText.exists()) {
    return btnText;
  }
  const byChild = getByChildText(node, text);
  return byChild;
}

export function getElementById(node: ReactWrapper<any, any>, testId: string) {
  return node.findWhere(propSelector("testID", testId)).first();
}

export function getElementByName(node: ReactWrapper<any, any>, name: string) {
  return node.findWhere(propSelector("name", name)).first();
}

 

2.

 

waitForComponentToPaint.ts 추가 -> act로 액션을 래핑 후 업데이트를 잠깐 기다려줘야 정상 반영된다고 한다.

 

// eslint-disable-next-line @typescript-eslint/ban-types
import { ReactWrapper } from "enzyme";
import React from "react";
import { act } from "react-dom/test-utils";

export type TestWrapper = ReactWrapper<any, any, React.Component<{}, {}, any>>;

export const waitForComponentToPaint = async (wrapper, action) => {
  // eslint-disable-next-line @typescript-eslint/no-misused-promises
  await act(async () => {
    action();
    await new Promise((resolve) =>
      setTimeout(() => {
        resolve(undefined);
      }, 10)
    );
    wrapper.update();
  });
};

 

3.

 

테스트 코드 작성

 

import React from "react";
import { AuthFlow } from "@src/navigation";
import makeFlow from "../../testHelper/makeFlow";
import { mount } from "enzyme";
import { getElementById, getElementByText } from "@src/testHelper/testGetter";
import {
  TestWrapper,
  waitForComponentToPaint,
} from "@src/testHelper/waitForComponentToPaint";
import { ScreenName } from "@src/types/screen";

let props;
let loginScreen: TestWrapper;
let inputId: TestWrapper;
let inputPw: TestWrapper;
let submitBtn: TestWrapper;
let signupBtn: TestWrapper;
let findPwBtn: TestWrapper;

describe("로그인 플로우 테스트", () => {
  props = {};
  const flow = makeFlow(<AuthFlow />);
  const authFlow = mount(flow);

  const updateLoginScreen = () => {
    loginScreen = getElementById(authFlow, "login_screen");
    inputId = getElementById(loginScreen, "input_id");
    inputPw = getElementById(loginScreen, "input_pw");
    submitBtn = getElementById(loginScreen, "login_submit");
    signupBtn = getElementByText(loginScreen, "회원가입");
    findPwBtn = getElementByText(loginScreen, "아이디/비밀번호 찾기");
  };

  updateLoginScreen();

  const spy = jest.spyOn(loginScreen.props().navigation, "navigate");

  const updateWithAction = async (action) => {
    await waitForComponentToPaint(authFlow, () => {
      action();
    });
    updateLoginScreen();
  };

  it("컴포넌트 정상 마운트 확인", () => {
    expect(loginScreen).toExist();
    expect(inputId).toExist();
    expect(inputPw).toExist();
    expect(submitBtn).toExist();
    expect(signupBtn).toExist();
    expect(inputId.props().value).toBe("");
    expect(inputPw.props().value).toBe("");
  });

  it("로그인 실패", async () => {
    await updateWithAction(() => {
      submitBtn.props().onPress();
    });

    expect(spy).not.toBeCalled();
  });

  it("아이디 비밀번호 입력", async () => {
    await updateWithAction(() => {
      inputId.props().onChangeText("slogupid");
      inputPw.props().onChangeText("123123123q");
    });

    expect(inputId.props().value).toBe("slogupid");
    expect(inputPw.props().value).toBe("123123123q");
  });

  it("로그인 성공 -> 홈 화면 이동", async () => {
    expect(submitBtn).toExist();

    await updateWithAction(() => {
      submitBtn.props().onPress();
    });

    expect(spy).toBeCalledWith(ScreenName.HOME);
  });

  it("회원가입 버튼 -> 회원가입 이동", async () => {
    expect(signupBtn).toExist();

    await updateWithAction(() => {
      signupBtn.props().onPress();
    });

    expect(spy).toBeCalledWith(ScreenName.SIGN_UP);
  });

  it("아이디/비밀번호 찾기 버튼 -> 본인인증 이동", async () => {
    expect(findPwBtn).toExist();

    await updateWithAction(() => {
      findPwBtn.props().onPress();
    });

    expect(spy).toBeCalled();
  });
});