We are back in the Heroes component, and this time we have routing capabilities. On initial render, Heroes displays its child HeroList component. We need it to be able to display HeroDetail when clicking the + button of the ListHeader. Then we need HeroList displayed again when clicking the refresh button of the ListHeader. Cancel button should go back from HeroDetail to HeroList. Create a new branch feat/Heroes-part2
For the time being, instead of switching between HeroList and HerdoDetail depending on the route, we can display them both together. Let's write the test (Red 1).
// src/heroes/Heroes.cy.tsximport Heroes from"./Heroes";import { BrowserRouter } from"react-router-dom";import"../styles.scss";describe("Heroes", () => {it("should handle hero add and refresh", () => {cy.window().its("console").then((console) =>cy.spy(console,"log").as("log"));cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("list-header");cy.getByCy("add-button").click();cy.get("@log").should("have.been.calledWith","handleAdd");cy.getByCy("refresh-button").click();cy.get("@log").should("have.been.calledWith","handleRefresh"); });it.only("should display hero list on render", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("hero-list");cy.getByCy("hero-detail"); });constinvokeHeroDelete= () => {cy.getByCy("delete-button").first().click();cy.getByCy("modal-yes-no").should("be.visible"); };it("should go through the modal flow", () => {cy.window().its("console").then((console) =>cy.spy(console,"log").as("log"));cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("modal-yes-no").should("not.exist");cy.log("do not delete flow");invokeHeroDelete();cy.getByCy("button-no").click();cy.getByCy("modal-yes-no").should("not.exist");cy.log("delete flow");invokeHeroDelete();cy.getByCy("button-yes").click();cy.getByCy("modal-yes-no").should("not.exist");cy.get("@log").should("have.been.calledWith","handleDeleteFromModal"); });});
To pass the test, we add the HeroDetail near HeroList. It has a prop hero, which can temporarily be index 0 of the heroes array (Green 1).
We want to switch the displayed component when the route changes. What drives this in React is first the route, then the child component, as we saw in the react-router chapter. In a component test, on the initial mount there is no url, but when clicking a link the route changes. We can drive the test with the refresh and + buttons to check the route. We will use cy.location in this test to check for the pathname. Here is an excerpt from Gleb Bahmutov's Cypress tips:
cy.visit("https://example.cypress.io/commands/location?search=value#top");// yields a specific part of the locationcy.location("protocol").should("equal","https:");cy.location("hostname").should("equal","example.cypress.io");cy.location("pathname").should("equal","/commands/location");cy.location("search").should("equal","?search=value");cy.location("hash").should("equal","#top");
For brevity, we will keep the test code focused on the .only section. We write a test that checks that when clicking the refresh button the path becomes /heroes (Red 2).
// src/heroes/Heroes.cy.tsxit.only("should display hero list on render", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("hero-list");cy.getByCy("hero-detail");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes");});
The test fails, but in the console we see handleRefresh log. Instead of the log, we can have something that changes the url. React-router's useNavigate can be used for this purpose which lets us programmatically navigate to any url (Green 2).
Our pathnames are looking good, but what we need is rendering different components based on the route.
react-router descendant routes
On initial render we want to see HeroList.
On clicking add button of ListHeader, we want to see HeroDetail.
On clicking refresh button of ListHeader, we want to see HeroList again, so on and so forth.
We need some React Router v6 knowledge here. Remember our react-router setup in the top app component. We are concerned about /heroes route here. When the pathname is just /heroes we want to display HeroesList, when it is /heroes/addd-hero we want to display HeroDetail. That means /heroes will need a descendent route.
In react-router v6 we need a trailing * when there is another <Routes> somewhere in that route's descendant tree. In that case, the descendant <Routes> will match the portion of the pathname that remains. We need to modify our App.tsx file for the path="/heroes" prop to path="heroes/*". This will let the descendant Routes component we will be adding to take over the route control.
With that setup we have 2 failures; our test fails because it doesn't render anything, TS gives an error because HeroDetail wants to have a hero prop with a defined hero (Red 4).
We had setup the HeroDetail to be used in two conditions; render the heroId field if heroId exists or not. Therefore we should be able to use the component for adding a new hero. For now we can make the prop optional, and have a default hero object with empty id, name and description properties. Here is how HeroDetail should look for the time being:
It is great that HeroDetail.cy.tsx passes after that change. Our only concern is that we broke our Heroes test.
// src/heroes/Heroes.cy.tsxit.only("should display hero list on render", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("hero-list").should("be.visible");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes");});
We recall from react-router chapter that a component test has no idea about routes, and unless we click navigate in the test, the route is uncertain. This also justifies a test for an invalid heroes route, for example heroes/foo42. When such is the case, we are looking for a heroId that does not exist, we would like to view the HeroList. We need to add a new Route element that renders the HeroList with path being *.
Because the url is uncertain on component mount in a test, we also need to change the url verification to checking that HeroList renders (Green 4).
// src/heroes/Heroes.cy.tsxit.only("should display the hero list on render", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("hero-list").should("be.visible");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes");});
That change makes the test work, but the suite is not making cohesive sense between the first two it blocks. The first test that was checking for the console.log on handleAdd and handleRefresh is not valid, nor needed anymore, since we are changing the route with useNavigate. We could spy on useNavigate, but that is implementation detail and we are already checking that the url is changing; we are testing things in a better way, at a higher level, without extra cost. Here is the refactor to the test (Refactor 4):
// src/heroes/Heroes.cy.tsximport Heroes from"./Heroes";import { BrowserRouter } from"react-router-dom";import"../styles.scss";describe("Heroes", () => {it("should display the hero list on render, and go through hero add & refresh flow", () => {cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("list-header").should("be.visible");cy.getByCy("hero-list").should("be.visible");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes"); });constinvokeHeroDelete= () => {cy.getByCy("delete-button").first().click();cy.getByCy("modal-yes-no").should("be.visible"); };it("should go through the modal flow", () => {cy.window().its("console").then((console) =>cy.spy(console,"log").as("log"));cy.mount( <BrowserRouter> <Heroes /> </BrowserRouter> );cy.getByCy("modal-yes-no").should("not.exist");cy.log("do not delete flow");invokeHeroDelete();cy.getByCy("button-no").click();cy.getByCy("modal-yes-no").should("not.exist");cy.log("delete flow");invokeHeroDelete();cy.getByCy("button-yes").click();cy.getByCy("modal-yes-no").should("not.exist");cy.get("@log").should("have.been.calledWith","handleDeleteFromModal"); });});
In the react-router chapter, we concluded that the best way to test routing is with e2e tests. We are testing the pathnames in the component here, but we cannot test that the right child component is being rendered when the route changes. We can start the e2e test covering a similar flow, which will also serve as a larger test that covers the CRUD hero flow in the future. When there is functionality that we cannot test, or cannot test confidently at a lower level, we move up in the test pyramid, in this case from a component test to an e2e test. Start the e2e runner with yarn cy:open-e2e. Create a new e2e test cypress/e2e/create-hero.cy.ts.
// cypress/e2e/create-hero.cy.tsdescribe("Create hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the refresh flow", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("not.exist");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); });});
This test enables us to check that HeroDetail renders on add, and that it renders without the id field since this is a new hero. The refresh create hero flow gives us a new idea; whether the backend is operational or not, the cancel flow for hero edit or add should also work. After having reached a certain milestone, e2e tests, or simply ad-hoc usage of the app can often give us new ideas for features. This is the scientific method after all, we know more and now we can try for more, and that in essence captures the original mindset behind TDD, as well as agile.
Let's add a failing e2e test for edit hero cancel flow (Red 5). Create a file cypress/e2e/edit-hero.cy.ts. It starts similarly to the add flow, but instead clicks the Edit button and expects to be in a relevant route.
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroAslaug"); });});
When it was not certain what to do with click handlers in our app, we started them off with console.log. In the console of the e2e test we can see handleSelectHero. This function resides in HeroList component. We just need to enhance it to utilize useNavigate like we did so in the parent Heroes component (Green 5).
We can navigate to the first hero, but can we navigate to another and end up on the right url? Let's write a test for it (Red 6).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroAslaug"); });it("should go through the cancel flow for another hero", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroBjorn"); });});
The test fails, because react-router needs a way to know the route parameter. We need to be able to do something better than a hardcoded heroId navigation. When we are editing the hero, we should be able to acquire that heroId from the heroes prop that gets passed to this component. handleSelectHero should take the id as an argument, and nav to it.
Let us enhance the test and check that when were are editing a hero, not only we have the right url path, but also we display the HeroDetail (Red 7).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroAslaug");cy.location("pathname").should("include","/heroes/edit-hero/");cy.getByCy("hero-detail").should("be.visible"); });it("should go through the cancel flow for another hero", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroBjorn");cy.location("pathname").should("include","/heroes/edit-hero/");cy.getByCy("hero-detail").should("be.visible"); });});
We need a way to extract heroId in the path and let the component know about it. In react-router we can take advantage of path attributes and the useParam hook. Here is a simple example showing how path attributes work. Assume that our data is milkshake and the data model looks as such:
To replicate that configuration, our edit-hero path needs a path attribute of id, and we need a way to extract that path attribute from the url. React-router's useParam returns an object with properties corresponding to URL parameters.
const { flavor,size } =useParams();
Mirroring that information to our app, the data looks as such:
The test is passing, we have the right url with the heroId, we are displaying HeroDetails, but the heroId field is not being displayed.
We write one more line of a test to ensure that the heroId field is visible when we are editing a hero (Red 8).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroAslaug");cy.location("pathname").should("include","/heroes/edit-hero/");cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible"); });it("should go through the cancel flow for another hero", () => {cy.location("pathname").should("eq","/heroes");cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("eq","/heroes/edit-hero/HeroBjorn");cy.location("pathname").should("include","/heroes/edit-hero/");cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible"); });});
In order to use the path attribute, destructure the id out of useParams() with const { id } = useParams(), this is what binds the route setup to the component. Instead of relying on the hero data, we want to rely on the path attribute that we get from the url, and useParams is the hook for that. We also have side benefit of being able to directly navigate to a url (Green 8).
No matter the edited hero, the id field displays with the value of the path attribute.
If we can get the id of the hero from the url, why should we not be able to get name and description as well? Let's enhance the tests to check that name and description fields are also populated (Red 9). We can fake the data by using the fixtures/heroes.json file. We want to verify that the data for name and description are displayed in the fields (Red 9).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(0).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[0].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[0].id);cy.findByDisplayValue(heroes[0].name);cy.findByDisplayValue(heroes[0].description); }); });it("should go through the cancel flow for another hero", () => {cy.location("pathname").should("eq","/heroes");cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[1].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[1].id);cy.findByDisplayValue(heroes[1].name);cy.findByDisplayValue(heroes[1].description); }); });});
HeroList component knows about all the heroes, and clicking on the Edit can take us to the relevant hero. Previously we expressed this with:
We could add more route parameters by modifying Heroes route path like so : <Route *path*="/edit-hero/:id/:name/:description" *element*={<HeroDetail />} />.
However, it would be better if we used search parameters and not have to change the react-router setup. Here is how handleSelectHero would look with search parameters:
Given the heroes array which gets passed as a prop to the component, we need a way to extract the hero.name and hero.description from a heroId, Array.find method could get us the hero we need :
The test is still failing, but upon clicking Edit on HeroList, now HeroDetail has all the relevant data in the url.
Now we need a way to extract the search parameters from the url when looking at HeroDetail, so that we can grab all the hero state (id, name, description) from the url.
When we hit Cancel on HeroDetails, we should have the HeroList display. Here is our failing test (Red 10).
// cypress/e2e/edit-hero.cy.tsdescribe("Edit hero", () => {beforeEach(() =>cy.visit("/"));it("should go through the cancel flow", () => {cy.location("pathname").should("eq","/heroes");cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(0).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[0].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[0].id);cy.findByDisplayValue(heroes[0].name);cy.findByDisplayValue(heroes[0].description);cy.getByCy("cancel-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); }); });it("should go through the cancel flow for another hero", () => {cy.location("pathname").should("eq","/heroes");cy.fixture("heroes").then((heroes) => {cy.getByCy("edit-button").eq(1).click();cy.location("pathname").should("include",`/heroes/edit-hero/${heroes[1].id}` );cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("be.visible");cy.findByDisplayValue(heroes[1].id);cy.findByDisplayValue(heroes[1].name);cy.findByDisplayValue(heroes[1].description);cy.getByCy("cancel-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); }); });});
If we check the console, we see that handleCancel is called. That function lives in HeroDetail component as well. We can once again utilize useNavigate to change the url to /heroes on clicking cancel (Green 10).
The cancel flow in edit-hero e2e test also applies to the add-hero flow. We can add a test to add-hero without duplicating the checks in refresh flow, and by using direct navigation instead of click navigation (Refactor 10).
// cypress/e2e/create-hero.cy.tsdescribe("Create hero", () => {it("should go through the refresh flow", () => {cy.visit("/");cy.location("pathname").should("eq","/heroes");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("hero-detail").should("be.visible");cy.getByCy("input-detail-id").should("not.exist");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); });it("should go through the cancel flow and perform direct navigation", () => {cy.visit("/heroes/add-hero");cy.getByCy("cancel-button").click();cy.location("pathname").should("eq","/heroes");cy.getByCy("hero-list").should("be.visible"); });});
Final refactors
Having a look at the HeroDetail component, we are getting all the data we need from the url, utilizing useParams and useSearchParams. We do not need any data to be passed as a prop anymore, since we have the id, name and description. They could get initialized in useState. Here is the refactor:
We get a type error because HeroDetail component tests are still passing in the hero prop. Looking at HeroDetail.cy.tsx and HeroList.cy.tsx component tests with, we also get an error about useNavigate needing to be used under a <Router> component. In src/heroes/HeroList.cy.tsx we wrap the mounts in BrowserRouter. We remove the console.log, and now can actually assert the url when edit-button is clicked, which should be /heroes/edit-hero/<someHeroId>.
We also realize an anti-pattern; we have been spying on useState when typing hero name and description into the fields. This is an implementation detail, and if we change state management, the test would need maintenance. We could lean more towards black box to avoid testing an implementation detail and have better confidence about how the component should work. Here is the updated test:
This is a nice use case for currying. The outer function can take our custom arg and returns a function that accepts the event. We can refactor HeroList like so:
Create a new file src/hooks/useHeroParams.ts and move the code to the hook. The only difference is that we are returning an object with what we need out of this hook.
import { useSearchParams } from "react-router-dom";
export function useHeroParams() {
const [searchParams] = useSearchParams();
const name = searchParams.get("name");
const description = searchParams.get("description");
return { name, description };
}
Import the hook, remove the useSearchParams import, and replace the 3 lines with a one-liner const {name, description} = useHeroParams():
We added a failing test to render HeroDetail together with HeroList (Red 1), and added the two components to the Heroes (Green 1).
We wrote a test that scrutinizes the url when clicking the refresh button on ListHeader (Red 2).
We used react-router's useNavigate to programmatically navigate to the url we need upon clicking refresh (Green 2).
We added a similar test for the add button, scrutinizing the url, again utilizing useNavigate (Red 3, Green 3).
We configured react-router for descendant routes, updated TS and the test to ensure everything still works (Red 4, Green 4). We refactored the test to be more cohesive and meaningful (Refactor 4).
Recalling react-router chapter that routing is best tested with e2e, we added an e2e test to increase confidence in our component; verify more than the pathname, that the child components render a certain way. Our e2e test for create hero cancel flow worked great.
Next, we saw a similarity in the edit hero cancel flow and added a failing e2e test for it When the edit button is clicked, we wanted to land on a route such as /heroes/edit-hero/HeroAslaug(Red 5)
We utilized useNavigate in the already existing handleSelectHero function of HeroList component, hard coding the same url (Green 5).
We added a test to check if we can edit any hero and end up at the correct route (Red 6).
We removed the hard coded /HeroAslaug from the navigate at HeroList, instead made handleSelectHeroa function driven by aheroId` argument (Green 6).
We enhanced the test to check that HeroDetail is displayed in addition to being at the correct route (Red 7)
We made use of route parameter :id at Heroes edit-hero route. Once the :id was matching the heroId argument of handleSelectHero, we had a passing test (Green 7).
We enhanced the test for conditional rendering of the id field; if there is a heroId, there should be a field (Red 8).
We made use of useParams and matched the :id router parameter by using const {id} = useParams() at HeroDetail. In the conditional rendering we used id instead of hero.id (Green 8).
We enhanced the test by checking that name and description are displayed in HeroDetail when editing a hero (Red 9).
We decided to use search params in HeroListhandleSelectHero instead of adding additional routes. useSearchParams helped grab the search parameters from the url (Green 9)
We added enhanced the test to check that cancel button lands us on HeroList component from HeroDetails (Red 10).
We enhanced HeroDetail to utilize useNavigate to /heroes route when cancel is clicked (Green 10).
We also added a test to the add hero flow, since the HeroDetails to HeroList navigation on Cancel is the same (Refactor 10).
Takeaways
It is ideal to test at higher level than implementation details, without extra costs. For example, we can test the consequences of the hooks vs if the hook is called; useNavigate & check url instead of spying on useNavigate.
When there is functionality that we cannot test, or cannot test confidently at a lower level, we move up in the test pyramid.
After having reached a certain milestone, e2e tests, or simply ad-hoc usage of the app can often give us a higher level perspective, with new ideas for features. This is the scientific method after all, we know more and now we can try for more, and that in essence captures the original mindset behind TDD, as well as agile.
In TDD, it is encouraged to use hard coded values to make the tests pass.
In component tests, we can lean more towards black box to avoid testing implementation details and have better confidence about how the component should work.
We can use currying to refactor event handlers : onClick={() => handleSelectHero(hero.id)} vs onClick={handleSelectHero(hero.id)}.
We can use custom hooks to abstract away some of the logic in components.