DEV Community

Kenichiro Nakamura
Kenichiro Nakamura

Posted on • Updated on

React/Redux application with Azure DevOps: Part 5 Function component and Hook

In the previous post, I enhanced the release pipeline. In this article, I come back to react application and update my application.

So far, I can only vote for cat or dog. Though I am quite happy for it, I will make it a bit more dynamic so that I can add other candidates on the fly. I also try to use following technologies.

  • React function component
  • Redux Hooks
  • Additional Test framework

The easiest way to understand Redux Hook is to follow Redux Toolkit: Advanced Tutorial.

Update Redux code

As redux store is the central place to store all data, I start updating this first.

1. Update voteSlice.ts. I was thinking about using lodash to use rich dictionary, but I use simple array for now.

  • Use array to hold data instead of catCount and dogCount
  • Add new action to add candidate on the fly
  • Use initialState to create cat and dog by default
/// voteSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export interface CountState {
    votes: number[];
    candidates: string[];
}

const initialState: CountState = {
    candidates: ['cat', 'dog'],
    votes: [0, 0]
};

const voteSlice = createSlice({
    name: 'vote',
    initialState: initialState,
    reducers: {
        increment(state: CountState, action: PayloadAction<number>) {
            state.votes[action.payload]++;
        },
        decrement(state: CountState, action: PayloadAction<number>) {
            state.votes[action.payload] =
                state.votes[action.payload] > 0 ?
                    state.votes[action.payload] - 1 : 0;
        },
        addCandidate(state: CountState, action: PayloadAction<string>) {
            state.candidates.push(action.payload);
            state.votes.push(0);
        }
    }
});

export const { increment, decrement, addCandidate } = voteSlice.actions;
export default voteSlice.reducer;

2. Update voteSlice.test.ts to match the test. Nothing special here.

import vote, { increment, decrement, addCandidate, CountState } from './voteSlice'
import { PayloadAction } from '@reduxjs/toolkit';

it('should be able to add candidate and initialize vote', () => {
  const initialState: CountState = {
    candidates: [],
    votes: []
  };
  const action: PayloadAction<string> = {
    type: addCandidate.type,
    payload: 'cat'
  };
  expect(vote(initialState, action)).toEqual({candidates: ['cat'], votes:[0]})
});

it('handle increment for cat', () => {
  const initialState: CountState = {
    candidates: ['cat'],
    votes: [0]
  };
  const action: PayloadAction<number> = {
    type: increment.type,
    payload: 0
  };
  expect(vote(initialState, action)).toEqual({candidates: ['cat'], votes:[1]})
});

it('handle increment for dog as 2nd candidate', () => {
  const initialState: CountState = {
    candidates: ['cat', 'dog'],
    votes: [0, 0]
  };
  const action: PayloadAction<number> = {
    type: increment.type,
    payload: 1
  };
  expect(vote(initialState, action)).toEqual({ candidates: ['cat', 'dog'], votes: [0, 1] })
});

describe('handle decrement', () => {
  it('handle decrement for first object when vote > 0', () => {
    const initialState: CountState = {
      candidates: ['cat', 'dog'],
      votes: [1, 1]
    };
    const action: PayloadAction<number> = {
      type: decrement.type,
      payload: 0
    };
    expect(vote(initialState, action)).toEqual({ candidates: ['cat', 'dog'], votes: [0, 1] })
  });

  it('handle decrement for first object when vote is already 0', () => {
    const initialState: CountState = {
      candidates: ['cat', 'dog'],
      votes: [0, 1]
    };
    const action: PayloadAction<number> = {
      type: decrement.type,
      payload: 0
    };
    expect(vote(initialState, action)).toEqual({ candidates: ['cat', 'dog'], votes: [0, 1] })
  });
});

That's it for redux part.

Components

To simply the application, I remove all Redux dependency from App.tsx so that I can convert it to function component in the future. I added three additional components instead.

  • CandidateBox: It only has input and button to add new candidate.
  • VoteBox: Display a candidate and its vote count. It also has buttons to vote.
  • VoteBoxes: Host all VoteBox for all candidate. Alt Text

To store all components, I added components folder under src.

CandidateBox

1. Add candidateBox.tsx under src/components. I use useDispatch Redux Hooks to simply the implementation so that I don't need to use connect. This gives me a capability to directly calls action without connect. See Redux Toolkit: Advanced Tutorial for more detail.

I also use useState to manage candidate state which only lives inside the component by following the information at Redux: Organizing State, which explain when to use redux vs setState.

One trick here is to use data-testid. This won't be affected in runtime, but I can use the id to obtain the element at the test time. See React Testing Library: Intro for more detail.

///candidateBox.tsx
import React, {useState} from 'react';
import { useDispatch } from 'react-redux';
import { addCandidate } from '../redux/reducer/voteSlice';

const CandidateBox: React.FC = () => {
  const [candidate, setCandidate] = useState("");
  const dispatch = useDispatch();

  return <div className="candidateBox">
    <input data-testid="input" type="text" value={candidate} onChange={(e) => {
        setCandidate(e.currentTarget.value);
      }} />
    <button onClick={() => {
      dispatch(addCandidate(candidate));
      setCandidate("");
    }
    }>Add candidate</button>
  </div>;
}

export default CandidateBox;

2. Add candidateBox.test.tsx in the same directory. I use two types of renderer here.

  • ShallowRenderer: Render the component to compare the snapshot
  • @testing-library/react - render: Render the component so that I can trigger event by using fireEvent
  • fireEvent.change to trigger change event
  • fireEvent.click to trigger click event

ShallowRender basically removes child components dependencies to simpily the unit test. See Shallow Renderer for more detail.

To isolate from Redux, use jest.mock to mock entire react-redux module.

/// candidateBox.test.tsx

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import CandidateBox from './candidateBox';
import { useDispatch } from 'react-redux';
import { addCandidate } from '../redux/reducer/voteSlice';

jest.mock('react-redux');
const useDispatchMock = useDispatch as jest.Mock;
const dummyFunc = jest.fn();

beforeEach(() => {
  useDispatchMock.mockReturnValue(dummyFunc);
});

it('should render expected element', () => {
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<CandidateBox />);
  const result = renderer.getRenderOutput();
  expect(result).toMatchSnapshot();
});

it('should call func with expected parameter', () => {
  const candidate = 'rabbit';
  const { getByText, getByTestId } = render(<CandidateBox />);
  fireEvent.change(getByTestId("input"), { target: { value: candidate } });
  fireEvent.click(getByText(/Add candidate/));
  expect(dummyFunc).toBeCalledTimes(1);
  expect(dummyFunc).toBeCalledWith({ type: addCandidate.type, payload: candidate });  
});

Votebox

1. Add voteBox.tsx under src/components. The useDispatch gives me a way to access store state data without connect.

  • Pass state and dispatch via useSelector and useDispatch
  • The component takes one property: index to identify the candidate and vote count
/// voteBox.tsx

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../redux/reducer/rootReducer';
import { increment, decrement } from '../redux/reducer/voteSlice';

interface voteProps{
  index:number
}

const Votebox: React.FC<voteProps> = props => {
  const dispatch = useDispatch();
  const { count, candidate } = useSelector(
    (state: RootState) => {
      return {
        count: state.vote.votes[props.index],
        candidate: state.vote.candidates[props.index]
      }
    }
  );

  return <div className="voteBox">
    <div>
      {candidate}:{count}
    </div>
    <button onClick={()=>dispatch(increment(props.index))}>+</button>
    <button onClick={()=>dispatch(decrement(props.index))}>-</button>
  </div>;
}

export default Votebox;

2. Add voteBox.test.tsx in the same folder. Similar approch to candidateBox.test.tsx.

  • Mock useSelector and return value for the test
  • Mock useDispatch and return mock function
  • Use jest snapshot testing to assert rendering result
///voteBox.test.tsx

import React from 'react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import VoteBox from './voteBox';
import { useSelector, useDispatch } from 'react-redux';

jest.mock('react-redux');
const useSelectorMock = useSelector as jest.Mock;
const useDispatchMock = useDispatch as jest.Mock;

beforeEach(() => {
  useDispatchMock.mockReturnValue(jest.fn());
});

it('should render cat votebox with vote 0', () => {
  const candidate = 'cat';
  const count = 0;
  useSelectorMock.mockReturnValueOnce({count:count, candidate:candidate});
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<VoteBox index={0} />);
  const result = renderer.getRenderOutput();
  expect(result).toMatchSnapshot();
});

it('should render dog votebox with vote 1', () => {
  const candidate = 'dog';
  const count = 1;
  useSelectorMock.mockReturnValueOnce({count:count, candidate:candidate});
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<VoteBox index={0} />);
  const result = renderer.getRenderOutput();
  expect(result).toMatchSnapshot();
});

VoteBoxes

The last component is VoteBoxes which render VoteBox as it's children.

1. Add voteBoxes.tsx in src/components folder. Simply take candidates from store and create child component by loop (map) the array.

///voteBoxes.tsx
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../redux/reducer/rootReducer';
import VoteBox from './voteBox';

const Voteboxes: React.FC = () => {
    const { candidates } = useSelector(
        (state: RootState) => state.vote
    );    

  return <div className="voteBoxes">
    {candidates.map((candidate, index) => <VoteBox key={index} index={index} />)}   
  </div>;
}

export default Voteboxes;

2. Add voteBoxes.test.tsx in the same directory. In this test, I uses ShallowRenderer, but not using Snapshot testing. I simply count child elements. There is no specific reason why I did it but I just wanted to test the framework capabilities.

///voteBoxes.test.tsx

import React from 'react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import VoteBoxes from './voteBoxes';
import VoteBox from './voteBox';
import { useSelector, useDispatch } from 'react-redux';

jest.mock('react-redux');
const useSelectorMock = useSelector as jest.Mock;
const useDispatchMock = useDispatch as jest.Mock;

beforeEach(() => {
  useDispatchMock.mockReturnValue(jest.fn());
});

it('should render two votebox', () => {
  useSelectorMock.mockReturnValueOnce({candidates:['cat','dog']});
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<VoteBoxes />);
  const result = renderer.getRenderOutput();
  expect(result.props.children.length).toBe(2);
  expect(result.props.children).toEqual([
    <VoteBox key={0} index={0} />,
    <VoteBox key={1} index={1}/>,
  ])
});

App

Now, all the elements are moved to each components, I can simply App.tsx a lot.

1. Update App.tsx. As I remove store dependency, I could remove connect as well as properties.

/// App.tsx

import React from 'react';
import logo from './logo.svg';
import './App.css';
import VoteBoxes from './components/voteBoxes';
import CandidateBox from './components/candidateBox';

class App extends React.Component {

  render() {    
    return (
      <div data-testid="App" className="App">
        <header className="App-header">
          <VoteBoxes />
          <CandidateBox />
          <img src={logo} className="App-logo" alt="logo" />
        </header>
      </div>
    );
  }
}

export default App;

2. Also update its test.

///App.test.tsx

import React from 'react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import App from './App';
import VoteBoxes from './components/voteBoxes';
import CandidateBox from './components/candidateBox';
import logo from './logo.svg';

it('render expected component', () => { 
    const renderer = ShallowRenderer.createRenderer();
    renderer.render(<App />);
    const result = renderer.getRenderOutput();
    expect(result.props.children).toEqual(<header className="App-header">
    <VoteBoxes />
    <CandidateBox />
    <img src={logo} className="App-logo" alt="logo" />
  </header>);
});

3. Because I changed how App component should be called, I also need to update index.tsx. I just need to remove properties from App.

///index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { Provider } from 'react-redux';
import store from './redux/store';

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root'));

    // If you want your app to work offline and load faster, you can change
    // unregister() to register() below. Note this comes with some pitfalls.
    // Learn more about service workers: https://bit.ly/CRA-PWA
    serviceWorker.unregister();

Run tests and application

Okay, to confirm everything works as expected, let's run the test first.

1. Run the test from shell and confirm the result.

npm test

2. Once the test is completed, I can see snapshots directory is added. This folder contains snapshot taken by jest.
Alt Text

3. Run the application to see if it works.

UI test

I tried several different approace this time.

Snapshot testing

One of the testing strategy I used this time is Snapshot testing. At first I was wondering what it is, but at the end, I feel this makes sense a lot.

The purpose of UI rendering test is to confirm all the component is rendered as expected. But right after the application is completed, I am quite sure it is rendering components as expected. One of the purpose of unit testing is to notice what's changed. Then why not just take snapshot of the rendered result, and compare to it next. If the rendered results are exactly same, test passes.

Once caveat is that, no one grantee your business logic is correct even though the snapshot matches. I maybe lucky (or unlucky in a sense) enough to generate same result even though my business logic has a bug. To avoid this situation, I should take snapshot with variation of possible data combination.

See Jest: Snapshot Testing for more detail including how to update and delete the snapshot.

Renderer

It is a bit confusing when I see so many renderers out there. Some provides very similar capabilities and some doesn't have function I need. The important thing is to know what I need to test, and find the renderer which can achieve it.

I didn't use the most famous renderer, enzyme. I will try it in the future.

Function component and hooks

This simplifies not only component implementation but also unit testing. If I don't need to manage state between session inside a component, then I definitely use function and hooks. But there maybe another use case for class component which I still don't know :)

CI

To run the unit test in CI pipeline, snapshot information is mandatory. According to Are snapshots written automatically on Continuous Integration (CI) systems?, it says:

It is recommended to always commit all snapshots and to keep them in version control.

Summary

In this article, I use jest snapshot testing to test UI component. In the next article, I will add external dependency and see how I can test.

Go to next article

Discussion (0)