react-navigation / rfcs

RFCs for changes to React Navigation
Other
88 stars 16 forks source link

<StaticNavigator> for tests/storybook #75

Open slorber opened 5 years ago

slorber commented 5 years ago

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

OzzieOrca commented 5 years ago

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 commented 5 years ago

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

OzzieOrca commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

great to see it works for you ;)

shamilovtim commented 3 years ago

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 commented 2 years ago

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.