In recent years there has been a surge in the community’s interest in clean code, in having good tests for our applications, and in searching for the correct or most viable way to carry out these tasks.
The truth is that there is no formula we can replicate automatically in every project that gives us a quality result all the same, for example: in one project we may have a larger number of e2e tests and this may bring us more value than in others, since there is hardly any logic to it and the application is based on mere user interactions; while in another case, having more granular unit tests may be more relevant due to component reuse.
Don’t worry, what matters is not choosing one over the others, but letting the application itself define iteratively what the best solution is for it. To that end, test-driven development (TDD) is the best tool I have adopted in my day-to-day work, and it allows me to keep a good balance in my developments.
It is worth highlighting that, based on my experience, TypeScript is key to the maintenance of projects. We never know what degree of knowledge or level of development rigor the person who may end up on the project we started will have, so being able to rely on types to define a solid skeleton for our components and being able to prevent the most basic errors without having to add tests for them is undoubtedly a great tool we must take into account. Furthermore, being a typed language, most editors/IDEs have support for applying refactors automatically.
But… in the frontend?
Many developers (myself included) think there are two possible approaches: if you want to check your application’s interface, use e2e tests. However, if what you want is to verify that your code meets expectations, use unit tests.
This approach is not negative at all, but it limits us a great deal when defining a good architecture. As developers such as Martin Fowler or Robert C. Martin point out, code that cannot be tested is not the best implementation of it. On the other hand, adding a specific feature to our code so that it can be tested, without providing any value, is not a good option either. So, what then?
Let us remember that test-driven development aims to define a functionality, while the tests generated for it will be a “collateral damage” (if we prefer to call it that) of choosing this methodology.
Without going any further, React is a library with which we can build interfaces (web, mobile, and desktop alike) and make it call our javascript code so that this in turn develops as required: calling an API, persisting to local storage, emitting a callback action… etc. Therefore, we can apply this methodology with complete normality.
How do we start?
To get down to work, the first thing is to be clear about what functionality we are going to develop, or at least about the flow the user will follow, that is: we need to be clear about which use case we are going to implement.
It is very important that we think about this from the point of view of the person who is going to use the application, and not from the programmer’s point of view. Let us consider that this is the layer closest to the user: the closer this definition is to how the end user will interact, the higher the level of abstraction it will have and therefore the less maintenance it will require. Put another way: code is liable to change, the way the user interacts with the application is not (since that would be a different functionality altogether). If we do not want to invest time editing tests for something that was already working, because we changed our code, the best thing will be to stay as close as possible to what should change the least.
For this example we will look for two use cases simple enough and that we can represent in a straightforward way:
- The user will visit our website and receive a product catalog.
- The user will select one of the products on screen and will be able to add it to the cart.
We will take for granted that we have created a React application and that we have configured jest in the project. It does not matter whether we used create-react-app, create-next-app, created it by hand, or used another tool.
e2e tests
First we will define the e2e tests. These will allow us to simulate exactly how the user is going to operate the website.
There are many tools to simulate this interaction; however, the most widely used today are:
- Cypress
- Puppeteer
- Nightwatch
In this case we will use cypress together with jest for it.
We will go to our React application (it does not matter if we created it with CRA, Next, or another tailored solution) and we will install the dependency:
npm i -D cypress
Once installed, we must start our application and run cypress to check that everything works correctly:
npx start
npx cypress open
You can dig a little deeper into this area by visiting the official documentation.
Once we have run cypress for the first time, we can proceed to create the tests; for that we will create a products-catalog.spec.js file in the cypress/integration directory
And we will define the following block:
describe('Product Catalog', () => {
it('shows the catalog', () => {
cy.visit('/');
cy.contains('.products > li');
});
});
In this case what we will do is navigate to the root of our application and verify that we are able to find a product listing.
As a handicap, let us think… is the user able to visualize the DOM tree? The answer is no, therefore having used this kind of approach will make our test very fragile. If, for design reasons, the name of the css class changed, or worse, if an li were not used, our test would break and we would have to spend time fixing it.
So, let us think: what is it that represents a product that the user will undoubtedly see? A priori, the title comes to mind. So what we can do is search by that field.
describe('Product Catalogue', () => {
it('shows the catalog', () => {
const productTitles = ['Clean Code', 'Clean Architecture', 'Spider-man: Life Story', 'Mastering React']
cy.visit('/');
productTitles.map( title => {
cy.contains(title);
});
});
});
OK, it is a very good idea… except for another small detail. Where does the data come from? Can we guarantee that the data source will not change? Unfortunately not. So we have two options:
-
Running the e2e tests, bringing up our API with a database specific for testing. This would give us a greater level of confidence, but we would have to invest a lot of time when it comes to configuration.
-
Intercepting the
httpcall and overwriting the call with predefined values, commonly known asfixtures.
Fortunately, this last method is simpler and cypress already gives us tools for it, but let us take it one step at a time:
First we must create a json file in the cypress/fixtures/catalog/products.json directory:
{
"products": [
{ "title": "Clean Code", "price": 20 },
{ "title": "Clean Architecture", "price": 20 },
{ "title": "Spider-man: Life Story", "price": 20 },
{ "title": "Mastering React", "price": 20 }
]
}
Next, in our test, we will have to define the endpoint we are going to consume:
const productsEndpoint = {
method: 'GET',
url: 'http://localhost:4000/products',
};
Then we will define that for each test, a listening server must be started:
beforeEach(() => {
cy.server();
});
And finally we will have to intercept the request:
cy.route({...productsEndpoint, response: 'fixture:catalog/products', status: 200 });
Therefore, the result of our test would be the following:
import { products } from '../fixtures/catalog/products.json'
describe('Product Catalogue', () => {
const productsEndpoint = {
method: 'GET',
url: 'http://localhost:4000/products',
};
beforeEach(() => {
cy.server();
});
it('shows the catalog', () => {
cy.route({...productsEndpoint, response: 'fixture:catalog/products', status: 200 });
cy.visit('/');
products.map( ({title}) => {
cy.contains(title);
})
});
})
These tests will be red most of the time until we complete the development in its entirety, so if it is more comfortable for you, you can mark them as
skippedand run them from time to time.
Github code
Now that we already have our first e2e test, we can begin to implement our application.
Unit tests
Now then, once we have defined what the use case is and what functionality we want to carry out, let us move on to unit tests. In this case there may be a certain collision of interests, since at first glance our use case may seem the same, but its purpose is completely different:
While the e2e test allows us to identify when the client’s expectations are met; the unit test will help us define an architecture that will be characterized by being modular.
Installation
In the following example we will make use of testing-library/react to mount our components and of testing-library/jest-dom to achieve more human assertions.
To do so, we will run the following command:
npm i --save-dev test-library/jest-dom testing-library/react
Once the installation is complete we can proceed to create our first test Home.spec.js
Personally, since these are unit tests, I usually prefer to create them in the same directory where I am going to create the component
First test - Displaying data
The test should have the following structure:
import * as React from 'react';
describe('Home Container', () => {
it('shows a list of products', () => {
// ... no code yet
expect(foundProducts.length).toBe(products.length)
});
});
But… one second, have we defined an expect? And the definition of variables? It is here where the magic begins to emerge.
Starting from this point we understand that, the number of products found must be equal to one previously defined in the test and that somehow we will have to pass it to the component, but how? It is from this point on that we start to define architecture.
We must be practical, it is no use looking for complicated solutions, we must make the test pass in the shortest time possible. Once we find the solution and implement the functionality we can assess whether it may really be worth complicating the solution, understanding by complicating the act of making our solution generic for several cases, instead of specific to this particular case.
To be able to retrieve the list of products into our component, which will be returned by the API, what we can do is pass it, through properties, the method in charge of retrieving that information. This is known as dependency injection.
Therefore, we will create a mock object, which returns a promise with our data:
it('shows a list of products', () => {
// Define a data source
const products: Product[] = [buildProduct({}), buildProduct({})];
const productsRepository = {
getProducts: jest.fn(() => Promise.resolve(products))
};
const view = render(<Home productsRepository={productsRepository}/>);
// ... no code yet
expect(foundProducts.length).toBe(products.length);
});
As you will observe, we make use of a buildProduct to define our values and not add irrelevant information to the test (You can read more about the builder pattern here). So we would only be left with searching visually for the representation of that data. One of the advantages of testing-library is that it gives us selectors very close to the user layer, so, as we already mentioned earlier, it will give a longer lifespan to our tests:
it('shows a list of products', async () => {
// Define a data source
const products: Product[] = [
buildProduct({handle: 'handle-1', title:'title 1'}),
buildProduct({handle: 'handle-2', title:'title 2'})
];
const productsRepository = {
getProducts: jest.fn(() => Promise.resolve(products))
};
const view = render(<Home productsRepository={productsRepository}/>);
// Search in async way all the product titles at the screen
const foundProducts = await Promise.all(products.map((product) => view.findByText(product.title)));
expect(foundProducts.length).toBe(products.length);
});
Perhaps in this process some doubts have arisen, or even during the definition of entities, such as, for example, what would happen if instead of a resolved promise, it returned a rejected promise to us? And what if it returns an empty list? Stopping to think about defining a use case helps us pay closer attention to our code and find edge cases more common than you would expect.
Let us remember that making several implementations at the same time is not the best of ideas; So, as we go along defining a test, we can note down the rest of the cases to consider them afterwards.
// what happen when request fails?
// what happen when list is empty?
it('shows a list of products', async () => {
// irrelevant for now
});
Now then, given the test, we have already assumed certain things, such as that our component will receive an object with a method that will retrieve the products and that, therefore, we will have to iterate over them to display them. For the moment, it is mandatory to run the test and verify that it fails (red). Once this is done, we can generate the component code in the test file itself:
interface HomeProps {
productsRepository: ProductsRepository;
}
export const Home: React.FC<HomeProps> = ({ productsRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
React.useEffect(() => {
productsRepository.getProducts().then(setProducts);
}, []);
return (
<section>
{products.map(product => <article key={product.handle}>{product.title}</article>)}
</section>
);
};
Running the test again, we see that it now works correctly (green), so it is time to commit and ask ourselves whether any refactor applies. In this case, it is time to move our component to the Home.jsx file.
This process is known as RGR, Red - Green - Refactor. We will only modify code when we find ourselves in Green. Raising new cases, or refactoring code in Red, will only lead us to increase the complexity of the application and incur errors.
Github code
Edge cases - Empty list
Let us continue with the other two cases. What happens when our product list is empty? A priori, the most sensible thing is to give the user feedback that the list is empty:
// what happen when request fails?
// what happen when list is empty?
it('shows a list of products', async () => {
// completed
});
it('shows a message when list is empty', () => {
// Not implemented yet
expect(view.queryByText(emptyMessage)).toBeInTheDocument();
});
Following the previous process, we can realize that the message will be something shared between the component and the test, but let us remember, now is not the moment to make these changes, we are in red.
On this occasion we will change the value that our productsRepository returns to us for an empty list, and we will define an emptyMessage variable:
it('shows a message when list is empty', async () => {
// Define a data source
const products: Product[] = [];
const productsRepository = {
getProducts: jest.fn(() => Promise.resolve(products))
};
const emptyMessage = 'No products were found';
const view = render(<Home productsRepository={productsRepository}/>);
expect(await view.findByText(emptyMessage)).toBeInTheDocument();
});
We run our test and we see that it indeed fails. Let us update the component:
interface HomeProps {
productsRepository: ProductsRepository;
}
export const Home: React.FC<HomeProps> = ({ productsRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
React.useEffect(() => {
productsRepository.getProducts().then(setProducts);
}, []);
return (
<section>
{ products && products.length > 0
? products.map(product => <article key={product.handle}>{product.title}</article>)
: <p>No products were found</p>
}
</section>
);
};
We are in Green now, we could apply several refactors such as:
- Exposing a constant from the component with the “No products were found” message.
- Refactoring our test to move the service definition to a
builderand make the test more readable. - Improving the readability of the condition to display the message
These changes would look as follows:
// what happen when request fails?
const buildProductRepository = (products: Product[]): ProductsRepository => ({
getProducts: jest.fn(() => Promise.resolve(products))
});
it('shows a list of products', async () => {
// Define a data source
const products: Product[] = [
buildProduct({handle: 'handle-1', title:'title 1'}),
buildProduct({handle: 'handle-2', title:'title 2'})
];
const productsRepository: ProductsRepository = buildProductRepository(products);
const view = render(<Home productsRepository={productsRepository}/>);
// Search in async way all the product titles at the screen
const foundProducts = await Promise.all(products.map((product) => view.findByText(product.title)));
expect(foundProducts.length).toBe(products.length);
});
it('shows a message when list is empty', async () => {
// Define a data source
const products: Product[] = [];
const productsRepository: ProductsRepository = buildProductRepository(products);
const view = render(<Home productsRepository={productsRepository}/>);
expect(await view.findByText(HomeText.emptyMessage)).toBeInTheDocument();
});
And our component:
export enum HomeText {
emptyMessage = 'No products were found'
}
interface HomeProps {
productsRepository: ProductsRepository;
}
export const Home: React.FC<HomeProps> = ({ productsRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
React.useEffect(() => {
productsRepository.getProducts().then(setProducts);
}, []);
const hasProducts = () => products && products.length > 0;
return (
<section>
{ hasProducts()
? products.map(product => <article key={product.handle}>{product.title}</article>)
: <p>{HomeText.emptyMessage}</p>
}
</section>
);
};
Github code
Edge cases - Exceptions
To finish, we are going to handle the error case. The most comfortable thing would be to change our Promise.resolve for a Promise.rejected; however, if we make this change in the builder, we will break the previous tests. This helps us understand that we have made the decision to apply a refactor to our code without having enough information to do so.
However, we will take advantage of the situation to find the best way to solve this case; that said, first the test in question:
it('shows a error when products can not be retrieved', async () => {
// Define a data source
const error = new Error('irrelevant error');
const productsRepository: ProductsRepository = {
getProducts: jest.fn(() => Promise.reject(error))
};
const view = render(<Home productsRepository={productsRepository}/>);
expect(await view.findByText(error.message)).toBeInTheDocument();
});
Now, we see the test fail and we move on to update our component with the new functionality:
const Home = ({ productsRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
const [error, setError] = React.useState<Error|null>(null);
React.useEffect(() => {
productsRepository.getProducts()
.then(setProducts)
.catch(setError);
}, []);
return (
<section>
{ error && <p>{error.message}</p> }
{ hasProducts()
? products.map(product => <article key={product.handle}>{product.title}</article>)
: <p>{HomeText.emptyMessage}</p>
}
</section>
);
};
Great, now everything works correctly; it is time to refactor our builder without breaking our tests. The easiest way to do it is to change the input parameter to the following:
const buildProductRepository = (promise: Promise<Product[]>) => ({
getProducts: jest.fn(() => promise)
});
And therefore in the definition of the productsRepository of each test:
it('shows a list of products', async () => {
const products: Product[] = [
buildProduct({handle: 'handle-1', title:'title 1'}),
buildProduct({handle: 'handle-2', title:'title 2'})
];
const productsRepository: ProductsRepository = buildProductRepository(Promise.resolve(products));
// ...
});
it('shows a message when list is empty', () => {
const products: Product[] = [];
const productsRepository: ProductsRepository = buildProductRepository(Promise.resolve(products));
// ...
});
it('shows a error when products can not be retrieved', () => {
const error = new Error('irrelevant error');
const productsRepository: ProductsRepository = buildProductRepository(Promise.reject(error));
// ...
});
Now, this last step has allowed us to close the component’s cycle and move on to other components. These components do not have to contain any tests if they are merely representational components lacking logic; let us keep in mind that the React features themselves are already verified by their team, and that we only need to worry about our own logic.
Furthermore, having started with the highest component in the DOM tree allows the components it wraps to have their display verified from our main test.
Github Code
Architecture changes
To avoid going on too long, let us assume that we have already defined the ProductCard component:
interface ProductCardProps {
product: Product;
onClick?: (handle: string) => void;
}
export const ProductCard: React.FC<ProductCardProps> = ({ product, onClick}) => (
<div>
<h3>{product.title}</h3>
<p>
Price:
<span>{product.price}</span>
</p>
{
onClick &&
<button type="button" onClick={() => { onClick(product.handle); }}>Add to cart</button>
}
</div>
);
With the following test:
it('calls with product identifier when is clicked', () => {
const product = buildProduct({ handle: 'irrelevant-handle' });
const clickMock = jest.fn();
const view = render(<ProductCard product={product} onClick={clickMock} />);
const button = view.getByRole('button');
fireEvent.click(button);
expect(clickMock).toHaveBeenCalledWith(product.handle);
});
And that therefore this component is used in our Home:
export const Home: React.FC<HomeProps> = ({ productsRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
const [error, setError] = React.useState<Error|null>(null);
React.useEffect(() => {
productsRepository.getProducts()
.then(setProducts)
.catch(setError);
}, []);
const hasProducts = () => products && products.length > 0;
return (
<section>
{ error && <p>{error.message}</p> }
{ hasProducts()
? products.map(product =>
<article key={product.handle}>
<ProductCard product={product} />
</article>)
: <p>{HomeText.emptyMessage}</p>
}
</section>
);
};
Now, we are aware that our ProductCard has a click event associated with it, and that when used from Home, it must add the product to the cart. This cart has nothing to do with the way we retrieve our products; it is rather a new entity that comes into play.
To be consistent, we have written the corresponding test to verify that this behavior holds:
it('add item to cart', async () => {
const firstProduct: Product = buildProduct({handle: 'first-product'});
const secondProduct: Product = buildProduct({handle: 'second-product'});
const productsRepository = buildProductRepository(Promise.resolve([firstProduct, secondProduct]));
const cartRepository = {
addItem: jest.fn(),
};
const view = render(<Home productsRepository={productsRepository} cartRepository={cartRepository} />);
const [, item] = await view.findAllByRole('button');
fireEvent.click(item);
expect(cartRepository.addItem).toHaveBeenCalledWith(secondProduct.handle);
});
And after seeing it fail, we have updated the component:
export const Home: React.FC<HomeProps> = ({ productsRepository, cartRepository }) => {
const [products, setProducts] = React.useState<Product[]>([]);
const [error, setError] = React.useState<Error|null>(null);
React.useEffect(() => {
productsRepository.getProducts()
.then(setProducts)
.catch(setError);
}, []);
const hasProducts = () => products && products.length > 0;
return (
<section>
{ error && <p>{error.message}</p> }
{ hasProducts()
? products.map(product =>
<article key={product.handle}>
<ProductCard
product={product}
onClick={cartRepository.addItem}
/>
</article>)
: <p>{HomeText.emptyMessage}</p>
}
</section>
);
};
Now, we have our dependencies and apparently our components work, but we will have to use real repository objects. To do so, we will simply create those entities, which will be in charge of fetching from the real API, and we will define those constants as the component’s default values:
export const cartRepository: CartRepository = {
addItem: (handle) => fetch('http://localhost:4000/cart', {
method: 'PUT',
body: JSON.stringify({ handle })
}).then(response => response.json())
};
export const productsRepository: ProductRepository = {
getProducts: () =>
fetch('http://localhost:4000/products')
.then(response => response.json())
.then(data => data.products)
}
And the component:
import { productsRepository as productsRepositoryInstance, ProductsRepository } from '../../repositories/ProductsRepository';
import { cartRepository as cartRepositoryInstance, CartRepository } from '../../repositories/CartRepository';
interface HomeProps {
productsRepository?: ProductsRepository;
cartRepository?: CartRepository;
}
export const Home: React.FC<HomeProps> = ({
productsRepository = productsRepositoryInstance,
cartRepository = cartRepositoryInstance,
}) => {
// ...
}
Now, why have we not written tests for our objects? It is really not necessary, since they only act as a proxy to the fetch library. However, if we had our own logic, such as transforming the response into a structure of our own, then carrying out that validation could be of interest.
Github Code
Final note
You may have noticed that the e2e tests do not pass, since it is unable to intercept the http request. This is because cypress can only intercept xhr requests, and the fetch we are using in our repository objects does not use this type of request. Cypress will start supporting it as of version 4.9.0.
For it to work correctly we will have to apply the following workaround:
/e2e/cypress/support/index.js
import './commands';
let polyfill;
before(() => {
const polyfillUrl = 'https://unpkg.com/whatwg-fetch@3.0.0/dist/fetch.umd.js';
cy.request(polyfillUrl).then(response => {
polyfill = response.body;
});
});
Cypress.on('window:before:load', win => {
delete win.fetch;
win.eval(polyfill);
});
Or, as another option, we can change our repository objects to use xhr and demonstrate that our architecture is fully decoupled between the view and data access.
Github Code
Conclusion
As we have seen, tests help us make decisions about our code, discover use cases we had not initially considered, and, of course, automatically verify that our code works.
However, this does not mean we have to go to the extreme where every line of code must be checked, mainly because in many cases it would make little sense, or because it would take additional time to learn how to check each thing in the right way, compared to what that checking brings us.