Skip to content

anisotropy/hook-test

Repository files navigation

hook-test

아래에서 설명한 createHookhookTest에 대한 소스 코드는 src/lib/에서 확인할 수 있습니다.

renderHook없이 훅 테스트 하기

리액트를 이용한 프로젝트에서는 커스텀 훅을 많이 작성하고, 훅에 대한 테스트를 작성하는 경우도 많습니다. 커스턴 훅을 테스트 하기 위해 testing-libraryrenderHook을 사용할 수 있지만, renderHook의 단점은 커스텀 훅 내에 존재하는 상태를 직접 변경할 수는 없다는 것입니다. 이를 해결할 수 있는 한가지 방법은 훅의 로직을 그래돌 가지고 있으면서, 부수 효과를 일으키는 모든 암묵적 입출력은 제거된 함수(순수함수)를 작성하고, 이 함수를 테스트하는 것입니다.

훅에서 순수함수 분리하기

예를 들어 아래와 같이 form을 핸들링하기 위해 useForm이라는 커스텀 훅을 작성할 수 있습니다.

const useForm = () => {
  const [state, setState] = useState({
    value: "",
    error: "",
    isSubmitting: false,
  });
  const change = (value: string) => {
    setState((prev) => ({ ...prev, value }));
  };
  const submit = async () => {
    setState((prev) => ({ ...prev, isSubmitting: true }));
    try {
      await submitValue(state.value);
      setState((prev) => ({ ...prev, error: "", isSubmitting: false }));
    } catch (error) {
      setState((prev) => ({ ...prev, error: "error", isSubmitting: false }));
    }
  };
  return { ...state, change, submit };
};

const Form = () => {
  const form = useForm();
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.submit();
      }}
    >
      <input value={form.value} onChange={(e) => form.change(e.target.value)} />
      <p>{form.error}</p>
    </form>
  );
};

renderHook을 이용해 useForm에 대한 테스트를 작성할 수 있지만, state를 직접 설정할 수는 없기 때문에, 테스트를 작성하는데, 제약이 있을 수 밖에 없습니다. useForm에서 부수효과를 일으키는 state, setState, submitValue를 인자로 받을 수 있다면 함수를 작성하면, 이 함수는 renderHook이 없이도 충분히 테스트 할 수 있습니다. 아래의 formHandleruseForm에서 암묵적인 입출력을 제거한 순수 함수입니다.

type State = { value: string; error: string; isSubmitting: boolean };

type Params = {
  state: State;
  submitter: (value: State["value"]) => Promise<void>;
  setState: (set: (state: State) => State) => void;
};

const formHandler = ({ state, submitter, setState }: Params) => {
  const change = (value: string) => {
    setState((prev) => ({ ...prev, value }));
  };
  const submit = async () => {
    setState((prev) => ({ ...prev, isSubmitting: true }));
    try {
      await submitter(state.value);
      setState((prev) => ({ ...prev, error: "", isSubmitting: false }));
    } catch (error) {
      setState((prev) => ({ ...prev, error: "error", isSubmitting: false }));
    }
  };
  return { ...state, change, submit };
};

const useForm = () => {
  const [state, setState] = useState<State>({
    value: "",
    error: "",
    isSubmitting: false,
  });
  return formHandler({ state, submitter: submitValue, setState: setState });
};

formHandler에 대한 테스트를 작성할 때 한가지 어려움이 있는데, formHandleruseForm과는 다르게, 상태의 변경 사항이 다시 적용되지 않기 때문에, 테스트를 작성할 때 그렇게 될 수 있도록 코드를 작성해주어야 한다는 것입니다. 아래의 코드는, 이 문제를 해결하기 위해 작성한 코드의 일부분입니다. useState에 의해 생성되는 statesetState의 역할을 대신 해줄 수 있는 객체와 함수를 만든 다음, getResult를 호출하면 갱신된 상태가 formHandler에 적용될 수 있도록 할 수 있습니다.

test.each(testCases)(
  "$name",
  async ({ params, prevState, event, doExpect }) => {
    const state = { current: prevState };
    const setState: Params["setState"] = (set) => {
      state.current = set(state.current);
    };
    const getResult = (state: Params["state"]) =>
      formHandler({ ...params, state, setState });
    event?.(getResult(state.current));
    await doExpect(() => getResult(state.current));
  }
);

만약 시간 지연이 있는 비동기 함수의 호출을 테스트하고 싶은 경우에는 [waitFor](https://testing-library.com/docs/dom-testing-library/api-async/#waitfor) 내에서 getResult을 호출하면 됩니다. 다음은 formHandler의 비동기 함수인 submit을 테스트 하기 위한 코드입니다.

type Params = Parameters<typeof formHandler>[0];
type Result = ReturnType<typeof formHandler>;

const createState = (state?: Partial<Params["state"]>): Params["state"] => ({
  value: "",
  error: "",
  isSubmitting: false,
  ...state,
});

const createParams = (params?: Partial<Params>): Params => ({
  state: createState(),
  submitter: async (value: string) => {
    await new Promise((r) => setTimeout(r, 100));
    if (value === "xxx") throw new Error("");
  },
  setState: () => undefined,
  ...params,
});

describe("formHandler", () => {
  type TestCases = {
    name: string;
    params: Params;
    prevState: Params["state"];
    event?: (result: Result) => void;
    doExpect: (getResult: () => Result) => Promise<void> | void;
  }[];
  const testCases: TestCases = [
    {
      name: "submit하는 경우",
      params: createParams(),
      prevState: createState({
        value: "a",
        isSubmitting: false,
        error: "error",
      }),
      event: (result) => {
        result.submit();
      },
      doExpect: async (getResult) => {
        expect(getResult().isSubmitting).toBe(true);
        await waitFor(() => {
          const { isSubmitting, error } = getResult();
          expect({ isSubmitting, error }).toEqual({
            isSubmitting: false,
            error: "",
          });
        });
      },
    },
  ];
  test.each(testCases)(
    "$name",
    async ({ params, prevState, event, doExpect }) => {
      const state = { current: prevState };
      const setState: Params["setState"] = (set) => {
        state.current = set(state.current);
      };
      const getResult = (state: Params["state"]) =>
        formHandler({ ...params, state, setState });
      event?.(getResult(state.current));
      await doExpect(() => getResult(state.current));
    }
  );
});

createStatecreateParams는 테스트 케이스가 여러 개일 경우, 중복되는 입력값 설정을 피하기 위해 작성한 함수입니다. createParams에서 state(): cretateState()setState: () => undefined는 사실상 무의미한 코드입니다. test.each(testCase)(…)내에서 덮어쓰게 될 것이기 때문입니다. TestCase 타입을 살펴보면 테스트의 흐름을 알 수 있습니다.

  • params: 테스트에 필요한 데이터 및 함수를 설정합니다. (여기에서는 submit에 대한 mock 함수를 설정합니다.)
  • prevState: ‘이전’ 상태를 설정합니다.
  • event: 이벤트를 트리거합니다.
  • doExpect: 트리거된 이벤트에 따라 상태 변경이 올바르게 이루어졌는지 확인합니다.

실제로 작성된 테스트를 살펴보면

  • params: submit에 대한 mock 함수를 설정하고,
  • prevState: ‘이전’ 상태가 { value: 'a', isSubmitting: false, error: 'error' } 라고 가정하고,
  • event: submit 을 호출합니다.
  • doExpect: submit이 호출되면, 우선 isSubmitting이 먼저 true로 변경이 된 것을 확인하고, submit이 완료되면, isSubmitting이 다시 false로 변경되고, error에 빈문자열이 설정되는 것(정상적인 비동기 함수 호출이므로 에러가 발생되지 않았습니다)을 확인합니다.

submit은 시간 지연이 있는 비동기 함수이기 때문에, 결과를 확인하려면 waitFor 내에서 결과를 확인해야 합니다. waitFor는 일정시간 간격으로 콜백 함수를 반복적으로 실행하기 때문에, 콜백 함수 내에서 getResult를 호출하면, getResult를 반복적으로 호출할 수 있어, 상태 변화를 확인할 수 있습니다.

반복되는 코드 함수화하기

많은 양의 훅을 이러한 방식으로 소스 코드와 테스트를 작성한다면, 반복되는 코드의 양의 상당히 많을 것입니다. 반복되는 코드를 분리해 함수화하면 이러한 불필요한 코드 작성을 피할 수 있을 것입니다. 먼저, 다음과 같이 formHandler와 같은 순수 함수를 훅으로 만들어주는 함수인 createHook를 작성할 수 있습니다.

type SetState<S> = (set: (state: S) => S) => void;

type Handler<Config, State, Result> = (
  config: Config,
  state: State,
  setState: SetState<State>
) => Result;

const createHook =
  <Config, State, Result>(
    createState: () => [State, SetState<State>],
    config: Config,
    handler: Handler<Config, State, Result>
  ) =>
  () => {
    const [state, setState] = createState();
    return handler(config, state, setState);
  };

createStateuseState의 리턴값을 리턴하는 함수이고, config는 함수 본문에서 사용될 비동기 함수와 같은 암묵적 입출력이며, handler는 훅으로 변환할 순수 함수입니다. createHook을 사용하면 다음과 같이 훅의 순수 함수 부분을 완전히 분리해서 작성할 수 있습니다.

const initState: State = { value: "", error: "", isSubmitting: false };

const formHandler = (
  config: { submitter: (value: State["value"]) => Promise<void> },
  state: State,
  setState: SetState<State>
) => { ... };

const useForm = createHook(
  () => useState<State>(initState),
  { submitter: submitValue },
  formHandler
);

훅으로 변환할 순수 함수를 테스트하기 위한 함수도 작성한다면 반복되는 코드의 양을 줄일 수 있을 것입니다.

type TestCase<Config, State, Result> = {
  name: string;
  config?: Partial<Config>;
  prevState?: Partial<State>;
  event?: (result: Result) => void;
  doExpect: (getResult: () => Result) => Promise<void> | void;
};

const hookTest = <Config, State, Result>(params: {
  name: string;
  handler: Handler<Config, State, Result>;
  defaultConfig: Config;
  defaultState: State;
  testCases: TestCase<Config, State, Result>[];
}) => {
  const { name, handler, defaultConfig, defaultState, testCases } = params;
  const createState = (state?: Partial<State>): State => ({
    ...defaultState,
    ...state,
  });
  const createConfig = (config?: Partial<Config>): Config => ({
    ...defaultConfig,
    ...config,
  });
  describe(name, () => {
    test.each(testCases)(
      "$name",
      async ({ config, prevState, event, doExpect }) => {
        const state = { current: createState(prevState) };
        const setState: SetState<State> = (set) => {
          state.current = set(state.current);
        };
        const getResult = (state: State) =>
          handler(createConfig(config), state, setState);
        event?.(getResult(state.current));
        await doExpect(() => getResult(state.current));
      }
    );
  });
};

hookTest를 사용하면 구체적인 구현에 신경쓰지 않고 테스트를 작성할 수 있습니다.

hookTest({
  name: "useForm 테스트",
  handler: formHandler,
  defaultConfig: {
    submitter: async (value: string) => {
      await new Promise((r) => setTimeout(r, 100));
      if (value === "xxx") throw new Error("");
    },
  },
  defaultState: { value: "", error: "", isSubmitting: false },
  testCases: [
    {
      name: "값을 변경하는 경우",
      event: (result) => {
        result.change("a");
      },
      doExpect: (getResult) => {
        expect(getResult().value).toBe("a");
      },
    },
    {
      name: "submit 하는 경우",
      prevState: { value: "a", isSubmitting: false, error: "error" },
      event: (result) => {
        result.submit();
      },
      doExpect: async (getResult) => {
        expect(getResult().isSubmitting).toBe(true);
        await waitFor(() => {
          const { isSubmitting, error } = getResult();
          expect({ isSubmitting, error }).toEqual({
            isSubmitting: false,
            error: "",
          });
        });
      },
    },
    {
      name: "submit을 할 때 에러가 발생하는 경우",
      prevState: { value: "xxx", isSubmitting: false, error: "" },
      event: (result) => {
        result.submit();
      },
      doExpect: async (getResult) => {
        expect(getResult().isSubmitting).toBe(true);
        await waitFor(() => {
          const { isSubmitting, error } = getResult();
          expect({ isSubmitting, error }).toEqual({
            isSubmitting: false,
            error: "error",
          });
        });
      },
    },
  ],
});

확장하기

createHookhookTest는 매우 유용한 도구이지만, 매개변수를 가지고 있거나 useRef를 사용하는 훅에 대해서도 사용할 수 있게 하기 위해서는, 코드를 좀 더 추가해야 합니다. 먼저, useRefuseState 처럼 다룰 수 있도록 useRefState라는 커스텀 훅을 작성합니다.

type SetRef<R> = SetState<R>;

const useRefState = <Ref,>(initRef: Ref): [Ref, SetRef<Ref>] => {
  const ref = useRef(initRef);
  const setRef = (set: (prev: Ref) => Ref) => {
    ref.current = set(ref.current);
  };
  return [ref.current, setRef];
};

그리고 createHook을 확장해 매개변수를 가지고 있고 useRefState를 사용하는 훅을 생성할 수 있도록 합니다.

type CreateState<Params, S> = (params: Params) => [S, SetState<S>];

type CreateRef<Params, R> = CreateState<Params, R>;

type Handler<Params, Config, State, Ref, Result> = (handlerParams: {
  config: Config;
  state: State;
  setState: SetState<State>;
  ref: Ref;
  setRef: SetRef<Ref>;
}) => (params: Params) => Result;

const createHook =
  <Params = void, Config = void, State = void, Ref = void, Result = void>(
    handler: Handler<Params, Config, State, Ref, Result>
  ) =>
  (
    hookParams: (Config extends void ? {} : { config: Config }) &
      (State extends void ? {} : { createState: CreateState<Params, State> }) &
      (Ref extends void ? {} : { createRef: CreateRef<Params, Ref> })
  ) =>
  (params: Params) => {
    const config =
      "config" in hookParams ? hookParams.config : (undefined as Config);
    const [state, setState] =
      "createState" in hookParams
        ? hookParams.createState(params)
        : ([] as unknown as ReturnType<CreateState<Params, State>>);
    const [ref, setRef] =
      "createRef" in hookParams
        ? hookParams.createRef(params)
        : ([] as unknown as ReturnType<CreateState<Params, Ref>>);
    return handler({ config, state, setState, ref, setRef })(params);
  };

커스텀 훅의 매개변수(params)를 입력받을 수 있고 useRefState의 리턴값을 리턴하는 createRef를 추가하고, createStateparams를 입력받을 수 있도록 변경되었습니다. 모든 매개변수는 사용되지 않을 가능성이 있는데, 그러한 경우 undefined를 입력하는 대신, 아무것도 입력하지 않도록 하기 위해, 객체 형태로 입력을 할 수 있도록 변경했습니다.

확장된 createHook은 다음과 같이 사용될 수 있습니다.

const formHandler =
  ({
    config,
    state,
    setState,
  }: {
    config: { submitter: typeof submitValue };
    state: State;
    setState: SetState<State>;
  }) =>
  () => { ... };

const useForm = createHook(formHandler)({
  config: { submitter: submitValue },
  createState: () => useState(initState),
});

formHandler가 객체의 형태로 config, state, setState를 입력 받고, (params: Params) => Reseult 형태로 리턴하며, createHook도 객체의 형태로 configcreateState를 입력 받는다는 것을 제외하면, 기존과 큰 차이는 없습니다. 사용하지 않는 useRefStateparams와 관련된 변수는 사용되지 않았기 때문입니다.

createHook이 수정되었기 때문에, 그에 맞춰 hookTest도 수정되어야 합니다.

import { describe, test, Mock, vi } from "vitest";

type Mocks = Record<string, Mock>;

type TestCase<Params, Config, State, Ref, Result> = {
  name: string;
  params?: Partial<Params>;
  config?: Partial<Config>;
  prevState?: Partial<State>;
  prevRef?: Partial<Ref>;
  mocks?: Mocks;
  event?: (p: { result: Result; mocks: Mocks }) => void;
  doExpect: (p: {
    getResult: () => Result;
    params: Params;
    mocks: Mocks;
  }) => Promise<void> | void;
};

const mergeObj =
  <O,>(obj: O) =>
  (partialObj?: Partial<O>): O => ({ ...obj, ...partialObj });

const hookTest =
  <Params = void, Config = void, State = void, Ref = void, Result = void>(
    handler: Handler<Params, Config, State, Ref, Result>
  ) =>
  (
    p: {
      name: string;
      testCases: Array<TestCase<Params, Config, State, Ref, Result>>;
    } & (Params extends void ? {} : { defaultParams: Params }) &
      (Config extends void ? {} : { defaultConfig: Config }) &
      (State extends void ? {} : { defaultState: State }) &
      (Ref extends void ? {} : { defaultRef: Ref })
  ) => {
    const defaultParams =
      "defaultParams" in p ? p.defaultParams : (undefined as Params);
    const defaultConfig =
      "defaultConfig" in p ? p.defaultConfig : (undefined as Config);
    const defaultState =
      "defaultState" in p ? p.defaultState : (undefined as State);
    const defaultRef = "defaultRef" in p ? p.defaultRef : (undefined as Ref);

    const { name, testCases } = p;
    const createParams = mergeObj(defaultParams);
    const createConfig = mergeObj(defaultConfig);
    const createState = mergeObj(defaultState);
    const createRef = mergeObj(defaultRef);

    describe(name, () => {
      test.each(testCases)(
        "$name",
        async ({
          params,
          config,
          prevState,
          prevRef,
          mocks = { _: vi.fn() },
          event,
          doExpect,
        }) => {
          const state = { current: createState(prevState) };
          const ref = { current: createRef(prevRef) };
          const setState: SetState<State> = (set) => {
            state.current = set(state.current);
          };
          const setRef: SetRef<Ref> = (set) => {
            ref.current = set(ref.current);
          };
          const getResult = (state: State, ref: Ref) =>
            handler({
              config: createConfig(config),
              state,
              setState,
              ref,
              setRef,
            })(createParams(params));
          event?.({ result: getResult(state.current, ref.current), mocks });
          await doExpect({
            getResult: () => getResult(state.current, ref.current),
            params: createParams(params),
            mocks,
          });
        }
      );
    });
  };

수정된 TestCase을 보면 알 수 있듯이, 기존의 hookTestparamsprevRef가 추가되고, doExpect에서도 params에 접근할 수 있습니다. 그런데 mocks는 뭘까요? mocks에서는 event 내에서 호출되고 doExpect 내에서 확인해야 하는 모든 mock 함수를 정의할 수 있습니다. 예를 들어, 다음과 같이 사용될 수 있습니다.

{
  name: "submit의 onSuccess 호출되는지 확인",
  mocks: { onSuccess: vi.fn() }
  event: ({ result, mocks }) => {
    result.submit({ onSuccess: mocks.onSuccess });
  },
  doExpect: async ({ getResult, mocks }) => {
    expect(mocks.onSuccess).toHaveBeenCalled()
  },
}