Software Development Cycle: Don't test implementation details - Redvike
21 September 2020

Software Development Cycle: Don’t test implementation details

21 September 2020

Software Development Cycle: Don’t test implementation details

Author:

Avatar
Jan Michalak
21 September 2020

Software Development Cycle: Don’t test implementation details

Author:

Avatar
Jan Michalak

When I’ve started my journey with React learning, using tools like redux, redux-thunks, reselect, or different helpful libraries came up really easy, except unit testing. Every time I’ve started writing tests for a new application I’ve dropped doing it after testing some amount of components. Mostly because I wasn’t convinced that my tests are useful for the software development cycle. The reason that happened is actually really trivial. I’ve focused on testing stuff that doesn’t matter for the end-user. I’ve been mostly focused on testing implementation details like component’s local state changes, redux reducers, actions, and so on.

Why have I been doing that?

The main reason is the lack of knowledge and experience. If you don’t know how good tests look like it’s harder to build them by yourself.

What’s more, the library I’ve picked didn’t help. The enzyme library is great. However, offering as many possibilities for newcomers as it does may lead to the catastrophe…

My role in this “process”

So I wrote multiple implementation details tests of my components. But the further I go, the more I noticed how useless these tests are for me. Eventually, I stopped writing tests at all. However, after some time of not testing my applications, I’ve discovered a library released by Kent C. Dodds – React Testing Library. Thanks to its simplicity, React Testing Library helps you understand what is important for app users and what you should test.

I’ve read docs, wrote some tests, and realized that it actually works. So if you’ve been in the same situation and didn’t know how to write valuable tests this article is for you.

If you want to achieve confidence by unit testing, you have to start thinking like clients of your application do. Try to cover as many user’s flows for every story as possible. Testing the weirdest edge cases will be still better than focusing on covering 100% of every function in your code.

Let’s see how it works in a real app

The plan is to implement the same app but with two ways of implementing state management. The first one will be using old fashioned redux and Class Components state, while the second one will be using function components with hooks and context API for the global app state. The goal is to have two apps which will work exactly the same but implemented using different approaches. The most important is to keep the same unit tests for both of them.

So imagine you’re getting a nice design from your Product Designer and requirements from the Project Manager and task for that can look pretty much like that:

Hi buddy, we have work to do for our client. We need to implement ASAP, to be honest, we needed it yesterday 🙂 Here’s the list of functionalities of our app:

  • our app has a scene with ground and sky
  • our app has one rocket on app init
  • user can add new rockets but there can be max 4 rockets on the scene
  • user can remove rockets but there have to be at least one rocket left
  • user can change rocket color on rocket click
  • the app has a fancy night/day mode which toggles on button click

Try TDD!

If you are lucky and have requirements listed clearly you can immediately start with implementing features. Even better will be TDD approach – start with tests and then implement it in React code.

describe("App", () => {
  it("renders", () => {
    render(<Main />);
  });

  it("it has scene with sky and ground", () => {
    const { getByTestId } = render(<Main />);
    expect(getByTestId("Sky")).not.toBeNull();
    expect(getByTestId("Ground")).not.toBeNull();
  });

  it("has only one rocket on app init", () => {
    const { getAllByTestId } = render(<Main />);
    expect(getAllByTestId("Rocket").length).toBe(1);
  });

  it("should add rocket on button click", () => {
    const { getAllByTestId, getByText } = render(<Main />);

    expect(getAllByTestId("Rocket").length).toBe(1);
    fireEvent.click(getByText(/add rocket/i));

    expect(getAllByTestId("Rocket").length).toBe(2);
  });

  it("shouldn't allow to have more that 4 rockets", () => {
    const { getAllByTestId, getByText } = render(<Main />);
    const addBtn = getByText(/add rocket/i);

    expect(getAllByTestId("Rocket").length).toBe(1);

    fireEvent.click(addBtn); // add 2
    fireEvent.click(addBtn); // add 3
    fireEvent.click(addBtn); // add 4

    expect(addBtn).toBeDisabled();

    fireEvent.click(addBtn); // add 4
    expect(getAllByTestId("Rocket").length).toBe(4);
  });

  it("should allow remove rockets if more than one", () => {
    const { getAllByTestId, getByText } = render(<Main />);

    const rmBtn = getByText(/remove rocket/i);
    const addBtn = getByText(/add rocket/i);

    expect(rmBtn).toBeDisabled();

    fireEvent.click(addBtn); // add rocket
    fireEvent.click(addBtn); // add rocket
    fireEvent.click(addBtn); // add rocket

    expect(getAllByTestId("Rocket").length).toBe(4);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(3);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(2);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(1);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(1);
  });

  it("changes toggles night/day mode", () => {
    const { getByTestId } = render(<Main />);

    const ground = getByTestId("Ground");
    const sky = getByTestId("Sky");
    const btn = getByTestId("LightModeBtn");

    expect(ground).toHaveClass("ground--day");
    expect(sky).toHaveClass("sky--day");
    expect(btn).toHaveTextContent(/night mode: on/i);

    fireEvent.click(btn);
    expect(ground).toHaveClass("ground--night");
    expect(sky).toHaveClass("sky--night");
    expect(btn).toHaveTextContent(/night mode: off/i);
  });

  it("changes rocket color on click", () => {
    utils.getRandomRGB = jest.fn().mockReturnValueOnce("red");
    const { getByTestId } = render(<Main />);
    const rocket = getByTestId("Rocket");
describe("App", () => {
  it("renders", () => {
    render(<Main />);
  });

  it("it has scene with sky and ground", () => {
    const { getByTestId } = render(<Main />);
    expect(getByTestId("Sky")).not.toBeNull();
    expect(getByTestId("Ground")).not.toBeNull();
  });

  it("has only one rocket on app init", () => {
    const { getAllByTestId } = render(<Main />);
    expect(getAllByTestId("Rocket").length).toBe(1);
  });

  it("should add rocket on button click", () => {
    const { getAllByTestId, getByText } = render(<Main />);

    expect(getAllByTestId("Rocket").length).toBe(1);
    fireEvent.click(getByText(/add rocket/i));

    expect(getAllByTestId("Rocket").length).toBe(2);
  });

  it("shouldn't allow to have more that 4 rockets", () => {
    const { getAllByTestId, getByText } = render(<Main />);
    const addBtn = getByText(/add rocket/i);

    expect(getAllByTestId("Rocket").length).toBe(1);

    fireEvent.click(addBtn); // add 2
    fireEvent.click(addBtn); // add 3
    fireEvent.click(addBtn); // add 4

    expect(addBtn).toBeDisabled();

    fireEvent.click(addBtn); // add 4
    expect(getAllByTestId("Rocket").length).toBe(4);
  });

  it("should allow remove rockets if more than one", () => {
    const { getAllByTestId, getByText } = render(<Main />);

    const rmBtn = getByText(/remove rocket/i);
    const addBtn = getByText(/add rocket/i);

    expect(rmBtn).toBeDisabled();

    fireEvent.click(addBtn); // add rocket
    fireEvent.click(addBtn); // add rocket
    fireEvent.click(addBtn); // add rocket

    expect(getAllByTestId("Rocket").length).toBe(4);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(3);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(2);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(1);

    fireEvent.click(rmBtn);
    expect(getAllByTestId("Rocket").length).toBe(1);
  });

  it("changes toggles night/day mode", () => {
    const { getByTestId } = render(<Main />);

    const ground = getByTestId("Ground");
    const sky = getByTestId("Sky");
    const btn = getByTestId("LightModeBtn");

    expect(ground).toHaveClass("ground--day");
    expect(sky).toHaveClass("sky--day");
    expect(btn).toHaveTextContent(/night mode: on/i);

    fireEvent.click(btn);
    expect(ground).toHaveClass("ground--night");
    expect(sky).toHaveClass("sky--night");
    expect(btn).toHaveTextContent(/night mode: off/i);
  });

  it("changes rocket color on click", () => {
    utils.getRandomRGB = jest.fn().mockReturnValueOnce("red");
    const { getByTestId } = render(<Main />);
    const rocket = getByTestId("Rocket");

    expect(rocket).toHaveStyle("fill: red");

    fireEvent.click(rocket);
    expect(rocket).toHaveStyle("fill: yellow");
  });
});
    expect(rocket).toHaveStyle("fill: red");

    fireEvent.click(rocket);
    expect(rocket).toHaveStyle("fill: yellow");
  });
});

Check tests in codesandbox!

Implement with Redux and React Class Components

So we wrote our tests and now we can start to implement it with redux and class components.

We will start by writing our reducer and actions as we made the decision to manage our global state with redux.

export const reducer = (state = {}, action) => {  
  switch (action.type) {  
    case 'ADD_ROCKET':  
 return {  
        ...state,  
        rockets: [...state.rockets, state.rockets.length + 1]  
      }  
    case 'REMOVE_ROCKET':  
 const rockets = [...state.rockets]  
      rockets.pop()  
      return {  
        ...state,  
        rockets  
  }  
  
    case 'TOGGLE_NIGHT_MODE':  
 return {  
        ...state,  
        nightMode: !state.nightMode  
      }  
    default:  
 return state  
  }  
}  
  
const addRocket = () => ({ type: 'ADD_ROCKET' })  
const removeRocket = () => ({ type: 'REMOVE_ROCKET' })  
const toggleNightMode = () => ({ type: 'TOGGLE_NIGHT_MODE' })

And now let’s wire it with our UI elements like rockets and buttons to add/remove them and toggle for day/night mode.

class RocketStation extends React.Component {  
  render() {  
    const {  
      rockets,  
      addRocket,  
      removeRocket,  
      toggleNightMode,  
      nightMode  
  } = this.props  
  return (  
      <div className="App">  
        <Scene nightMode={nightMode}>  
          {rockets.map(id => (  
            <Rocket key={id} />  
          ))}  
        </Scene>  
        <div className="actions">  
          <Button  
  variant={'success'}  
            onClick={addRocket}  
            disabled={rockets.length === 4}>  
            Add rocket  
  </Button>  
          <Button  
  variant={'danger'}  
            onClick={removeRocket}  
            disabled={rockets.length === 1}>  
            Remove rocket  
  </Button>  
          <Button  
  data-testid="LightModeBtn"  
  variant={'navy'}  
            onClick={toggleNightMode}>  
            {`Night mode: ${nightMode ? 'OFF' : 'ON'}`}  
          </Button>  
        </div>  
      </div>  
    )  
  }  
}  
  
const mapStateToProps = state => ({  
  rockets: state.rockets,  
  nightMode: state.nightMode  
})  
const mapDispatchToProps = {  
  addRocket,  
  removeRocket,  
  toggleNightMode  
}  
  
const Main = connect(  
  mapStateToProps,  
  mapDispatchToProps  
)(RocketStation)

And all this stuff will be rendered inside our app root component:

export const initialState = {  
  rockets: [1],  
  nightMode: false  
}  
  
const store = createStore(reducer, initialState)  
  
function App() {  
  return (  
    <Provider store={store}>  
      <Main />  
    </Provider>  
  )  
}

The only thing we forgot is to change the rocket’s color on rocket click:

class Rocket extends React.Component {  
  constructor(props) {  
    super(props)  
    this.state = {  
      color: getRandomRGB()  
    }  
  
    this.changeColor = this.changeColor.bind(this)  
  }  
  
  changeColor() {  
    this.setState({ color: getRandomRGB() })  
  }  
  
  render() {  
    return (  
      <RocketIcon  
  data-testid="Rocket"  
  onClick={this.changeColor}  
        style={{ fill: this.state.color }}  
      />  
    )  
  }  
}

Let’s get all tests green

But after implementation like that, some of our tests may fail. So we will make a few adjustments.

We’re getting this dirty error:

Could not find “store” in the context of “Connect(RocketStation)”. Either wrap the root component in a <Provider>, or pass a custom React context provider to <Provider> and the corresponding React context consumer to Connect(RocketStation) in connect options.

And that’s because we are using redux and we need to include redux <Provider> to our tests as well. To do this we will override render method from @testing-library/react

import { render as rtlRender } from '@testing-library/react'  
import { createStore } from 'redux'  
import { Provider } from 'react-redux'  
  
function render(  
  ui,  
  { state = initialState, store = createStore(reducer, state) } = {}  
) {  
  return {  
    ...rtlRender(<Provider store={store}>{ui}</Provider>),  
    store  
  }  
}  
  
test('renders', () => {  
  render(<Main />)  
})

The next thing to do is to mock our getRandomRGB function. It returns random color(BG) on every call. We are doing it because it is impossible to assert the result of that function with the expected one. You have to be confident when writing tests.

import  *  as  utils  from  "../utils";

it('changes rocket color on click', () => {  
  utils.getRandomRGB = jest.fn().mockReturnValueOnce('red')  
  const { getByTestId } = render(<Main />)  
  const rocket = getByTestId('Rocket')  
  
  expect(rocket).toHaveStyle('fill: red')  
  
  utils.getRandomRGB = jest.fn().mockReturnValueOnce('yellow')  
  
  fireEvent.click(rocket)  
  expect(rocket).toHaveStyle('fill: yellow')  
})

Now all of our tests are green! Yay!!!

Rewrite it to hooks with Context API

Now the coolest part. We will rewrite our app from React Class Components to function ones and instead of redux for the global state, we will use Context API. After that our tests should pass the tests without changing them AT ALL. Sounds great? Let’s do it.

Let’s start with declaring the context and it’s provider – <AppProvider>:

const MainContext = React.createContext()  
  
export function AppProvider({ children }) {  
  const [state, dispatch] = useReducer(reducer, initialState)  
  return (  
    <MainContext.Provider  
  value={{  
        rockets: state.rockets,  
        nightMode: state.nightMode,  
        addRocket: () => dispatch({ type: 'ADD_ROCKET' }),  
        removeRocket: () => dispatch({ type: 'REMOVE_ROCKET' }),  
        toggleNightMode: () => dispatch({ type: 'TOGGLE_NIGHT_MODE' })  
      }}>  
      {children}  
    </MainContext.Provider>  
  )  
}

Thanks to the useReducer hook we can use the same one we had with redux implementation!!!

Now get rid of connect from RocketStation and use that context.

function RocketStation() {  
  const {  
    rockets,  
    addRocket,  
    removeRocket,  
    toggleNightMode,  
    nightMode  
  } = useContext(MainContext)  
  return (  
    <div className="App">  
      <Scene nightMode={nightMode}>  
        {rockets.map(id => (  
          <Rocket key={id} />  
        ))}  
      </Scene>  
      <div className="actions">  
        <Button  
  variant={'success'}  
          onClick={addRocket}  
          disabled={rockets.length === 4}>  
          Add rocket  
  </Button>  
        <Button  
  variant={'danger'}  
          onClick={removeRocket}  
          disabled={rockets.length === 1}>  
          Remove rocket  
  </Button>  
        <Button  
  data-testid="LightModeBtn"  
  variant={'navy'}  
          onClick={toggleNightMode}>  
          {`Night mode: ${nightMode ? 'OFF' : 'ON'}`}  
        </Button>  
      </div>  
    </div>  
  )  
}

And don’t forget to refactor Rocket Class Component to function one:

function Rocket() {  
  const [color, changeColor] = useState(getRandomRGB())  
  return (  
    <RocketIcon  
  data-testid="Rocket"  
  onClick={() => {  
        console.log('click')  
        changeColor(getRandomRGB())  
      }}  
      style={{ fill: color }}  
    />  
  )  
}

Now adjust our render method for unit tests as we are not using redux <Provider> anymore so we will replace it with <AppProvider>

function  render(ui)  {
return  {
		...rtlRender(<AppProvider>{ui}</AppProvider>)
	};
}

And woohoo!!! Our tests are still green 🙂

Summary

I think I don’t even have to write much because as you’ve already seen, not focusing on implementation details is a huge benefit for your unit tests. You don’t have to struggle with testing how an app works under the hood but how it works for clients. The process of writing tests gets nicer and event TDD approach, which I recommend you to try, is easier too.

As long as the app still provides the same experience for users, you can even completely rewrite the way of implementation. It lets you refactor the codebase without any risk of the broken product.

App with Redux Class Components approach – Link to Condesandbox!

App with Hooks and Context API – Link to Codesandbox!

component
development
software
testing

Related blog posts

Interested? - let us know ☕
[email protected] Get Estimation