Storybook에서 nextjs 에러가 발생할 때
Tags: react, nextjs, storybook, testing
Published: 2020-12-13 15:00:11

스토리북을 쓰면서 nextjs mock module을 간단하게 구현했던 부분을 공유하려고 합니다. 이번 글은 nextjs에 관련된 내용이지만, 조금만 응용하면 storybook에서 발생하는 대부분의 외부 모듈 의존성 에러를 처리할 수 있지 않을까 생각합니다.


## 문제 발생 ##
Storybook으로 컴포넌트의 UI를 테스트하다 보면, 외부모듈이 적절히 주입되지 않아서 에러가 발생할 때가 있습니다. 예를 들어 `next/router`, `next/link`, `next/image`를 사용하면 storybook에서는 다음과 같은 에러가 발생합니다.

```typescript
// next/router: Uncaught TypeError: Cannot read property 'pathname' of null
const isRoot = router.pathname === '/';

// next/link: Uncaught TypeError: Cannot read property 'prefetch' of null
<Link href="/signup">Sign Up</Link> 

// next/image: http://localhost:6006/_next/image?url={src}&w=640&q=75 (404 Not Found)
<Image src={src} alt="logo" width={250} height={50} /> 
```


## 문제의 원인 ##
`nextjs`는 redux, i18n, react-router처럼 개발자가 `<Provider />` 를 명시적으로 주입하지 않기 때문에, 적절한 `mock module`을 만들어줘야 테스트 러너가 코드를 실행할 수 있습니다.

`nextjs`는 zero-configuration framework를 목표로 하다보니, 렌더링 하는 부분에서 nextjs Provider 주입을 자동으로 처리해주기 때문입니다.

```typescript
// next/next-server/server/render.tsx

const AppContainer = ({ children }: any) => (
  <RouterContext.Provider value={router}>
    <AmpStateContext.Provider value={ampState}>
      <HeadManagerContext.Provider value={headValue}>
        <LoadableContext.Provider value={(moduleName) => reactLoadableModules.push(moduleName)}>
          {children}
        </LoadableContext.Provider>
      </HeadManagerContext.Provider>
    </AmpStateContext.Provider>
  </RouterContext.Provider>
)
```

반면 `storybook`에서는 (당연히) 그런 nextjs의 Provider를 처리해주지 않죠.

```typescript
// storybook/app/react/src/client/preview/render.tsx

const render = (node: ReactElement, el: Element) =>
  new Promise((resolve) => {
    ReactDOM.render(node, el, resolve);
  });
```


## 해결방법 ##
Storybook의 [decorator를 활용해 Provider를 주입](https://github.com/vercel/next.js/issues/15543#issuecomment-664955766) 해주는 방식도 있지만, 이번에는 조금 더 간단하게 [webpack의 module.alias api](https://webpack.js.org/configuration/resolve/#resolvealias) 를 이용해 mock module을 만들어 해결해보겠습니다.

```javascript
// __mocks__/next/router.js
export const useRouter = () => ({
  route: '/',
  pathname: '',
  query: '',
  asPath: '',
  prefetch: () => {},
  push: () => {},
});

export default { useRouter };

// __mocks__/next/link.js
import React from 'react';

export default function ({ children }) {
  return <a>{children}</a>;
}

// __mocks__/next/image.js
import React from 'react';

export default function (props) {
  return <img {...props} />
}

```

실제로 nextjs의 모듈 코드가 리턴하는 값과 유사한 type을 리턴해주시면 됩니다. 
폴더와 파일이름은 편한대로 설정해도 상관 없습니다. 저는 테스트에 필요한 mock code를 모아놓는 `__mocks__` 폴더에 next 모듈과 동일한 방식을 차용했습니다.

각각의 mock module을 만들어준 뒤에 storybook webpack 설정에 적용해주시면 됩니다. 컴포넌트를 렌더링 할 때, import 하는 모듈의 경로를 위에서 만든 mock module로 바꿔줍니다.

```javascript
// .storybook/main.js
module.exports = {
  // ...your config
  webpackFinal: (config) => {
    config.resolve.alias['next/router'] = require.resolve('../__mocks__/next/router.js');
    config.resolve.alias['next/link'] = require.resolve('../__mocks__/next/link.js');
    config.resolve.alias['next/image'] = require.resolve('../__mocks__/next/image.js');
    return config;
  },
};

```

이제 storybook을 다시 시작하면 위에서 발생했던 에러는 다시 나타나지 않습니다. 해당 모듈이 제공하는 기능은 동작하지 않겠지만, storybook의 UI 테스팅에는 필요한 기능이 아니기 때문에 테스트에는 영향이 없을 것 입니다.
필요하다면 로깅같은 동작을 덧붙이거나, 다른 모듈 에러가 발생했을 때도 응용할 수 있다고 생각합니다.

출처
https://stackoverflow.com/questions/63536822/how-to-mock-modules-in-storybooks-stories
https://github.com/vercel/next.js
https://github.com/storybookjs/storybook