Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

<StaticNavigator> for tests/storybook #75

Open
slorber opened this issue Jan 25, 2019 · 14 comments
Open

<StaticNavigator> for tests/storybook #75

slorber opened this issue Jan 25, 2019 · 14 comments

Comments

@slorber
Copy link
Member

slorber commented Jan 25, 2019

Hi,

I have screens on which some components are "connected" to react-navigation through withNavigation() hoc (a screen navbar header actually)

I want to be able to render these components in envs like tests or RN storybook, but by default it fails because no navigation is found in React context.

I don't really care if when pressing the backbutton it does not really do anything (ie the navigation action is ignored because unknown), as long as it renders.

The current workaround does not look very idiomatic, and I think having an official support for this feature could be helpful

Here's my solution for Storybook:

const reactNavigationDecorator: StoryDecorator = story => {
  const Screen = () => story();
  const Navigator = createAppContainer(createSwitchNavigator({ Screen }))
  return <Navigator />
}

storiesOf('ProfileContext/Referal', module)
  .addDecorator(reactNavigationDecorator)
  .add('Referal', () => <ReferalDumb />)

I'd find this more idiomatic to do:

storiesOf('ProfileContext/Referal', module)
  .addDecorator(reactNavigationDecorator)
  .add('Referal', () => (
    <StaticNavigator>
      <ReferalDumb />
    </StaticNavigator>
  ))

This would be quite similar to the of react-router

@slorber slorber changed the title <StaticNavigator> for tests/storybook <StaticNavigator> for tests/storybook Jan 25, 2019
@OzzieOrca
Copy link

How would you provide navigation state params to both the createAppContainer(createSwitchNavigator({ Screen })) version (so I can use it now) and the <StaticNavigator> proposed version?

@satya164
Copy link
Member

If you don't care about the HOC, wouldn't it be better to mock it?

@OzzieOrca
Copy link

I got this component working:

const TestNavigator = ({
  children,
  params,
}: {
  children: NavigationComponent;
  params?: NavigationParams;
}) => {
  const Navigator = createAppContainer(
    createSwitchNavigator({
      TestScreen: { screen: () => Children.only(children), params },
    }),
  );
  return <Navigator />;
};

Then you can use it in react-native-testing-library's render function:

render(
  <TestNavigator params={{ fakeParam: true }}>
    <SomeComponent />
  </TestNavigator>,
}

My motivation was to get the useNavigationParam hook working which is why just passing a navigation prop didn't work for testing.

Here's a full example of the testing helpers I wrote to wrap the tested component in a navigator and a redux provider: (used this for inspiration for the redux stuff)

import React, { ReactElement, Children } from 'react';
import 'react-native';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import { render } from 'react-native-testing-library';
import configureStore, { MockStore } from 'redux-mock-store';
import {
  createAppContainer,
  createSwitchNavigator,
  NavigationComponent,
  NavigationParams,
} from 'react-navigation';

export const createThunkStore = configureStore([thunk]);

const TestNavigator = ({
  children,
  params,
}: {
  children: NavigationComponent;
  params?: NavigationParams;
}) => {
  const Navigator = createAppContainer(
    createSwitchNavigator({
      TestScreen: { screen: () => Children.only(children), params },
    }),
  );
  return <Navigator />;
};

interface RenderWithContextParams {
  initialState?: {} | undefined;
  store?: MockStore;
  navParams?: NavigationParams;
}

export function renderWithContext(
  component: ReactElement,
  {
    initialState,
    store = createThunkStore(initialState),
    navParams,
  }: RenderWithContextParams = {},
) {
  return {
    ...render(
      <TestNavigator params={navParams}>
        <Provider store={store}>{component}</Provider>
      </TestNavigator>,
    ),
    store,
  };
}

export function snapshotWithContext(
  component: ReactElement,
  renderWithContextParams?: RenderWithContextParams,
) {
  const { toJSON } = renderWithContext(component, renderWithContextParams);
  expect(toJSON()).toMatchSnapshot();
}

Not sure how I feel about the renderWithContext name but I was trying to communicate that it wraps the component in Providers or provides context.

@slorber
Copy link
Member Author

slorber commented Apr 29, 2019

Great job, that's what I'd like to be implemented, + some other details like providing navigationOptions and other things.

@satya164 mocking certainly has some advantages like ability to test the RN integration and see what navigation methods are called etc.

But for usecases like adding a whole screen to storybook, where you might have several little compos (already tested independently with a mock) coupled to RN, it can be annoying to mock the navigation again. When you start to use things like useNavigationEvents and other hooks, you need to add more and more implementation to your mock. Having a comp is IMHO a simpler path already adopted by libraries like ReactRouter

@OzzieOrca
Copy link

Doing this throws a bunch of duplicate navigator warnings...

Added this as a Jest setup file:

const originalWarn = console.warn;

beforeAll(() => {
  console.warn = (...args: any[]) => {
    if (
      /You should only render one navigator explicitly in your app, and other navigators should be rendered by including them in that navigator/.test(
        args[0],
      )
    ) {
      return;
    }
    originalWarn.call(console, ...args);
  };
});

afterAll(() => {
  console.warn = originalWarn;
});

@OzzieOrca
Copy link

OzzieOrca commented May 21, 2019

I discovered my TestNavigator doesn't work for rerender/update (I'm using react-native-testing-library). Since a new navigation instance is created with every call to createAppContainer, the whole tree rerenders, not just the component under test.

Here's a new version. It feels hacky. My new function renders a navigator and returns the createAppContainer's navigation prop. Then you can use that and the NavigationProvider for rendering. I'd love feedback or thoughts about adding some sort of test navigator to the library.

navigationHelpers.tsx:

import React from 'react';
import {
  createAppContainer,
  createSwitchNavigator,
  NavigationParams,
  NavigationScreenProp,
} from 'react-navigation';
import { render } from 'react-native-testing-library';

export const createNavigationProp = (params?: NavigationParams) => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let navigationProp: NavigationScreenProp<any> | undefined;

  const Navigator = createAppContainer(
    createSwitchNavigator({
      TestScreen: {
        screen: ({
          navigation,
        }: {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          navigation: NavigationScreenProp<any>;
        }) => {
          navigationProp = navigation;
          return null;
        },
        params,
      },
    }),
  );

  render(<Navigator />);

  if (navigationProp === undefined) {
    throw 'Unable to get navigation screen prop';
  }

  return navigationProp;
};

Your test:

import React from 'react';
import { Text } from 'react-native';
import { render } from 'react-native-testing-library';
import { NavigationParams } from 'react-navigation';
import { NavigationProvider } from '@react-navigation/core';
import { useNavigationParam } from 'react-navigation-hooks';

import { createNavigationProp } from './navigationHelpers';

const ComponentUnderTest = () => {
  const testParam = useNavigationParam('testParam');

  return <Text>{testParam}</Text>;
};

it('should render correctly', () => {
  const navParams: NavigationParams = { testParam: 'Test' };
  const navigation = createNavigationProp(navParams);

  const { toJSON } = render(
    <NavigationProvider value={navigation}>
      <ComponentUnderTest />
    </NavigationProvider>,
  );

  expect(toJSON()).toMatchInlineSnapshot(`
    <Text>
      Test
    </Text>
  `);
});

@satya164
Copy link
Member

There's another issue I just thought of with something like StaticNavigator. There are several navigators such as stack, tab, drawer etc. Each provide additional helpers (such as push for stack, jumpTo for tabs etc). A static navigator won't be able to provide correct helpers depending on the screen, so will break.

What might be more useful is to provide a context provider (e.g. NavigationMockProvider to provide mock implementations for the navigation prop (and the route prop for v5). We could provide default mocks for the core helpers so you don't need to write mocks for everything manually to make this easier (or even accept the router as a prop which auto-generates mocks for navigator specific helpers)

I think this way, we can support all different types navigators and help with both storybook and test scenarios.

@slorber
Copy link
Member Author

slorber commented Oct 17, 2019

That makes sense, didn't think about the navigation helpers. What about deriving NavigationMockProvider.Stack for example, if we want to provide mocks for popToTop automatically etc? (and let user override what he wants with jest.fn() if needed)

@andreialecu
Copy link

I haven't been able to find any documentation on recommended practices for using react-navigation v5 with storybook.

Are the workarounds posted here still valid for the new version?

@slorber
Copy link
Member Author

slorber commented Apr 10, 2020

@andreialecu , it should work the same way. I haven't tested but you can try:

const reactNavigationDecorator = story => {
  const Screen = () => story();
  return (
    <NavigationContainer>
      <Stack.Navigator>
          <Stack.Screen name="MyStorybookScreen" component={Screen} />
      </Stack.Navigator>
    <NavigationContainer>
  )
}

@andreialecu
Copy link

Thanks @slorber, I ended up with the following:

const Stack = createStackNavigator();

const reactNavigationDecorator = story => {
  const Screen = () => story();
  return (
    <NavigationContainer independent={true}>
      <Stack.Navigator>
          <Stack.Screen name="MyStorybookScreen" component={Screen} options={{header: () => null}} />
      </Stack.Navigator>
    </NavigationContainer>
  )
}

addDecorator(reactNavigationDecorator);

I needed independent={true} because storybook apparently has a bug with the new Fast Refresh in RN 0.61+ and somehow the decorator keeps being re-added on code changes, and the following happens on each code change:

image

Not sure if react-navigation can do anything about this or it's just a StoryBook issue.

I was able to work around it by disabling the screen header via options={{header: () => null}}. Didn't see any issues otherwise.

The story itself would look like this:

storiesOf("Forms", module).add("Confirm Email", () => {
  const navigation = useNavigation<
    StackNavigationProp<AppStackParamList, "RegConfirm">
  >();
  const route = useRoute<RouteProp<AppStackParamList, "RegConfirm">>();
  route.params = {
    values: {
      email: "test@test.com",
      username: "tester",
      password: "justtesting"
    }
  };

  return (
    <ConfirmForm
      navigation={navigation}
      route={route}
      onSubmit={(values) => {
        Alert.alert("", JSON.stringify(values, null, 2));
      }}
    />
  );
});

@slorber
Copy link
Member Author

slorber commented Apr 15, 2020

great to see it works for you ;)

@shamilovtim
Copy link

shamilovtim commented Apr 13, 2021

Does anyone have any experience with a completely white screen in storybook after adding a Navigator decorator? I am using Storybook 6.2.x and RN5.

Basically the way I resolved this is by using webpack to mock useNavigation and useRoute rather than trying to mock all of the providers and scaffolding of react navigation.

Really simple stuff:

/.storybook/mocks/navigation.js:

export const useRoute = () => {
  return {
    name: 'fun route',
    params: {
      nothing: 'nice ocean view'
    }
  };
};

export const useNavigation = () => {
  return {
    push: () => null,
    goBack: () => null,
    pop: () => null,
    popToTop: () => null,
    reset: () => null,
    replace: () => null,
    navigate: () => null,
    setParams: () => null,
    jumpTo: () => null
  };
};

@kenchoong
Copy link

kenchoong commented Sep 18, 2021

Hey sir,

Does anyone have any experience with a completely white screen in storybook after adding a Navigator decorator? I am using Storybook 6.2.x and RN5.

Basically the way I resolved this is by using webpack to mock useNavigation and useRoute rather than trying to mock all of the providers and scaffolding of react navigation.

Really simple stuff:

/.storybook/mocks/navigation.js:

export const useRoute = () => {
  return {
    name: 'fun route',
    params: {
      nothing: 'nice ocean view'
    }
  };
};

export const useNavigation = () => {
  return {
    push: () => null,
    goBack: () => null,
    pop: () => null,
    popToTop: () => null,
    reset: () => null,
    replace: () => null,
    navigate: () => null,
    setParams: () => null,
    jumpTo: () => null
  };
};

Regarding this,

Basically the way I resolved this is by using webpack to mock useNavigation and useRoute

How do you this file ya? Can you share your webpack file as well?

I have tried in my /.storybook/main.js I do like this:

module.exports = {
    // your Storybook configuration
  
    webpackFinal: (config) => {
        config.resolve.alias['@react-navigation/native'] = require.resolve('../__mocks__/navigation.js');
        return config;
    },
  };

But it still didnt use the function in mock folder. And I will get this error:

Warning: Cannot update a component from inside the function body of a different component.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants