Dojo : TDD Against the Time with React and Redux
This project is maintained by Bogala
We have 2 milestones for our ant : 700 moves and 20000+ moves (No spoilers here, you’ll see it in time)
But, if we want to move the ant up to 700 or 2000 times, we don’t want to click on the play button for each move. Therefore, we will upgrade our button with three steps:
Firstly, our App component is too complex, and we have to separate the graphic component from the functional behavior. To split those, we have to migrate our local state to a state manager : Redux.
Redux was created by Dan Abramov around June 2015. It was inspired by Facebook’s Flux and functional programming language Elm. Redux got popular very quickly because of its simplicity, small size (only 2 KB) and great documentation.
Redux is a predictable state container for JavaScript apps that helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
Redux simplify significtaly communication between components (PPTX) :
Without Redux | With Redux |
---|---|
To resume, Redux is :
Flux is the application architecture that Facebook uses for building client-side web applications. It complements React’s composable view components by utilizing a unidirectional data flow. It’s more of a pattern rather than a formal framework.
A simple function that takes a state and an action, and returns a new state.
Reducers specify how the application’s state changes in response to actions sent to the store. Remember that actions only describe the fact that something happened, but don’t describe how the application’s state changes.
The container is the connector between React component and reducers (redux state).
We have to install packages redux, react-redux and recompose (used for Higher-Order Components)
yarn add redux recompose react-redux
, associated types and mock system…
yarn add @types/redux @types/recompose @types/react-redux redux-mock-store @types/redux-mock-store -D
Now, we can initiate and connect redux to react.
Let’s create a store folder under src and add a reducer
lagton-ant-app
|_ src
|_ components
|_ App
|_ App.scss
|_ App.spec.tsx
|_ App.ts
|_ index.ts
|_ store
|_ index.ts
|_ reducer.spec.ts
|_ reducer.ts
|_ stories
|_ index.tsx
|_ index.ts
|_ registerServiceWorker.ts
[...]
reducer.spec.ts
import { MainState, default as reducer } from './reducer';
import { Action } from 'redux';
describe('reducer', () => {
it('should initialise with MainState Interface', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual as MainState).toBeTruthy();
});
it('should pass state by cdefault', () => {
class MockMainState implements MainState {}
const actual = reducer(new MockMainState(), { type: null} as Action);
expect(actual instanceof MockMainState).toBeTruthy();
});
});
reducer.ts
import { Action } from 'redux';
export interface MainState {
}
const initialState: MainState = {};
export default (state: MainState = initialState, action: Action) => {
switch (action.type) {
default:
return state;
}
};
Now make a store with this reducer
import { createStore } from 'redux';
import reducer from './reducer';
export const configureStore = () => (
createStore(
reducer,
// tslint:disable-next-line:no-any
(window as any).__REDUX_DEVTOOLS_EXTENSION__ && (window as any).__REDUX_DEVTOOLS_EXTENSION__()
)
);
To connect redux and react, we have two steps :
The file to update for this is our main index.tsx
We have to surround our app component with a react-redux Provider
[...]
import { Provider } from 'react-redux';
import { configureStore } from './store/index';
ReactDOM.render(
<Provider store={configureStore()}>
<App />
</Provider>,
document.getElementById('root') as HTMLElement
);
[...]
To prepare App component, we have to make a typing for the props :
Add these interfaces to your App.tsx
[...]
export interface AppBindingProps {}
export interface AppEventProps {}
export interface AppProps extends AppBindingProps, AppEventProps {}
export default (props: AppProps) => (
[...]
Add a new file App.container.spec.tsx
import 'core-js';
import 'jest-enzyme';
import * as Adapter from 'enzyme-adapter-react-16';
import * as React from 'react';
import { configure, shallow, ShallowWrapper } from 'enzyme';
import configureStore from 'redux-mock-store';
import App from './';
// tslint:disable-next-line:no-any
configure({ adapter: new Adapter() });
const mockStore = configureStore();
let container: ShallowWrapper;
describe('App container', () => {
beforeEach(() => {
const store = mockStore({});
container = shallow(<App />, { context: { store } });
});
it('renders without crashing', () => {
expect(container.length).toEqual(1);
});
});
and associated App.container.ts
import { MapStateToProps, MapDispatchToProps, connect } from 'react-redux';
import { MainState } from '../../store/reducer';
import App, { AppProps, AppBindingProps, AppEventProps } from './App';
const mapStateToProps: MapStateToProps<AppBindingProps, AppProps, MainState> = (state, props) => ({});
const mapDispatchToProps: MapDispatchToProps<AppEventProps, AppProps> = (dispatch, ownProps) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(App);
you can understand mapStateToProps types like :
mapStateToProps = (state: MainState, props: AppProps):AppBindingProps => ({});
Not forget to change index.ts reference :
import App from './App.container';
Move refs from App.state
to App[Grid].props
on App.spec.tsx
.
For example
Before:
const initAndPlay = async (playTimes: number = 1) => {
const wrapper = mount(<App />);
for (let times = 0; times < playTimes; times++) {
await wrapper.find(AvPlayArrow).simulate('click');
}
const ant: Ant = wrapper.state().ant;
const cells: boolean[][] = wrapper.state().cells;
return {
wrapper,
ant,
cells
} as TestContext;
};
After:
const initAndPlay = async (playTimes: number = 1) => {
const wrapper = mount(<App />);
for (let times = 0; times < playTimes; times++) {
await wrapper.find(AvPlayArrow).simulate('click');
}
const ant: Ant = wrapper.find(Grid).props().ant;
const cells: boolean[][] = wrapper.find(Grid).props().cells;
return {
wrapper,
ant,
cells
} as TestContext;
};
So, you can search for “wrapper.state()” and replace it with “wrapper.find(Grid).props()”
Create and implement tests in redux elements:
import { MainState, Ant, default as reducer } from './reducer';
import { Action } from 'redux';
describe('reducer', () => {
test('should initialise with MainState Interface', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual as MainState).toBeTruthy();
});
test('should pass state by cdefault', () => {
class MockMainState implements MainState {
grid: boolean[][];
ant: Ant;
}
const actual = reducer(new MockMainState(), { type: null} as Action);
expect(actual instanceof MockMainState).toBeTruthy();
});
test('initial state must have a grid definition', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual).toHaveProperty('grid');
});
test('initial state must have a 21x21xfalse grid', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual.grid).toEqual(new Array<Array<boolean>>(21).map(() => new Array<boolean>(21).fill(false)));
});
test('initial state must have an ant definition', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual).toHaveProperty('ant');
});
test('initial state must have an ant at 10:10:0°', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual.ant).toEqual(new Ant());
});
});
Move step tests from App to reducer:
import { MainState, Ant, default as reducer } from './reducer';
import { Action } from 'redux';
import * as _ from 'lodash';
interface GridCoordinates {
x: number;
y: number;
}
const initAndPlay = (playTimes: number = 1): MainState => {
const initialState = _.cloneDeep(reducer(undefined, { type: null} as Action));
let finalState = initialState;
for (let times = 0; times < playTimes; times++) {
finalState = reducer(finalState, { type: 'PLAY'} as Action);
}
return finalState;
};
const expectGreyCells = (context: MainState, ...greyCells: Array<GridCoordinates>) => {
for (let line = 0; line < context.grid.length; line++) {
for (let cell = 0; cell < context.grid[line].length; cell++) {
const val = greyCells.some(value => value.x === cell && value.y === line);
expect(context.grid[line][cell]).toBe(val);
}
}
};
describe('reducer', () => {
test('should initialise with MainState Interface', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual as MainState).toBeTruthy();
});
test('should pass state by cdefault', () => {
class MockMainState implements MainState {
grid: boolean[][];
ant: Ant;
}
const actual = reducer(new MockMainState(), { type: null} as Action);
expect(actual instanceof MockMainState).toBeTruthy();
});
test('initial state must have a grid definition', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual).toHaveProperty('grid');
});
test('initial state must have a 21x21xfalse grid', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual.grid)
.toEqual(
new Array<Array<boolean>>(21).fill(new Array<boolean>(21)).map(() => new Array<boolean>(21).fill(false))
);
});
test('initial state must have an ant definition', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual).toHaveProperty('ant');
});
test('initial state must have an ant at 10:10:0°', () => {
const actual = reducer(undefined, { type: null} as Action);
expect(actual.ant).toEqual(new Ant());
});
describe('[App]Step 3.1: First move', () => {
test('Ant must rotate 90° when play button clicked', () => {
const { ant } = initAndPlay();
expect(ant.rotation).toBe(90);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(initAndPlay(), { x: 10, y: 10 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay();
expect(ant.x).toBe(11);
expect(ant.y).toBe(10);
});
});
describe('[App]Step 3.2: Second move', () => {
test('Ant must rotate 90° when play button clicked', () => {
const { ant } = initAndPlay(2);
expect(ant.rotation).toBe(180);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(initAndPlay(2), { x: 10, y: 10 }, { x: 11, y: 10 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay(2);
expect(ant.x).toBe(11);
expect(ant.y).toBe(11);
});
});
describe('[App]Step 3.3: Third move', () => {
test('Ant must rotate 90° when play button clicked', () => {
const { ant } = initAndPlay(3);
expect(ant.rotation).toBe(270);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(initAndPlay(3), { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 11 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay(3);
expect(ant.x).toBe(10);
expect(ant.y).toBe(11);
});
});
describe('[App]Step 3.4: Fourth move', () => {
test('Ant must rotate 90° when play button clicked', async () => {
const { ant } = await initAndPlay(4);
expect(ant.rotation).toBe(0);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(initAndPlay(4), { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 11, y: 11 }, { x: 10, y: 11 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay(4);
expect(ant.x).toBe(10);
expect(ant.y).toBe(10);
});
});
describe('[App]Step 3.5: Fifth move', () => {
test('Ant must rotate 90° when play button clicked', () => {
const { ant } = initAndPlay(5);
expect(ant.rotation).toBe(270);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(
initAndPlay(5),
{ x: 11, y: 10 },
{ x: 11, y: 11 },
{ x: 10, y: 11 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay(5);
expect(ant.x).toBe(9);
expect(ant.y).toBe(10);
});
});
describe('[App]Step 3.10: Tenth move', () => {
test('Ant must rotate 90° when play button clicked', () => {
const { ant } = initAndPlay(10);
expect(ant.rotation).toBe(180);
});
test('Cell is grey when play button clicked', () => {
expectGreyCells(
initAndPlay(10),
{ x: 10, y: 10 },
{ x: 11, y: 10 },
{ x: 11, y: 11 },
{ x: 10, y: 11 },
{ x: 9, y: 9 },
{ x: 10, y: 9 });
});
test('Ant must move left when play button clicked', () => {
const { ant } = initAndPlay(10);
expect(ant.x).toBe(9);
expect(ant.y).toBe(11);
});
});
});
Now, implement tests with App elements:
import { Action } from 'redux';
import * as _ from 'lodash';
export class Ant {
public x: number;
public y: number;
public rotation: number;
constructor(x: number = 10, y: number = 10, rotation: number = 0) {
this.x = x;
this.y = y;
this.rotation = rotation;
}
}
export interface MainState {
grid: Array<Array<boolean>>;
ant: Ant;
}
const initGrid = () => (
new Array<Array<boolean>>(21).fill(new Array<boolean>(21))
.map(() => new Array<boolean>(21).fill(false))
);
const initialState: MainState = {
grid: initGrid(),
ant: new Ant()
};
export default (state: MainState = initialState, action: Action) => {
switch (action.type) {
case 'PLAY': {
const finalState = play(state);
return { ...finalState };
}
default:
return state;
}
};
const play = ({ grid, ant }: MainState): MainState => {
const movement = moveByRotation(ant.rotation, grid[ant.y][ant.x]);
const rotation = newRotation(ant.rotation, grid[ant.y][ant.x]);
grid[ant.y][ant.x] = !grid[ant.y][ant.x];
movement.x += ant.x;
movement.y += ant.y;
return {
ant: { ...ant, rotation: rotation, x: movement.x, y: movement.y },
grid: [...grid]
};
};
const newRotation = (rotation: number, right: boolean) => {
let result = rotation + 90;
if (right) {
result += 180;
}
if (result >= 360) {
result -= 360;
}
return result;
};
const moveByRotation = (rotation: number, right: boolean) => {
const value = { x: 0, y: 0 };
switch (rotation) {
case 90:
value.y++;
break;
case 180:
value.x--;
break;
case 270:
value.y--;
break;
default:
value.x++;
break;
}
if (right) {
value.x = -value.x;
value.y = -value.y;
}
return value;
};
newRotation
and moveByRotation
are copied from App.tsx and play
function is an modifed version of onClick
.
Don’t forget the movement of the Ant interface by removing the definition in Grid.tsx.
If you update src/components/App/Grid/index.ts
like this:
export {Ant} from '../../../store/reducer';
You will avoid side-effects.
If you want to refactor, you can move functions to an actions.ts
file.
To use the reducer in our application, we have to map the PLAY
event to our App’s onClick
, as well as map grid
and ant
to the grid.
So, we don’t need to have the grid and ant definitions anymore in App.tsx
. Now, this is Redux’ responsability.
Let’s begin with App.tsx
. We want to purge the state and map onClick
to the redux dispatcher.
export interface AppEventProps {
onClick?: () => void;
}
If you want to map an event to a dispatcher, you have to add this event to the props.
We want the play button to launch the PLAY
action in the reducer:
App.container.spec.tsx
const mockStore = configureStore();
let container: ShallowWrapper;
const store = mockStore({
grid: new Array<Array<boolean>>(21).fill(new Array<boolean>(21))
.map(() => new Array<boolean>(21).fill(false)),
ant: new Ant()
});
describe('App container', () => {
test('renders without crashing', () => {
container = shallow(<App />, { context: { store } });
expect(container.length).toEqual(1);
});
test('map Dispatch to onClic prop', async () => {
store.dispatch = jest.fn();
const wrapper = mount(<App />, { context: { store } });
await wrapper.find(AvPlayArrow).simulate('click');
expect(store.dispatch).toBeCalledWith({ type: PLAY} as Action);
});
});
With this test, we can implement App.container.tsx
:
const mapDispatchToProps: MapDispatchToProps<AppEventProps, AppProps> = (dispatch, ownProps) => ({
onClick: () => {
dispatch({ type: PLAY} as Action);
}
});
Now, we have to connect the Grid as well:
Grid.container.spec.tsx
import 'core-js';
import 'jest-enzyme';
import * as Adapter from 'enzyme-adapter-react-16';
import * as React from 'react';
import { configure, shallow, ShallowWrapper } from 'enzyme';
import configureStore from 'redux-mock-store';
import Grid from './';
import { Ant } from './';
// tslint:disable-next-line:no-any
configure({ adapter: new Adapter() });
const mockStore = configureStore();
let container: ShallowWrapper;
const store = mockStore({
grid: new Array<Array<boolean>>(21).fill(new Array<boolean>(21))
.map(() => new Array<boolean>(21).fill(false)),
ant: new Ant()
});
describe('App container', () => {
test('renders without crashing', () => {
container = shallow(<Grid />, { context: { store } });
expect(container.length).toEqual(1);
});
test('Grid dispatched from redux to props', async () => {
container = shallow(<Grid />, { context: { store } });
expect(container.prop('cells')).toEqual(new Array<Array<boolean>>(21).fill(new Array<boolean>(21))
.map(() => new Array<boolean>(21).fill(false)));
});
test('Ant dispatched from redux to props', async () => {
container = shallow(<Grid />, { context: { store } });
expect(container.prop('ant')).toEqual(new Ant());
});
});
Grid.container.tsx
import { MapStateToProps, MapDispatchToProps, connect } from 'react-redux';
import { MainState } from '../../../store/';
import Grid, { GridBindingProps, GridEventProps, GridProps } from './Grid';
const mapStateToProps: MapStateToProps<GridBindingProps, GridProps, MainState> = (state, props) => ({
ant: state.ant,
cells: state.grid
});
const mapDispatchToProps: MapDispatchToProps<GridEventProps, GridProps> = (dispatch, ownProps) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(Grid);
Grid/index.ts
import Grid from './Grid.container';
import './Grid.scss';
export {Ant} from '../../../store/reducer';
export default Grid;
If your project does not work, you can download the solution zip file
If you want to play with routes, you can make a route validator : if we are at root (/
), we can see the grid. Otherwise, we will have a 404 error.
Before, we make a NotFound
component under App
NotFound.spec.tsx
import * as React from 'react';
import * as renderer from 'react-test-renderer';
import NotFound from './';
describe('footer', () => {
it('Render match snapshot ', () => {
const component = renderer.create(<NotFound />).toJSON();
expect(component).toMatchSnapshot();
});
});
NotFound.tsx
import * as React from 'react';
export default () => (
<div>
<h1>404 not found</h1>
<p>The page your are searching for is not here!</p>
</div>
);
React Router v4 is a pure React rewrite of the popular React package. Previous versions of React Router used configuration disguised as pseudo-components and would be difficult to understand. Now with v4, everything is now “just components”. Every component can manage its own routes. Let’s make our simple example :
Before all, explain to your application that you want to use routes with tag BrowserRouter
index.tsx
<Provider store={configureStore()}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root') as HTMLElement
);
And, in your App
component, add an explanation about what we want :
const App = ({ title, onClick }: AppProps) => (
<MuiThemeProvider>
<div>
<AppBar
title={title || 'Langton Ant'}
iconElementLeft={<IconButton><AvPlayArrow onClick={onClick} /></IconButton>}
/>
<div>
<div className="stretch">
<Card className="md-card">
<Switch>
<Route path="/" component={Grid} exact={true} />
<Route component={NotFound} />
</Switch>
</Card>
</div>
</div>
</div>
</MuiThemeProvider>
);
Switch
indicate the route specifications definitionRoute
is used to define and specify how works one Routepath
is the url suffix used to redirect to component. Here, we are in the root path (http://localhost:3000/) but, if we are in a component under path /foo and we define path with “/bar”, the url of this component is http://localhost:3000/foo/bar.exact
define if the system read any path under the Route. If false, the path is considered as a begining. All url after /
will be managed in defined component. If true, the /
is the only one route used for this component.Route
element without path. It’s used for all other paths.If you want, you can directly download the code with router.
So here, if we are on http://localhost:3000/ url, the grid will be shown. But, all other path show the Not Found component.
Now, we can make tests and implement our new functionalities.
When you’re done, you can go to the next step : Asynchronous logic with Redux
5 Steps to reproduce every cycle:
Before each test, launch a five minutes timer.
All of your code must be covered by unit tests.
We’ll avoid any
as much as possible (implicit or not).