React & Vite (Part 2)
Let’s add some tests to the TODO app we created in Part 1.
Vitest
Vitest is a popular test framework that we can use to test our React applications. We don’t want to ship vitest with our actual builds, so instead we install it as a development dependency.
npm install --save-dev vitest
We’ll want to add a line to our package.json
, to easily run our tests. Go ahead and add the following inside the scripts block of the package.json
file.
"test": "vitest"
Remember to add a comma to the previous script entry if it’ missing one
If you go ahead and run the script now, you should get an error.
npm run test
This is a good error. It shows that vitest is installed and running, but we haven’t created any test files yet, so this error is expected.
Testing Controversy
Before we go ahead and write our tests, lets discuss some of the pros and cons of testing. I intend to make a clear distinction here between my personal opinions, and those of others, so you can make your own conclusions. The last thing we need is more Cargo Cult Programming.
Potential Benefits of testing
- Gives greater confidence that code still works as intended after refactoring.
- Helps you to write cleaner code. Highly coupled messy code is hard to test, and so a strict testing policy almost becomes a tool to encourage refactoring.
- In the long run, tests can speed up development by reducing the amount of manual verification the programmer has to do.
- Doubles as a kind of documentation, communicating the original intent of the code.
- When combined with extreme programming, testing supports the YAGNI (“you aren’t gonna need it”) principle, ensuring that you only write the code you actually need.
Potential Downsides of testing
- Can require a lot more up front development time, slowing down delivery of features.
- Can be very difficult to add to a large existing code base, requiring significant development time.
- Developers can become too confident that everything is fine because all of the tests pass. The test suite passing DOES NOT guarantee program correctness.
- Front end tests can be very challenging to write, requiring fakery such as browser like api’s to simulate rendering components.
My own Controversial thoughts
While far from exhaustive, I think there is some truth to all of the benefits and downsides I’ve listed. My own experience has been that writing tests takes significantly more developer time. Even just maintaining the testing framework and layers of misdirection can be a significant time sink.
On the other side of the equation, I can certainly recall times when I’ve been refactoring something complex, and the reassurance given by well written tests has been very comforting.
One time, a Consultant and I spent two weeks trying to write tests for a new feature. The feature had taken me one week to write, and despite the intense focus on testing, an internal user broke it shortly after release. It was a trivial bug, and I fixed it in under an hour. The code for that feature was not touched for at least three years after that point. So what did the two weeks writing tests achieve? The tests made no difference to my debugging as the issue was trivial, and we never touched that code again.
I could fill a whole post on this topic, but this is a side mission. Different people have different experiences throughout their careers, and end up at different conclusions. My own view is that Front End testing is often more trouble than it’s worth. There are absolutely times where I’ve tested small segments of front end code because they are particularly important, or I believed that the potential bugs in that area could be devilishly subtle, but they are rare.
Context is just too important to create absolute rules when it comes to writing tests. Saying that, here are some things to think about when deciding how much effort to invest in testing.
- How complex is the logic? A compiler is a very complex piece of software. It warrants far more coverage than a set of event handlers on a website, unless that website has lives depending on it.
- How long will the software last? We don’t always know this, but we can usually make a reasonable guess.
- How hard is it to add tests for this project, and how big is the gain from doing so.
Consider the tradeoffs, the context. Don’t act on superstition, make an informed decision based on your circumstances. Don’t just demand 100% TEST COVERAGE! becuase Johnny Mc Tester thinks it’s the one true way, and equally don’t dismiss testing because we’re all coding cowboys, sailing on the high seas of Stack Overflow.
Testing our Context
With the pros and cons out of the way, lets test our Cart Context that we wrote in part 1. I want to start with context, because the meat of it is the reducer function, which just so happens to be pure. Pure functions are often much easier to test, and we don’t need any extra libraries to test the reducer, so lets get testing.
Create the following file src/contexts/cart.test.tsx
. Note the .test
in the name. Vitest looks for files with .test
or .spec
in the name by default, though this can be configured.
import { describe, it, expect } from 'vitest'
import { cartReducer } from './cart'
describe('test the cart reducer', () => {
it('adds to cart', () => {
const newState = cartReducer({
products:[],
}, {
type: "addToCart",
id: "espresso-4910",
});
expect(newState.products.find((p) => p.id == "espresso-4910")).toBeTruthy();
});
});
There is actually a surprising amount of subtlety here, both in the test, and the logic it is testing. Firstly, we are only testing one of the three functions in the reducer, but lets discount that for now and focus on just this one.
The first thing to note is that we are directly passing an action to the cart reducer function. This is different to how the rest of the code uses the reducer. Ideally, the reducer should only be triggered via the dispatch function. Given that we are testing a pure function in an isolated way, and in a way that a user should never trigger, I would categorise this as a Unit test.
On a related note, in order to test the cartReducer, we must export it from src/contexts/cart.tsx
. This is not good. We are in effect making this function public. We are opening the door to future developers accidentally importing it and using it in a way we did not intend. I’d love to know a good solution to this in Javascript. In general I think it’s bad practice to enlarge your public interface purely to satisfy testing requirements.
Perhaps a more interesting thing to note is the expect
function. It looks straight froward, but there is a lot of room to debate the best approach here.
Consider the following alternative line.
expect(newState.products[0].id).toBe("espresso-4910");
Which is better?
I personally believe option one is better. A quick reminder…
expect(newState.products.find((p) => p.id == "espresso-4910")).toBeTruthy();
In option one we search all products to find one with the matching id. It doesn’t matter too much as we are passing a blank products array for the initial state, but I generally find it better to make your assertions only test the thing we care about. In this case, the test name is screaming that “we care that the item is added to the cart”.
In the second example, we are also testing the product has been added, but also that it has been added at array position 0. When you are adding an item to a cart, unless the context dictates otherwise, we probably don’t care about the position. The code calling addToCart will likely not care how we are arranging cart items in memory, and most likely should not.
Another thing we might consider here, is that we are accessing the products array directly in our expect call. It’s acceptable in this case, because we are testing a reducer, the whole purpose of which is to return a state object. In a different context, we might prefer to access the products via some indrect method like a getter. We don’t want people manipulating the product array directly. If there is a mechanism to do that, then people could potentially implement functionality directly against that product array. Again this is in the context of a react reducer so it’s not super relevant, but it’s definitely worth keeping in mind. Always ask yourself, are you testing the interface, or the implementation.
As a general rule, unless you are testing that something has the correct structure, you should try to test the public interface, not the implementation. Suppose in our example we change our code to store products as an object, mapping id’s to product data instead of an array. All of our tests would break in this case. If however we were accessing products via some function, maybe a returnProductsList(): Product[]
function, we wouldn’t have this problem. In such a scenario, the responsibility for supporting the new structure, has been moved to the person implementing it. They need to update the interface function. It’s the responsibility of everyone else to use that function, and not the underlying store.
This is what is meant when people talk about tests being brittle. If you have to fix tests after every small internal change, your tests are brittle, and coupled too tightly to your implementation. As far as possible, try to test against interfaces. Interfaces should change less often, especially ones between architectural boundaries.
With all that said, lets add some more tests.
it('removes 0 from cart', () => {
const newState = cartReducer({
products:[{
id: "espresso-4910",
name: "Coding Hipster Espresso Machine",
price: 682.50,
qty: 4,
}],
}, {
type: "reduceQtyFromCart",
id: "espresso-4910",
qty: 0,
});
expect(newState.products.find((p) => p.id == "espresso-4910")?.qty).toBe(4);
});
it('removes 3 from cart', () => {
const newState = cartReducer({
products:[{
id:"espresso-4910",
name: "Coding Hipster Espresso Machine",
price: 682.50,
qty: 4,
}],
}, {
type: "reduceQtyFromCart",
id: "espresso-4910",
qty: 3,
});
expect(newState.products.find((p) => p.id == "espresso-4910")?.qty).toBe(1);
});
it('removes 5, removing the product entirely as there are only 4 in the cart ', () => {
const newState = cartReducer({
products:[{
id:"espresso-4910",
name: "Coding Hipster Espresso Machine",
price: 682.50,
qty: 4,
}],
}, {
type: "reduceQtyFromCart",
id: "espresso-4910",
qty: 5,
});
expect(newState.products.find((p) => p.id == "espresso-4910")).toBe(undefined);
});
These tests are much like the first one. Note that we are still looking directly at the id field on the product itself. I do think it’s acceptable in this situation. In others, I’d suggest implementing a findProduct
interface function so we could depend on that rather than the underlying implementation.
Testing React Components
This is the hard part. React components have a lot of magic around them, and we need a significant amount of fakery to test them. Let’s try testing our Shop page.
We need to add a few new dependencies to our program. Go ahead and install.
npm install --save-dev @testing-library/react @testing-library/dom jsdom
We also need to modify our vite.config.ts
file, telling it to use jsdom.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
},
})
With that done, lets go ahead and test that our Shop page component renders a list of available products.
Create the file src/pages/Shop.test.tsx
, and paste in the following.
import React from 'react'
import { expect, describe, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Shop } from './Shop'
import { CartProvider } from '../contexts/cart';
// We need a cart provider
function fancyRender(children: React.ReactNode) {
render (
<CartProvider>
{children}
</CartProvider>
);
}
describe('Shop', () => {
it('renders the Shop component', () => {
fancyRender(<Shop />);
const addToCartButtons = screen.getAllByText('Add to Cart');
expect(addToCartButtons.length).toBeGreaterThan(0);
const espressoMachine = screen.getAllByText(
'Coding Hipster Espresso Machine: £682.50'
);
expect(espressoMachine).toBeTruthy();
});
});
The render function comes from the React testing library. It renders our components without a browser, mimicking the dom that a browser would see from the rendered component.
Here we search the dom for at least one add to cart button, and an entry for our lovely Espresso Machine.
This code will run, but it does have a number of issues.
- We had to create a ‘fancyRender’ function to wrap the Shop component in context it needs.
- We are making assumptions about the list of available products.
- We had to add multiple dependencies to our project.
We can’t do much about number 3, but the remainder can be improved.
We can fix the first issue by renaming ‘fancyRender’ to something sensible, and moving it to it’s own file. It’s likely as we add more tests, that we will need more wrappers. We do not want multiple versions of this function that we have to update as our application grows, so just create one in a utility file somewhere, and use it everywhere.
The second issue reveals a potential code smell. Testing specifically for the espresso machine is bad. It’s brittle. What if we stop selling those. Should our tests really break because we change what is being sold? When testing, we need to be very deliberate and aware of what we are testing. In our case, the Shop component is simply responsible for rendering each item, and an associated add to cart button. It is not responsible for the actual items we are selling, so we shouldn’t test for specific items.
The reason for this hard coding, is the code smell. Our product list is hard coded, imported by the Shop component. This would make it very difficult to test the component with different product lists. It also seriously limits the components reusability. This is an example of testing revealing something that can be improved.
If we modify the component to take in a list of products, it becomes trivial to test different cases. We could go further and make it more generic, but for now lets keep it shop specific. Modify src/pages/Shop.tsx
to take in products as a prop like this.
import { useContext } from 'react'
import { Product } from '../products'
import { CartContext } from '../contexts/cart'
interface ShopProps {
products: Product[],
};
export function Shop({ products }: ShopProps) {
const { dispatch } = useContext(CartContext);
const addItemToCart = (productId: string) => {
dispatch({
type: "addToCart",
id: productId,
})
};
return (
<div className="content">
<ul className="shoplist">
{
products.map(item =>
<li key={item.id}>
{item.name}: £{item.price.toFixed(2)}
<button onClick={() => addItemToCart(item.id)}>
Add to Cart
</button>
</li>
)
}
</ul>
</div>
)
}
We also need to modify the router to pass in products. This also isn’t great, as the router shouldn’t care about products either. We could solve this by having an extra component between the two, but for this demo we will be a little naughty, and import products directly into the router like so.
import {
createBrowserRouter,
RouterProvider,
} from 'react-router-dom'
import { PageWithHeader } from './pages/PageWithHeader'
import { About } from './pages/About'
import { Shop} from './pages/Shop'
import { Cart } from './pages/Cart'
import { products } from './products'
export function Router() {
const router = createBrowserRouter([
{
element: <PageWithHeader />,
children: [
{
path: "/",
element: <About />,
},
{
path: "/about",
element: <About />,
},
{
path: "/shop",
element: <Shop products={products} />,
},
{
path: "/cart",
element: <Cart />,
},
],
},
]);
return (
<RouterProvider router={router} />
)
}
Initially it may look like we have achieved nothing. It’s a subtle, but important change. Our shop component is now more loseley coupled to the products than it was before. Reducing coupling may not seem like much when you’re only making a small change in one place, but over time it can make a big difference to your applications architecture.
Lets go back to our shop test, and pass in a custom list of products, purely for testing.
import React from 'react'
import { expect, describe, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Shop } from './Shop'
import { CartProvider } from '../contexts/cart';
// WE STILL NEED TO MOVE THIS :) I HAVE NOT FORGOTTEN
function fancyRender(children: React.ReactNode) {
render (
<CartProvider>
{children}
</CartProvider>
);
}
describe('Shop', () => {
it('renders the Shop component', () => {
const products = [{
id: "yoyo",
name: "Cool Yoyo",
price: 32.5,
}, {
id: "watch",
name: "Timefinder Wrist Watch",
price: 120.0,
}];
fancyRender(<Shop products={products}/>);
const addToCartButtons = screen.getAllByText('Add to Cart');
expect(addToCartButtons.length).toBe(2);
const coolYoyo = screen.getByText(
'Cool Yoyo: £32.50'
);
expect(coolYoyo).toBeTruthy();
});
});
Yoyos for everybody! Now we can specify any list of products we like, including the edge case of no products, and ensure our component responds the way that we want. You could still argue that we are testing implementation too much here, and instead should be focusing purely on the number of elements in our list, not specific text strings, but you have to draw the line somewhere.
As this post is getting rather long (and my Pizza is nearly ready), I’m going to end it with a few key points, and an exercise for the reader.
As stated earlier, programming rarely has hard rules, but here are some guidlines that will hopefully help to make testing less of a headache.
- Pure functions are the easiest to test, and you should be aware of this when writing functions that start having a lot of side effects. They are also usually easier to understand.
- Testing is tricky, and has a lot of subtlety, not to mention a bunch of contradicting opinions on how to do it right. Be very clear about what each test is doing, and weigh up the time and effort against the value provided by the test.
- Try to keep complex logic outside of things like react hooks. Instead, make hooks call normal functions for that functionality. It’s much easier to test vanilla functions in isolation than inside whatever new mechanism the latest framework has come up with. It’s also more future proof.
- Related to pure functions, but it’s worth repeating. Following a dependency injection style of coding (within reason) is often beneficial and makes things easy to test. A very common example is the ability to inject a fake or test database easily.
Now your homework. Modify the CartProvider wrapper to take an optional initial state, so you can test that the cart component renders content when there are items in the cart, and write the tests to do that.