erikras / react-redux-universal-hot-example

A starter boilerplate for a universal webapp using express, react, redux, webpack, and react-transform
MIT License
11.99k stars 2.5k forks source link

Example to test route with fetchData and/or fetchDataDeffered and @connect? #720

Closed mmahalwy closed 8 years ago

mmahalwy commented 8 years ago

I have been having some trouble thinking about the best way to test a container that has both fetchData, fetchDataDeferred and some computation on the mergeState for @connect. Would love to get some thoughts on this.

Initially, I thought it'd really mimic the true functionality of the container is to calling fetchData and test the component there, then test what happens when fetchDataDeferred is called and what happens there, and so on.

You'll realize I made a big mistake in my test and that is calling fetchData before rendering the component and testing there... then when calling the deferred already my component is rendered and will not compute further changes.

My goal is either:

  1. test fetchData, fetchDataDeferred on their own, then test component mimicking that either of those have resolved and what the data in the store would look like
  2. test the component as a whole (so when rendering, it'd do the @connectData calls, need some helper to do that for me, then go ahead and test the container).

Thoughts?

Here is my test:

import React from 'react';
import ReactDOM from 'react-dom';
import { renderIntoDocument } from 'react-addons-test-utils';
import { Provider } from 'react-redux';
import {reduxReactRouter} from 'redux-router';
import createHistory from 'history/lib/createMemoryHistory';

import createStore from 'redux/create';
import  * as regionsRedux from 'redux/modules/regions';
import { isNotLoadedIds, load as loadTagPages } from 'redux/modules/tag_pages';
import { setRegion, setTagPage } from 'redux/modules/navbar';

import Region from './index';
import ApiClient from 'helpers/ApiClient';
import { regions, regionImages, activities, tickets, images, tagPages, tags } from '../../../tests/stubs';
const client = new ApiClient();

let mockStore;
let component;
let dom;
let store;
let dispatch;

describe.only('container:Region', () => {
  describe('when region is not in store', () => {
    beforeEach(() => {
      mockStore = {
        activities: {
          errored: false,
          loaded: false,
          data: {}
        },
        tickets: {
          errored: false,
          loaded: false,
          data: {}
        },
        images: {
          errored: false,
          loaded: false,
          data: {}
        },
        regions: {
          errored: false,
          loaded: false,
          data: {}
        },
        regionImages: {
          errored: false,
          loaded: false,
          data: {}
        },
        tagPages: {
          errored: false,
          loaded: false,
          data: {}
        },
        tags: {
          errored: false,
          loaded: false,
          data: {}
        },
        router: {
          params: {
            regionId: '566b236d4d6f681409000000'
          },
          location: {
            pathname:"/regions/566b236d4d6f681409000000",
            search:"",
            hash:"",
            state:null,
            action:"POP",
            key:"31zavf"
          }
        }
      };

      dispatch = sinon.stub();
      dispatch.onCall(1).returns({
        then(cb) {
          mockStore.regions.data = regions;
          mockStore.regionImages.data = regionImages;

          cb();
        }
      });

      Region.fetchData(() => mockStore, dispatch, {}, {regionId: '566b236d4d6f681409000000'});

      store = createStore(reduxReactRouter, null, createHistory, client, mockStore);
      component = renderIntoDocument(
        <Provider store={store} key="provider">
          <Region />
        </Provider>
      );

      dom = ReactDOM.findDOMNode(component);
    });

    afterEach(() => {
      dispatch = null;
    });

    it('should load region', () => {
      expect(dispatch).to.have.been.calledThrice;
      expect(mockStore.regions.data[mockStore.router.params.regionId]).to.exist;
    });

    it('should have header image', () => {
      const regionImageUrl = regionImages[regions['566b236d4d6f681409000000'].regionImageIds[0]].filepickerUrl;

      expect(dom.querySelector('[class^="header"]').style.backgroundImage).to.eql(`url("${regionImageUrl}")`);
    });

    it('should not have loaded tag pages yet', () => {
      expect(dom.querySelector('[class^="globe"]')).to.exist;
    });

    it('should not show carousel', () => {
      expect(dom.querySelector('.slick-slider')).to.not.exist;
    });

    describe('when tag pages are partially or not in the store', () => {
      beforeEach(() => {
        mockStore.tagPages.data = tagPages;
        mockStore.activities.data = activities;
        mockStore.images.data = images;
        mockStore.tags.data = tags;

        Region.fetchDataDeferred(() => mockStore, dispatch, {}, {regionId: '566b236d4d6f681409000000'});
      });

      it('should dispatch for tag pages', () => {
        expect(dispatch).to.have.been.called;
        expect(dispatch.callCount).to.eql(4);
      });

      it('should not have loaded tag pages yet', () => {
        expect(dom.querySelector('[class^="globe"]')).to.exist;
      });
    });

    describe('when tag pages are already in store', () => {
      beforeEach(() => {
        mockStore.tagPages.data = tagPages;
        mockStore.activities.data = activities;
        mockStore.images.data = images;
        mockStore.tags.data = tags;
        mockStore.regions.data['566b236d4d6f681409000000'].tagPageIds = Object.keys(tagPages);
        store.dispatch({type: 'type'});
        Region.fetchDataDeferred(() => mockStore, dispatch, {}, {regionId: '566b236d4d6f681409000000'});
      });

      it('should not dispatch for tag pages', () => {
        expect(dispatch).to.have.been.called;
        expect(dispatch.callCount).to.eql(3);
      });

      // it('should not have loaded tag pages yet', () => {
        // expect(dom.querySelector('[class^="globe"]')).to.not.exist;
      // });
    });
  });
});

And here is my container:

import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import { Grid, Row, Col } from 'react-bootstrap';

import { isLoaded, load as loadRegion } from 'redux/modules/regions';
import { isNotLoadedIds, load as loadTagPages } from 'redux/modules/tag_pages';
import { setRegion, setTagPage } from 'redux/modules/navbar';

import connectData from 'helpers/connectData';
import debug from 'helpers/debug';

import TagPageLink from 'components/TagPageLink';
import CoreLoader from 'components/CoreLoader';
import Carousel from './Carousel';

const style = require('./style.scss');

function fetchData(getState, dispatch, location, params) {
  dispatch(setTagPage(null));

  if (!isLoaded(getState(), params.regionId)) {
    debug('Component:Region:fetchData', 'Region not loaded');

    return dispatch(loadRegion(params.regionId)).then(() => {
      debug('Component:Region:fetchData', 'Region fetched');
      dispatch(setRegion(params.regionId));
    });
  }

  debug('Component:Region:fetchData', 'Region loaded');
}

function fetchDataDeffered(getState, dispatch, location, params) {
  // Sometimes when going from TP -> Region, we don't have all the TPs and need to fetch
  const notLoadedIds = isNotLoadedIds(getState(), getState().regions.data[params.regionId].tagPageIds);

  if (notLoadedIds.length) {
    const tagPageIds = notLoadedIds.join(',');

    debug('Component:Region:fetchDataDeffered', 'Loading tagPages');

    return dispatch(loadTagPages(tagPageIds));
  }
}

@connectData(fetchData, fetchDataDeffered)
@connect(
  state => {
    const region = state.regions.data[state.router.params.regionId];
    const tagPages = region.tagPageIds.map(tagPageId => state.tagPages.data[tagPageId]).filter(tp => !!tp);
    const regionImages = region.regionImageIds.map(imageId => state.regionImages.data[imageId]);
    const images = state.images.data;
    const activities = state.activities.data;
    const tags = state.tags.data;
    console.log(tagPages.length);
    return {
      region,
      regionImages,
      tagPages,
      images,
      activities,
      tags
    };
  },
  null
)
export default class Region extends Component {
  static propTypes = {
    tagPages: PropTypes.array,
    regionImages: PropTypes.array,
    region: PropTypes.object,
    images: PropTypes.object,
    activities: PropTypes.object,
    tags: PropTypes.object
  };

  static contextTypes = {
    store: PropTypes.object.isRequired
  };

  componentWillUnmount() {
    debug('Component:Region', 'componentWillUnmount');
  }

  renderHeader() {
    debug('Component:Region', 'renderHeader');
    const { region, regionImages } = this.props;

    return (
      <Row className={`${style.header} text-center margin-lg-bottom`} style={{backgroundImage: `url(${regionImages[0].filepickerUrl})`}}>
        <Col className="padding-sm-v" xs={12}>
          <h1>
            Book Amazing Activities in
            <br/>
            {region.name}
          </h1>
        </Col>
      </Row>
    );
  }

  renderDiscoverMore() {
    const { tagPages } = this.props;

    if (tagPages.length < 8) {
      return false;
    }

    const list = tagPages.slice(8, tagPages.length).map(tagPage => {
      return (
        <Col xs={6} key={tagPage.id}>
          <Link to={`/tag_pages/${tagPage.id}`}>
            {tagPage.name}
          </Link>
        </Col>
      );
    });

    return (
      <Col md={4} xs={6}>
        <h4>
          Discove more..
        </h4>
        <Row>
          {list}
        </Row>
      </Col>
    );
  }

  renderTagPages() {
    debug('Component:Region', 'renderTagPages');
    const { region, tagPages, images, activities } = this.props;

    if (tagPages.length !== region.tagPageIds.length) {
      return <CoreLoader globe />;
    }

    return tagPages.slice(0, 8).map(tagPage => {
      const imageIds = tagPage.activityIds.slice(0, 3).reduce((ids, id) => {
        return ids.concat(activities[id].imageIds);
      }, []);

      const tagPageImages = imageIds.map(id => images[id]);

      return (
        <Col md={4} xs={6} key={tagPage.id}>
          <TagPageLink tagPage={tagPage} images={tagPageImages} />
        </Col>
      );
    });
  }

  renderCarousel() {
    const { activities, tags, images, tagPages, region } = this.props;

    if (tagPages.length === region.tagPageIds.length) {
      return (
        <Carousel activities={activities} tags={tags} images={images} />
      );
    }
  }

  render() {
    const { region } = this.props;

    if (!region) {
      return <Grid fluid />;
    }

    debug('Component:Region', 'render');

    return (
      <Grid fluid>
        {this.renderHeader()}
        <Row>
          {this.renderCarousel()}
        </Row>
        <Row>
          <Col xs={12}>
            <h3>
              Popular in {region.name}
            </h3>
            <Row>
              {this.renderTagPages()}
              {this.renderDiscoverMore()}
            </Row>
          </Col>
        </Row>
      </Grid>
    );
  }
}
Ruudieboy commented 8 years ago

I would also like to see an example.

michalmikolajczyk commented 8 years ago

+1

quicksnap commented 8 years ago

We're in the process of migrating to react-router-redux (aka redux-simple-router) on the simple-router branch. It will eventually merge into master. In that branch, the fetchData functions are gone, replaced by a similar singular static method used by the redux-async-connect library.

I recommend testing it out and opening new issues with any problems.