Practical Six
October 30, 2025 at 11:59 PM
Goals
- Learn some basic techniques for testing React apps
- Get some experience using Vitest and the Testing Library
- Get some more TDD practice
Prerequisites
Create the git repository for your practical by accepting the assignment from GitHub Classroom. This will create a new repository for you with the Next/React infrastructure in place.
Clone the repository to you computer with
git clone(get the name of the repository from GitHub).Open up the
package.jsonfile and add your name as the author of the package.Install the module dependencies by typing
pnpm installin the shell in the terminal in the root directory of your package (the directory that contains the package.json file).Get some practice with our new workflow and start by making a feature branch in your repository.
Make sure that you have completed the Getting Started steps and have Docker Desktop installed.
You previously used Vitest for unit testing JS code. Today we are going to use Vitest in combination with the Testing Library library to test a React application. There are a number of items that need to be installed, but the project skeleton includes everything you need.
Regression Tests
Smoke test
The easiest form of testing we can perform is called a smoke test1. Unlike the testing we saw earlier, we aren’t going to assert anything, nor will we test anything explicitly. All we will do is try to render a component. If the process throws any errors, the test will fail, otherwise it succeeds. This kind of test is great for quick and dirty regression testing, where we are trying to make sure that adding new features or fixing bugs hasn’t inadvertently added any errors in unexpected places. Note that it doesn’t actually tell you that the functionality hasn’t been broken, just that it didn’t catch fire, as it were (the name comes from the hardware side of the world, where a smoke test means “plug it in and see if smoke comes out”).
For our smoke test, we will test the whole render tree of the DistanceConverter component. You will find the file index.tsx in the src/ directory. I have already provided the imports for you.
All we need to do is render the component, so the smoke test simply looks like this:
test('Smoke test', () => {
render(<DistanceCalculator />);
});Snapshots
Vitest provides another quick regression testing tool called snapshots. The idea with a snapshot is to make sure that the output of a function doesn’t change when we aren’t expecting it. When the test runs for the first time, Vitest serializes the value and saves it in a file (a file that should be committed to our repo for future tests). The next time the test is run, the output is compared with the saved value.
We can use this for anything, but we will find it most useful for monitoring the UI. We can render a component and then snapshot it If the component changes, the test will fail, which is a cue to either fix the offending code, or to take a new snapshot because the change was intentional. Note that the snapshot is not a literal picture, it is a JSON description of the component that can be quickly compared.
Here is the snapshot test:
test("Snapshot test", () => {
const { asFragment } = render(<App />);
expect(asFragment()).toMatchSnapshot();
});Here we are getting the asFragment function from the render function. The asFragment function returns a DocumentFragment with the rendered component. We then snapshot that fragment.
Note that we didn’t write anything to generate the snapshot. Vitest will do that automatically the first time the test is run.
You will find that Vitest has created a new directory called __snapshots__ in the same directory as your test file. Open this up and look at the snapshot that is stored in there. This should be committed with your code so that subsequent tests can use it.
So that you can see how the snapshot test works, go into src/pages/index.js and find where the place where write the name of the app (“Distance Converter”) and change it to something else. If you run the test now, the snapshot test will fail. Notice that you are provided with a diff showing what has changed. Of course, sometimes you will be making a change and you want the page to be different. If you are running the test with the file watcher (our default mode), you can type u to update the snapshot. If you were using pnpm test -run to disengage watcher mode, you can add a -u flag to update the snapshot.
No. Recall in TDD we write the tests first. But snapshots require a working implementation. Snapshots are most useful for regression testing.
Testing React Applications
Testing DistanceUnit
The DistanceConverter is composed of DistanceUnits. Let’s start our testing there. In particular, we will write a test to make sure that when the spin button is updated the callback is called. Here is an initial “Arrange” step, where we render the DistanceUnit component using a mock function for the callback.
test("DistanceUnit invokes callback with converted input", async () => {
const user = userEvent.setup(); // initialize the virtual user
// Arrange
const setMetersMock = vi.fn();
render(
<DistanceUnit
label="miles"
valueInMeters={0}
setMeters={setMetersMock}
conversionFactor={0.000621371}
/>,
);
We are using the user-event library from the Testing Library. Because of that, we are initializing the user event object before we do anything else. Note also that we have also declared the test’s function to be async so we can handle the asynchronous input.
The actions involve the numeric input, so we need to obtain that element using RTL’s query tools. We can use the website https://testing-playground.com to help us figure out good queries (amongst the many possibilities).
Add
screen.debug()after the element is rendered to the generated DOM to the console. Copy the printed DOM into https://testing-playground.com.Click on the “miles” input field to get suggested queries.
Notice the suggested query
getByRoleuses the ARIA role for the number input, made more precise by matching (with a regular expression for a case-insensitive substring match) the name of the associated label.screen.getByRole('spinbutton', { name: /miles/i });
Now, remove screen.debug() and insert the query and the “arrange” assertion on the initial value:
const input = screen.getByRole("spinbutton", { name: /miles/i });
expect(input).toHaveValue(0);
The “action” is to enter a distance. Add the following user call to update the input value.
await user.type(input, "1");
Consider the assertions. Note that the input in DistanceUnit is a controlled component (controlled by the valueInMeters prop). Thus the action won’t actually change the value. Instead it will trigger the callback. Add assertions that the value remains the same and the callback is invoked with the expected value (based on the conversion factor for miles to meters). For example:
expect(input).toHaveValue(0);
expect(setMetersMock).toHaveBeenCalledWith(
expect.closeTo(1 / 0.000621371, 2),
);
Comparing floating point values (i.e., fractional numbers) for equality is fraught. Differences in rounding can lead to slightly different values that compare as not equal but are effectively the same. As a result we want to compare floating points numbers within some error range. Here we use expect.toBeCloseTo(number, numDigits?)), a Vitest matcher for floating point values. It requires the input to be equal within numDigits after the decimal point.
Run the tests to verify they pass. Temporarily change the miles conversion factor in the component (i.e., break the component) to verifying that the tests are actually catching errors.
Testing DistanceConverter
We have a general pattern that we follow when writing tests around state changes.
- Assert that we are in the initial state
- Act to change the state
- Assert that we are in the new state
- Act to return to the original state
- Assert that we are in original state.
The first step is often overlooked, but important to establish that the system is “Arranged” in a known baseline state. Without that, we can’t know that a state change actually occurred. The last two steps are less important, but worth doing when the state is a form of binary toggle (less relevant here).
In the existing test for DistanceConverter add assertions to verify the initial state of the component. In this case, we expect all the units to have a value of 0 (based on the initial value of the meters state). Recall that we can select specific inputs by specifying the relevant “name” for the number input. Make sure you are testing all the different units (think about how you could use forEach to do so concisely). As a suggestion consider making an array of objects with the expected labels and conversion factors that you can use to implement your tests. For example:
const units = [
{ label: "meters", fromMeters: 1 },
{ label: "miles", fromMeters: 0.000621371 },
]Above we used a hard-coded regular expression literal (/meters/i) to match the button by label. But now we want to match based on a variable, e.g., label. To do so, we can create the regular expression dynamically. For example, assuming label is assigned "meters", the following
screen.getByRole("spinbutton", {
name: new RegExp(label, "i"),
});is equivalent to screen.getByRole("spinbutton", { name: /miles/i }).
The skeleton includes an “Act” step that updates the “meters” input to be 1. Add a corresponding set of assertions to verify the new state. One such assertion is included in the skeleton. Notice we extract the current value of the component, which is a string, convert it to a Number and compare the expected value within 2 decimal places.
Applying TDD for a new feature
Recall that in TDD, we first write the tests then the code. We want to add inches as a unit to the converter component (i.e., we should have miles, km, feet, meters and inches). Start by extending your tests to include an “inches” input. At this point the tests should fail since you have’t yet implemented the necessary code. Now extend the DistanceConverter component (in src/pages/index.tsx) to includes inches. At this point your tests should all pass!
Finishing Up
- Make sure the tests are passing (with
pnpm test) and there are no linting errors (withpnpm run lint) - Add and commit your changes and push those commit(s) to GitHub.
- Submit your repository to Gradescope
Requirements
- Smoke test is present
- Snapshot test is present
DistanceUnittest is completeDistanceConvertertest is complete- inches is added to
DistanceConverter - Pass all tests
- Pass all Biome checks
Recall that the Practical exercises are evaluated as “Satisfactory/Not yet satisfactory”. Your submission will need to implement all of the required functionality (i.e., pass all the tests) to be Satisfactory (2 points).
Footnotes
https://en.wikipedia.org/wiki/Smoke_testing_(software)↩︎