문제 상황...
초기 값을 localStorage에서 불러오는 zustand store의 테스트 코드를 짜야 할 일이 생겼습니다. 코드 일부는 이렇게 생겼어요.
1const useAuthStore = create<IAuthStore>((set) => {2const authDataJSON = LocalStorage.getItem("authData");3const authData = authDataJSON4? JSON.parse(authDataJSON)5: { accessToken: null };67return {8isLogin: !!authData.accessToken,9accessToken: authData.accessToken,10};11});
초깃값이 localStorage 값에 맞게 잘 설정되는지를 확인하기 위해서 localStorage.getItem
메서드를 상황에 맞게 모킹해줬는데요. 실제로 테스트를 들려보면 모킹한 내용이 전혀 반영되지 않는 문제가 있었습니다.
1test("로컬스토리지에 값이 없는 경우의 초깃값", () => {2// 기존에 저장된 로그인 상태가 없는 경우3(LocalStorage.getItem as jest.Mock).mockReturnValue(null);4// 테스트 로직...5});67test("로컬스토리지에 값이 있는 경우의 초깃값", () => {8// 기존에 저장된 로그인 상태가 있는 경우9(LocalStorage.getItem as jest.Mock).mockReturnValue(10JSON.stringify({ accessToken: MOCK_ACCESS_TOKEN })11);12// 테스트 로직...13});
문제의 원인
한참 헤맸는데 문제의 원인은 zustand의 create
함수가 실행되는 시점이었습니다. create
함수는 store를 생성하는 시점에 실행되는데, 테스트 코드의 경우에는 module을 import하는 시점이 됩니다. 즉, create
함수가 실행되는 시점에는 아직 localStorage.getItem
메서드가 모킹되지 않았던 것입니다. 그래서 아무리 모킹을 해도 그 결과가 store에 반영되지 않았던 것이죠.
문제 해결을 위해 시도한 것 (하지만 결과는 실패인...)
이 문제를 해결하기 위해서는 module을 import하는 시점이 아닌 제가 원하는 시점, 즉 localStorage.getItem 메서드를 모킹한 뒤에 직접 store를 생성해주는 것이 필요했습니다. jest도, zustand도 등장한 지 꽤 된 라이브러리이기 때문에 방법을 쉽게 찾을 수 있을 것이라 생각했는데... 생각보다 방법을 찾기가 어렵더라고요. 제가 시도했지만 실패한 방법들입니다...
1) Zustand 공식의 테스트 가이드북을
store는 singleton이기 때문에 테스트코드에서도 하나의 store를 공유하게 됩니다. 그래서 zustand에서는 초기 store 상태를 저장해두고 매 테스트 케이스마다 초기 상태로 되돌려주는 코드를 제공합니다.
https://zustand.docs.pmnd.rs/guides/testing#jest
그런데 제 경우에는 초깃값이 테스트 케이스마다 다른 경우를 확인하고 싶었던 것이기 때문에 초깃값을 저장하는 이 방법은 적합하지 않았습니다.
2) jest.resetModules와 jest.isolateModules
jest에서는 jest.resetModules
와 jest.isolateModules
를 제공하여 모듈 캐시를 초기화하거나 격리된 환경에서 모듈을 실행할 수 있습니다.
- https://jestjs.io/docs/jest-object#jestresetmodules
- https://jestjs.io/docs/jest-object#jestisolatemodulesfn
import할 때 create 함수가 실행되는 것이 문제였기 때문에 이 두 메서드를 이용해서 새로 모듈을 불러오면 문제가 해결될 것이라고 생각했는데... 테스트하는 것이 훅이기 때문일까요? 에러가 발생하는 바람에 결국 사용하지 못했습니다. 🥲
1Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.
이유는 아직 잘 모르겠습니다. 🥺
해결 아이디어 - 팩토리 함수
의외로 좀 방법이 간단했는데, 팩토리 함수를 만들어서 store를 생성하는 방법이었습니다. create 함수가 실행되는 로직을 팩토리 함수로 감싸서 직접 원하는 시점에 store를 생성해주고, 필요한 의존성들도 이 때 함께 주입해주는 방법입니다.
해결 과정 1. 팩토리 함수 만들기
createAuthStore
함수는 create를 호출하는 기능을 합니다. 이것이 간단하지만 핵심...!
1export interface IDependencies {2localStorage: typeof LocalStorage;3}45export const createAuthStore = (deps: IDependencies) => {6const { localStorage } = deps;78const authDataJSON = localStorage.getItem("authData");9const authData = authDataJSON10? JSON.parse(authDataJSON)11: { accessToken: null };1213const stateCreator: StateCreator<IAuthStore> = (set) => ({14isLogin: !!authData.accessToken,15accessToken: authData.accessToken,16});1718return create<IAuthStore>(stateCreator);19};
해결 과정 2. 프로덕션에서 사용하던 훅에 팩토리 함수 적용하기
프로덕션 코드에서는 원래 코드처럼 하나의 store를 공유해야 하기 때문에 팩토리 함수를 이용해서 store를 생성하고, 그 결과를 export해서 씁니다.
1const useAuthStore = createAuthStore({ localStorage: LocalStorage });
해결 과정 3. 테스트 코드에 적용하기
테스트코드에서는 매 테스트코드에서 새로운 store를 사용하는 것이 가장 최선의 방법이기 때문에 render 함수 안에서 매번 store를 생성해주는 식으로 했습니다.
1const renderUseAuthStore = () => {2const dependencies: IDependencies = { localStorage: mockLocalStorage };3const useMockAuthStore = createAuthStore(dependencies);45return renderHook(() => useMockAuthStore());6};
해결 완료!!! 👏
이렇게 store를 생성하는 로직을 분리하는 방법으로 store의 초깃값 테스트를 할 수 있었습니다. 뭔가 대단히 복잡한 개념을 사용해서 해결했다기보다는 create 함수의 호출을 외부로 분리하는 간단한 방법을 사용했다는게 나름의 킥인 것 같습니다...🥹 정리하고 보니 생각보다도 너무 간단해서 좀 민망하긴 한데, 너무 방법을 찾는 데 오래 걸렸어서... 저처럼 헤매고 있을 누군가를 위해 남겨 봅니다...!