Storybook을 relay 와 함께 사용하기

Written by Paul
목차
  1. Storybook 을 사용하여 RelayEnvironmentProvider 를 Decorator로 묶어주기
  1. Decorator에서 전역적으로 relay 쿼리에 대한 mock & resolve 하기
  1. 실제 컴포넌트로 렌더링 해보기
  1. Navigation Decorator 등 데코레이터 커스터마이징
 

들어가며

기능에 따른 페이지를 그리는 작업을 진행하면서 워크플로우는 다음과 같습니다.
  1. schema.graphql 을 기반으로 페이지 진입 시 초기에 불러와야 할 query를 로드합니다.
  1. QueryLoader 를 사용하여 로드된 쿼리를, usePreloadedQuery 를 사용하여 실제 graphQL 서버에서 네트워크 (상황에 따라 store에서 가져올 수도 있음) 페칭을 합니다.
  1. 서버에서 필요한 데이터를 받아왔다면, 그 데이터를 바탕으로 UI component를 그리는 작업을 합니다.
 
해당 부분이 아마 대부분의 relay graphQL을 사용하여 기능 단위의 페이지를 그리는 공통적인 workflow 일텐데요.
만약, 서버 작업이 좀 늦어지거나, schema.graphql 에 해당 기능에 대한 공통 스키마가 정의 되어있지 않다면 어떻게 될까요?
무작정 서버 작업을 기다리는 대신, 클라이언트에서 먼저 작업을 할 수 있도록 환경을 구성하는 대체 방법이 있을 거라고 예상됩니다.
rest 방식의 API 라면 msw 를 쓴다거나, 상황에 따른 mockup 데이터를 만들어서 UI 를 그릴 수도 있겠죠.
 
하지만 우리에게는 Storybook이라는 좋은 프레임워크가 존재합니다!
그럼 이 Storybook을 사용하여 어떻게 서버 없이 UI 작업을 미리 할 수 있는지 알아보겠습니다.

1. Storybook 을 사용하여 RelayEnvironmentProvider 를 Decorator로 묶어주기

Storybook은 프레임워크 적인 UI 컴포넌트 렌더러인데요, 해당 기능 중에서는 decorator 라는 기능이 존재합니다.
말 그대로 Storybook 파일에서 렌더링하는 컴포넌트 위에서 자리 잡고 있어서, 직접 그 파일에서 렌더링 하진 않지만 Provider 역할을 할 수 있는 컴포넌트들을 설정 할 수 있는 기능입니다.
더 궁금한 점이 있다면, 공식문서에 나오는 decorator 설명을 참고 해보셔도 좋습니다.
각각의 데코레이터들은 글로벌하게 사용할 수 있도록 설정 할 수도 있고, 각각의 story 파일에서 목적에 따라서 설정 해 줄 수도 있습니다.

글로벌하게 데코레이터를 설정하기

글로벌한 데코레이터는 .storybook/preview.ts 에서 설정 할 수 있습니다.
const preview: Preview = { decorators: [ReactNavigationProviderDecorator, RelayEnvironmentProviderDecorator], }
위와 같은 예시처럼 preview 설정에서 글로벌한 decorator 설정이 가능 합니다.
이렇게 설정된 데코레이터들은 각각의 story 파일에서 설정하지 않더라도, 디폴트로 렌더링 되게 됩니다.

각각의 스토리 파일에서 데코레이터를 설정하기

위의 공식문서 예제를 그대로 가져와 보겠습니다.
import type { Meta, StoryObj } from '@storybook/react'; import { Button } from './Button'; const meta: Meta<typeof Button> = { component: Button, }; export default meta; type Story = StoryObj<typeof Button>; export const Primary: Story = { decorators: [ (Story) => ( <div style={{ margin: '3em' }}> {/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */} <Story /> </div> ), ], };
해당 방식처럼, decorators 라는 값을 배열로 반환할 수 있습니다.
배열에 들어가는 함수의 첫번째 인자는 Story 컴포넌트 이며, JSX 형태로 그려줄 수 있습니다.
 

RelayEnvironment를 제공하는 데코레이터 적용하기

그렇다면, relay graphQL 을 쓰는 환경을 위한 스토리북 데코레이터는 어떻게 적용하면 될까요?
 
import { RelayEnvironmentProvider, createMockEnvironment } from 'react-relay' const RelayEnvironmentProviderDecorator = (story: () => ReactNode): ReactElement => { const StoryComponent = (): ReactNode => story() const [testEnvironment] = useState(() => createMockEnvironment()) useEffect(() => { const operations = testEnvironment.mock.getAllOperations() operations.forEach(operation => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation), ) }) }, [testEnvironment.mock]) return ( <RelayEnvironmentProvider environment={testEnvironment}> <StoryComponent /> </RelayEnvironmentProvider> ) }
위와 같이, story 컴포넌트를 렌더링 하는 함수를 인자로 받습니다.
relay testing 환경을 제공하는 testEnvironment 를 만들어주고, 해당 provider의 prop 으로 전달해줍니다.
하지만 위와같은 형태로 작성하게 되면 global decorator에서는 잘 쓰일 수 있겠지만,
실제 각각의 스토리 파일에서 커스터마이징 하기는 힘듭니다. 첫번째 인자로 () => ReactNode 타입의 함수를 받기 때문이지요.
따라서 위 상황을 대비하기 위하여 다음과 같이 리팩토링을 해줍니다.
export const StorybookRelayEnvironmentProvider = ({ children, mockResolver, }: PropsWithChildren<{ mockResolver?: MockPayloadGenerator.MockResolvers }>): ReactNode => { const [testEnvironment] = useState(() => createMockEnvironment()) useEffect(() => { const operations = testEnvironment.mock.getAllOperations() operations.forEach(operation => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation, mockResolver), ) }) }, [mockResolver, testEnvironment.mock]) return ( <RelayEnvironmentProvider environment={testEnvironment}> {children} </RelayEnvironmentProvider> ) } const RelayEnvironmentProviderDecorator = (story: () => ReactNode): ReactElement => { const StoryComponent = (): ReactNode => story() return ( <StorybookRelayEnvironmentProvider> <StoryComponent /> </StorybookRelayEnvironmentProvider> ) } export default RelayEnvironmentProviderDecorator
위와같이 각각의 기능에 따라서 컴포넌트들을 분할합니다.
  • mockResolver를 채워넣을 수 있는 StorybookRelayEnvironmentProvider
    • 각각의 스토리파일에서 decorators 값에서 커스터마이징하게 사용해줍니다.
  • global decorator 용도로 쓸 수 있는 RelayEnvironmentProviderDecorator
    • .storybook/preview.ts 에 배열의 인자로 주입시켜 줍니다.
 

2. Decorator에서 전역적으로 relay 쿼리에 대한 mock & resolve 하기

자, 이제 relay mockup 환경을 제공하는 데코레이터가 준비가 되었습니다!
각각의 코드를 조금 더 살펴볼까요?
export const StorybookRelayEnvironmentProvider = ({ children, mockResolver, }: PropsWithChildren<{ mockResolver?: MockPayloadGenerator.MockResolvers }>): ReactNode => { const [testEnvironment] = useState(() => createMockEnvironment()) useEffect(() => { const operations = testEnvironment.mock.getAllOperations() operations.forEach(operation => { testEnvironment.mock.resolve( operation, MockPayloadGenerator.generate(operation, mockResolver), ) }) }, [mockResolver, testEnvironment.mock]) return ( <RelayEnvironmentProvider environment={testEnvironment}> {children} </RelayEnvironmentProvider> ) }
StorybookRelayEnvironmentProvider의 내부를 살펴보게 되면, useEffect안에서 특이한 작업을 돌리는 것을 알 수 있습니다.
testEnvironment 에서 mocking 을 하여 실제 서버를 연결하여 불러야 될 query operation들을 모킹해주는 작업입니다.
해당 작업을 거치면 실제 렌더링 하는 컴포넌트에서 queryLoader 와 usePreloadedQuery 를 사용하더라도, 실제 서버로 보내지 않고 필요하다면 mockResolver 를 받아서 미리 모킹된 데이터를 반환해줍니다.
해당 릴레이 테스팅 부분이 더 궁금하시다면 공식문서를 살펴보셔도 좋습니다.

3. 실제 컴포넌트로 렌더링 해보기

실제 컴포넌트의 경우 위의 공식문서에 나온 방식대로 렌더링을 해주면 됩니다.
import type { Meta, StoryObj } from '@storybook/react'; import SomeFeaturePage from './SomeFeaturePage'; const meta: Meta<typeof SomeFeaturePage> = { component: SomeFeaturePage, }; export default meta; type Story = StoryObj<typeof SomeFeaturePage>; export const Primary: Story = {};
해당 SomeFeaturePage 컴포넌트 내부에서는 relay query를 부르고 있다는 가정하에 작성된 코드입니다.
만약 모킹 데이터를 커스터마이징 하기 위한 mockResolver 작업이 필요하다면, 아래와 같이 작성할 수 있습니다.
 

4. Navigation Decorator 등 데코레이터 커스터마이징

storybook에서 react-native 환경이라면 react-navigation 에 대한 모킹 작업이 필요할 것 입니다.
Relay 데코레이터와 비슷한 방식으로 아래와 같이 기능 별로 컴포넌트를 분리 할 수 있습니다.
// https://davidl.fr/blog/react-navigation-object-storybook import {NavigationContainer} from '@react-navigation/native' import {createStackNavigator} from '@react-navigation/stack' import type React from 'react' import {type ReactNode} from 'react' // import { createNativeStackNavigator } from "@react-navigation/native-stack"; /** * Helper component tor create a Dummy Stack to access {navigation} object on *.story.tsx files * * @usage add this decorator * ``` * .addDecorator(NavigationDecorator) * ``` */ const StoryBookStack = createStackNavigator() // or the native one // const StoryBookStack = createNativeStackNavigator(); export const StorybookNavigation = ({ screen, initialParams, }: { screen: React.ComponentType initialParams: Partial<object | undefined> }): ReactNode => { return ( <NavigationContainer independent={true}> <StoryBookStack.Navigator> <StoryBookStack.Screen name="MyStorybookScreen" component={screen} options={{header: () => null}} initialParams={initialParams} /> </StoryBookStack.Navigator> </NavigationContainer> ) } export const NavigationDecorator = (story: () => ReactNode): ReactNode => { const Screen = (): ReactNode => story() return <StorybookNavigation screen={Screen} initialParams={{}} /> }
 
실제로 쓰는 방식은 위 Relay 데코레이터와 비슷한 방식으로 작성해주시면 됩니다.
import type { Meta, StoryObj } from '@storybook/react'; import SomeFeaturePage from './SomeFeaturePage'; const meta: Meta<typeof SomeFeaturePage> = { component: SomeFeaturePage, }; export default meta; type Story = StoryObj<typeof SomeFeaturePage>; export const Primary: Story = { decorators: [ Story => { return ( <StorybookNavigation screen={Story} initialParams={{ chapter: 3, order: 3, }} /> ) }, ] };
글로벌 데코레이터라면 동일하게 .storybook/preview.ts 에 렌더링 순서에 따라 설정해주시면 됩니다.
 
← Go home