What is TDD?
The goal of these small pieces of code mentioned in the introduction is to verify small chunks of a system (then they are called Unit Tests) or whole scenarios on live application (this approach is called the end to end tests). E2E tests are usually written by a Quality Assurance Specialist and could be based on Behaviour Driven Development scenarios that describe every possible path for a new feature in the application. Developers are most often focused on Unit Tests. It is not obvious how or when we should write those tests. In TDD approach developer should first write single, failing tests and then try to write code that would pass the test.
Test-Driven Development is a software development process that relies on simple life-cycle:
- Add failing test
- Write/fix code to make the test pass
- (optional) Refactor the code
- Go to the first step.
The theory behind it seems simple, isn’t it? How does it look in practice, in real frontend application? It is not clear what should we test on the frontend. Should we test stylesheets? Should we test the render method of each component? What about interactions and mocking data?
What should we test?
TDD in React application should be based on unit tests for single components or helper (pure) functions. In the simplest case, for a pure function like data parser or form value validator you should:
- Write a basic, first test for simplest (happy) path in YourComponent.spec.js file
- Add (or even create YouComponent.js file) some code only to pass the test from point 1.
- Refactor code (if needed)
- Add another test for a more complicated case
- Add or fix existing code
- Refactor… go to point 4.
For cases like this, you only need some test runner like Jest. It is a Javascript testing framework that could be added to almost any Javascript project. In the basic configuration, it could be run via the command line (yarn jest) and it will run every *.spec.js file in your repository and display the results (how many tests have passed or failed and where they failed).
Things are going to be more complicated if we want to write tests for React components. We need some render library like react-test-renderer that provides API for rendering components and creating assumptions for DOM tree. For basic components, it will be enough but if our components have some logic (and they definitely should have some :-) ) then we need to interact with them and we need a library like Enzyme. It provides an API for mounting React components and interacting with them (like simulating mouse click or form submit).
In Merixstudio we are splitting React components into two groups: smart and dumb components. Let's start from the latter - dumb components or sometimes called presentational components. These types of components are responsible for the visual aspects. They are simple in the sense that the only take what's provided to them with props and render it accordingly. The other group of components usually implements some internal business logic and state (it could be also called a container if it's connected to Redux Store). Those components shouldn’t bother too much about the visual representation of the data - they just pass it down to the dumb ones with props.
We definitely should write tests from dumb components. Dumb components should have a simple interface/props definition without any complex logic. We could for example test visibility of some content that is connected with the component's props. The example below use mount() from the enzyme library and create one test case (each test should be wrapped into Jest’s it() function. Multiple tests (for the single component) could be grouped into a test suite with describe() function.
describe('Input', () => {
it('should render a label if provided', () => {
const input = mount(<Checkbox label="Custom label" />);
expect(input.find('label').text()).toContain('Custom label');
});
};
That could be one of our first tests for custom input component that will fail because we are omitting label props in our implementation. We should remember that in TDD we have to write a failing test first, and then add (or fix) code (for example: add conditional label rendering based on component’s props)!
In tests we could also mock some action handlers (like form onSubmit function) and check if it’s called or not with proper parameters by using Jest spy function: jest.fn()
it('should be submitted correctly', () => {
const spy = jest.fn();
const formConfig = {
onSubmit: spy,
initialValues: {
name: 'Duck',
description: 'Yellow, rubber duck',
},
};
const wrapped = mount(
<CategoryForm {...formConfig} />
); wrapped
.find('form')
.simulate('submit'); expect(spy).toBeCalledWith(formConfig.initialValues);
});
In the example above, you can see that more complex tests should be divided into 3 parts separated by an empty line:
- declare function spies, variable values and mount components,
- interact with component (find and simulate),
- make assertions (with Jest functions like expect, toBe, etc.).
In the next step of Form component implementation, we could add test for invalid initialValues (like missing name) and check if our spy function has not been called. It should fail at first run but then we should add some validation rules for our form: like required name field that will prevent the form from submitting if the value is missing.
Having a Behaviour Driven Development scenarios could be beneficial for writing test cases for smart components. You cannot copy and paste them but you will be able to see how the logic in our complex component should behave (for example: how the form should validate data) and how tests for that component should look like.
What is important in Test Driven Development?
You should always remember that:
- Single tests should be simple (and short). The set of tests should create documentation for tested components (it takes this value… then display that…).
- You should test only one independent component. Using TDD in some way forces you to follow the Functional Programming paradigm. You could still test class-based components but it makes tests more complex. You should also add and mock additional Wrappers, HOC, and Providers (ex. Redux or React-Intl) only if it’s really necessary.
- Mock as little as possible. If you have to mock too many handlers and interfaces, maybe You are trying to write integration or end2end test.
- Don’t test external API or DOM API (like checking if Router.push() really change the route or HTML input change the value of uncontrolled input).
- Remember to write tests first. It’s less time consuming if you have to write code for existing tests. In another way, you will spend too much time trying to hack or create dirty and complex test suites or trying to refactor existing code to make a test less complicated. If you are obligated to have tests (and good code coverage), write them first. Always.
- Monitor code coverage with Jest console command yarn test --coverage (check coverage/lcov-report/index.html in your project folder). you could also use a library like Wallaby.js that integrates with Your IDE and show live test code coverage in the project.
- Create pre-commit hooks, and always run tests (and eslint check) before each commit. You could use Husky package from npm and basic configuration in package.json that run eslint for staged files and run tests for the whole project:
"husky": {
"hooks": {
"pre-commit": "yarn lint-staged && yarn test --bail"
}
},
"lint-staged": {
"*.js": [
"yarn eslint"
]
}
Containers, forms, snapshots
Creating unit tests for containers could lead to writing complex and hard to maintain tests that look like e2e scenarios. You should depend on (for example) automated UI tests for e2e testing provided by QA. Software developers should focus on unit tests. If you are creating a form with components, fields, and validators that already have unit tests, you could only check if a form is properly submitted (or not) like in the example above. In more general words: test only a new functionality provided by your component and don’t test functionality provided by already tested children. Things are going to be more complicated in containers. It is really hard to write a unit test for container - You have to mock a lot of API calls and data. Writing selectors for interactions with nested components is also tricky. If you are using already tested components in containers, You can create only snapshots (with react-test-renderer library that allows you to render DOM tree and styles without browser).
Each snapshot contains a piece of rendered source code (HTML with styles) and makes tracking changes in project really easy. For example: if you change some code of Redux Form Field that is used in many places, during snapshot update you will see what is going to change in the whole project. If it is possible, you could provide different initial data for container and make multiple snapshots for different cases (for example with selected different tabs in Tab Container component).
Redux, actions, selectors…
Almost every project is based on Redux or a similar solution for state management. Writing tests for Redux functions is really easy. For actions, You can use the Moxios library to force different API responses. Just add:
beforeEach(() => {
moxios.install();
});
afterEach(() => {
moxios.uninstall();
});
and then in each test, you could use moxios wait() and request() function to mock API response:
const mockData = { duckColor: 'yellow' };
it('creates FETCH_SETTINGS__SUCCESS after successfully fetching settings', () => {
moxios.wait(() => {
const request = moxios.requests.mostRecent();
request.respondWith({
status: 200,
response: mockData,
});
});
const expectedActions = [
{ type: actions.GET_SETTINGS_REQUEST },
{ type: actions.GET_SETTINGS_SUCCESS, payload: mockData },
];
const store = mockStore({ setting: {} }); return store.dispatch(actions.getSettings()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
In Unit Tests created for actions you only should test if an appropriate actions is dispatched for specific API response. For reducer tests, You only have to create an initial state, action payload and then make assumptions for state modified by the reducer.
it('should handle DELETE_DUCK_SUCCESS', () => {
expect(reducer({
ducks: [
{ id: 1, color: yellow },
{ id: 2, color: pink },
],
}, actions.deleteDuckSuccess(2)).abstracts).toEqual({
ducks: [
{ id: 1, color: 'yellow },
],
});
});
If your selectors don’t have any complex logic (just like state => state.setting.duckColor) you can skip testing that part of code. If you are adding some logic (like sorting, filtering or anything else) it can be beneficial for code coverage to add some tests for that (remember to write them first!).
Pros and cons of Test Driven Development
Pros:
- Self-documented components.
- Force developers to write clear code. Spaghetti code is harder to test. Testable code is more modular and easier to use as independent blocks in the project.
- Confidence (“If it passed my test, it works!”, “If I want to add features to someone else components I could be sure that nothing should break”).
Cons:
- More time-consuming in a short time (but generates code easier to maintain in the future).
- if you skip writing tests first it can be really time-consuming to catch up with new tests for existing code.
So as you can see there are various benefits and disadvantages of this type of approach. I won’t give you a final answer whether it will fit your project but you should definitely take it into consideration during the software development process.
Navigate the changing IT landscape
Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .