Skip to content

Commit

Permalink
Added connect() support
Browse files Browse the repository at this point in the history
  • Loading branch information
Josh Goldberg committed May 25, 2020
1 parent 2d74f2e commit ccd630c
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 19 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@babel/preset-typescript": "^7.9.0",
"@testing-library/react": "^10.0.4",
"@types/jest": "^25.2.3",
"@types/lodash.mapvalues": "^4.6.6",
"@types/redux-mock-store": "^1.0.2",
"@types/testing-library__react": "^10.0.1",
"@typescript-eslint/eslint-plugin": "3.0.0",
Expand Down Expand Up @@ -63,6 +64,7 @@
"version": "0.1.1",
"dependencies": {
"@types/react": "^16.9.35",
"@types/react-redux": "^7.1.9"
"@types/react-redux": "^7.1.9",
"lodash.mapvalues": "^4.6.0"
}
}
42 changes: 42 additions & 0 deletions src/connect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as React from "react";
import type { MapStateToProps, MapDispatchToProps } from "react-redux";
import mapValues from "lodash.mapvalues";

type AnyAction = (payload: any) => void;

const defaultMergeProps = (...sources: unknown[]) => Object.assign({}, ...sources);

export function mockConnect<State>(getDispatch: () => jest.Mock, getState: () => State) {
return function <OwnProps, StateProps, DispatchProps extends Record<string, AnyAction>>(
mapStateToProps?: MapStateToProps<StateProps, OwnProps, State>,
mapDispatchToProps?: MapDispatchToProps<DispatchProps, OwnProps>,
mergeProps = defaultMergeProps,
) {
const createStateProps = (ownProps: OwnProps) => mapStateToProps?.(getState(), ownProps) ?? {};

const createDispatchProps = (ownProps: OwnProps) => {
if (!mapDispatchToProps) {
return {};
}

const dispatch = getDispatch();

return mapDispatchToProps instanceof Function
? mapDispatchToProps(dispatch, ownProps)
: mapValues(
mapDispatchToProps,
(action): AnyAction => (payload) => dispatch(action(payload)),
);
};

return function mockConnectComponent(Component: React.ComponentType<any>) {
return function MockConnectedComponent(ownProps: OwnProps) {
return (
<Component
{...mergeProps(createStateProps(ownProps), createDispatchProps(ownProps), ownProps)}
/>
);
};
};
};
}
40 changes: 28 additions & 12 deletions src/mockRedux.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mockConnect } from "./connect";
import { createGetSelector, GetSelector } from "./selectors";
import { MockRedux, AnySelector } from "./types";

Expand All @@ -9,26 +10,41 @@ type MockSituation = {

let mockSituation: MockSituation | undefined;

const mockNotImplemented = (name: string) => () => {
throw new Error(`${name} is not supported when using mock-redux.`);
};

const mockStateError = (name: string) => {
throw new Error(
`You included mock-redux but didn't call mockRedux() before calling ${name} from react-redux.`,
);
};

jest.mock("react-redux", () => {
const getDispatch = () => mockSituation?.dispatch ?? mockStateError("useDispatch");
const getSelector = (selector: AnySelector) => {
return mockSituation
? mockSituation.getSelector(selector).provide()
: mockStateError("useSelector");
};

return {
connect: mockNotImplemented("connect"),
Provider: mockNotImplemented("Provider"),
useDispatch: () => mockSituation?.dispatch ?? mockStateError("useDispatch"),
useSelector: (selector: AnySelector) => {
return mockSituation
? mockSituation.getSelector(selector).provide()
: mockStateError("useSelector");
connect: mockConnect(getDispatch, () => {
if (!mockSituation) {
throw new Error(
"You included mock-redux but didn't call mockRedux() before rendering a connect() component.",
);
}

if (!mockSituation.state) {
throw new Error(
"You included mock-redux but didn't set state before rendering a connect() component.",
);
}

return mockSituation.state;
}),
Provider: () => {
throw new Error(`Provider is not supported when using mock-redux.`);
},
useDispatch: getDispatch,
useSelector: getSelector,
};
});

Expand All @@ -39,7 +55,7 @@ afterEach(() => {
export const mockRedux = <State>(): MockRedux<State> => {
if (require.cache[require.resolve("react-redux")]) {
throw new Error(
"It looks like you imported react-redux before mock-redux. Put mock-redux before react-redux or any imports that include react-redux.",
"It looks like you imported react-redux before mock-redux. Import mock-redux before react-redux or any imports that include react-redux.",
);
}

Expand Down
150 changes: 147 additions & 3 deletions src/tests/connect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,152 @@
import "mock-redux";
import { render } from "@testing-library/react";
import { mockRedux } from "mock-redux";
import React, { useEffect } from "react";
import { connect } from "react-redux";
import { act } from "react-dom/test-utils";

const value = "Hi!";
const payload = { value };
const action = (payload: unknown) => ({ payload, type: "ACTION" });

type WithAction = { action: typeof action };
type WithValue = { value: string };

describe("connect", () => {
it("throws an error when mock-redux has been imported", async () => {
expect(connect).toThrowError("connect is not supported when using mock-redux.");
it("passes through to the component when there are no mappings", async () => {
const ConnectedRendersValue = connect()((props: { text: string }) => <>{props.text}</>);

mockRedux();

const view = render(<ConnectedRendersValue text={value} />);

view.getByText(value);
});

describe("mapStateToProps", () => {
const RendersValue = ({ value }: WithValue) => {
return <>{value}</>;
};

it("throws an error when a connected component is rendered without a mock state", () => {
const selectValue = (state: WithValue) => state.value;
const mapStateToProps = (state: WithValue) => ({ value: selectValue(state) });
const ConnectedRendersValue = connect(mapStateToProps)(RendersValue);

mockRedux();

expect(() => ConnectedRendersValue({})).toThrowError(
"You included mock-redux but didn't set state before rendering a connect() component.",
);
});

it("provides a selector value when mapStateToProps calls a selector", async () => {
const selectValue = (state: WithValue) => state.value;
const mapStateToProps = (state: WithValue) => ({ value: selectValue(state) });
const ConnectedRendersValue = connect(mapStateToProps)(RendersValue);

mockRedux().state({ value });

const view = render(<ConnectedRendersValue />);

view.getByText(value);
});
});

describe("mapDispatchToProps", () => {
const FiresAction = ({ action }: WithAction) => {
useEffect(() => {
action(payload);
}, []);
return null;
};

it("throws an error when a connected component is rendered without having called mockRedux", () => {
const ConnectedFiresAction = connect(null, { action })(FiresAction);

expect(() => ConnectedFiresAction({})).toThrowError(
"You included mock-redux but didn't call mockRedux() before calling useDispatch from react-redux.",
);
});

it("mocks an action dispatch when mapDispatchToProps is used in the object form", async () => {
const ConnectedFiresAction = connect(null, { action })(FiresAction);
const { dispatch } = mockRedux();

act(() => {
render(<ConnectedFiresAction />);
});

expect(dispatch).toHaveBeenCalledWith(action(payload));
});

it("mocks an action dispatch when mapDispatchToProps is used in the function form", async () => {
const ConnectedFiresAction = connect(null, (dispatch) => ({
action: () => dispatch(action(payload)),
}))(FiresAction);
const { dispatch } = mockRedux();

act(() => {
render(<ConnectedFiresAction />);
});

expect(dispatch).toHaveBeenCalledWith(action(payload));
});
});

describe("mergeProps", () => {
type TracksPropsProps = Record<string, unknown> & {
spy: jest.Mock;
};

const TracksProps = (props: TracksPropsProps) => {
useEffect(() => {
props.spy(props);
}, []);
return null;
};

const fromState = { from: "state" };
const fromDispatch = { from: "dispatch" };

const mapStateToProps = () => ({ fromState });
const mapDispatchToProps = { fromDispatch };

it("defaults to a basic merge when not provided", () => {
const spy = jest.fn();
const ConnectedTracksProps = connect(mapStateToProps, mapDispatchToProps)(TracksProps);

mockRedux().state({});

act(() => {
render(<ConnectedTracksProps spy={spy} />);
});

expect(spy).toHaveBeenCalledWith({
fromDispatch: expect.any(Function),
fromState,
spy,
});
});

it("is used when provided", () => {
const spy = jest.fn();
const ConnectedTracksProps = connect(
mapStateToProps,
mapDispatchToProps,
(stateProps, dispatchProps, ownProps) => ({ stateProps, dispatchProps, ...ownProps }),
)(TracksProps);

mockRedux().state({});

act(() => {
render(<ConnectedTracksProps spy={spy} />);
});

expect(spy).toHaveBeenCalledWith({
dispatchProps: { fromDispatch: expect.any(Function) },
stateProps: { fromState },
spy,
});
});
});
});
2 changes: 1 addition & 1 deletion src/tests/mockRedux.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mockRedux } from "mock-redux";
describe("mockRedux", () => {
it("throws an error when required after react-redux", async () => {
expect(mockRedux).toThrowError(
"It looks like you imported react-redux before mock-redux. Put mock-redux before react-redux or any imports that include react-redux.",
"It looks like you imported react-redux before mock-redux. Import mock-redux before react-redux or any imports that include react-redux.",
);
});
});
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
"declaration": true,
"esModuleInterop": true,
"jsx": "react",
"module": "commonjs",
"module": "esnext",
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"strict": true,
"target": "es2015"
"target": "es2017"
}
}
17 changes: 17 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,18 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==

"@types/lodash.mapvalues@^4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.mapvalues/-/lodash.mapvalues-4.6.6.tgz#899b6e1d3b9b4e313bc332ec18182f9fce7aec7c"
integrity sha512-Mt9eg3AqwAt5HShuOu8taiIYg0sLl4w3vDi0++E0VtiOtj9DqQHaxVr3wicVop0eDEqr5ENbht7vsLJlkMHL+w==
dependencies:
"@types/lodash" "*"

"@types/lodash@*":
version "4.14.152"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.152.tgz#7e7679250adce14e749304cdb570969f77ec997c"
integrity sha512-Vwf9YF2x1GE3WNeUMjT5bTHa2DqgUo87ocdgTScupY2JclZ5Nn7W2RLM/N0+oreexUk8uaVugR81NnTY/jNNXg==

"@types/node@*":
version "14.0.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.5.tgz#3d03acd3b3414cf67faf999aed11682ed121f22b"
Expand Down Expand Up @@ -3673,6 +3685,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"

lodash.mapvalues@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=

lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
Expand Down

0 comments on commit ccd630c

Please sign in to comment.