ch11-HeroList
HeroList
is the second of our more complex components, parent components. In the Angular version of the app, we see a list of heroes. Each item in the list is a div
that wraps our CardContent
component and two ButtonFooter
components for editing or deleting the list item.
HeroList-initial
Create a branch
feat/HeroList
. Create 2 files under src/heroes/
folder; HeroList.cy.tsx
, HeroList.tsx
. As usual, start minimal with a component rendering; copy the below to the files and execute the test after opening the runner with yarn cy:open-ct
.// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
describe("HeroList", () => {
it("should", () => {
cy.mount(<HeroList />);
});
});
// src/heroes/HeroList.tsx
export default function HeroList() {
return <div>hello</div>;
}
When creating a list component in React, it is easier to start with one item at first, and then build up to the list. We will start with the
div
. Here is the outline we wish for:- div
CardContent
footer
- 2 x
ButtonFooter
We start with a test for
CardContent
render (Red 1).// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
describe("HeroList", () => {
it("should", () => {
cy.mount(<HeroList />);
cy.getByCy("card-content");
});
});
After adding the child component, the test passes but we get a compiler warning about missing props name and description from
CardContent
. For now we can add the props with empty strings.// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
export default function HeroList() {
return (
<div>
<CardContent name="" description="" />
</div>
);
}
Now we can add a failing test checking for the prop values (Red 2).
// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
describe("HeroList", () => {
it("should", () => {
cy.mount(<HeroList />);
cy.getByCy("card-content");
cy.contains("Aslaug");
cy.contains("warrior queen");
});
});
And we can add the hard-coded prop values to the component to make the test pass (Green 2).
// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
export default function HeroList() {
return (
<div>
<CardContent name="Aslaug" description="warrior queen" />
</div>
);
}
We get a hint from the usage that
hero
is a piece of data that we get from the network. For now we can create a hero
object and copy it to both the test and the component (Refactor 2).// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import { Hero } from "models/Hero";
import "../styles.scss";
describe("HeroList", () => {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
it("should", () => {
cy.mount(<HeroList />);
cy.getByCy("card-content");
cy.contains(hero.name);
cy.contains(hero.description);
});
});
// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
return (
<div>
<CardContent name={hero.name} description={hero.description} />
</div>
);
}

HeroList-Refactor2
We can create a new test that checks for the render of
cancel
and edit
buttons, which are variants of the ButtonFooter
component. Checking for the footer
and making sure that the buttons are inside it is optional; we could just check for the components instead. Testing is always a call between cost and confidence, and how much we test depends. In this case "Will the footer
tag ever change," "Is it a high amount of work to use the within
api?" "How much more confidence do we get by testing this detail" are some of the questions that can determine our decision (Red 3).// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import { Hero } from "models/Hero";
import "../styles.scss";
describe("HeroList", () => {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
it("should", () => {
cy.mount(<HeroList />);
cy.getByCy("card-content");
cy.contains(hero.name);
cy.contains(hero.description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
});
As we add the
ButtonFooter
child to the component, we get a compiler warning about missing props, as well as a failing test (Red3).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
return (
<div>
<CardContent name={hero.name} description={hero.description} />
<footer>
<ButtonFooter />
<ButtonFooter />
</footer>
</div>
);
}
We can take advantage of the
ButtonFooter
types to add the missing props to pass the test. For now we can leave the onClick
values empty (Green 3).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
return (
<div>
<CardContent name={hero.name} description={hero.description} />
<footer>
<ButtonFooter label="Delete" IconClass={FaRegSave} onClick={""} />
<ButtonFooter label="Edit" IconClass={FaEdit} onClick={""} />
</footer>
</div>
);
}

HeroList-Green3
In the TDD mindset, when we have green tests, we want to prefer adding more tests or refactoring versus adding additional source code. Let's write a failing test for handling the delete and select hero events. Similar to the test
heroes/HeroDetail.cy.tsx
for now we can spy on a console.log
to ensure that something happens when the button is clicked. We can use the beforeEach
hook and a context
block like we did so in the previous chapters (Red 4).// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import { Hero } from "models/Hero";
import "../styles.scss";
describe("HeroList", () => {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
it("should render the item layout", () => {
cy.mount(<HeroList />);
cy.getByCy("card-content");
cy.contains(hero.name);
cy.contains(hero.description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
context("handleDeleteHero, handleSelectHero", () => {
beforeEach(() => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(<HeroList />);
});
it("should handle delete", () => {
cy.getByCy("delete-button").click();
cy.get("@log").should("have.been.calledWith", "handleDeleteHero");
});
it("should handle edit", () => {
cy.getByCy("edit-button").click();
cy.get("@log").should("have.been.calledWith", "handleSelectHero");
});
});
});
All we need to make the test pass is fill in functions that return console.logs with the respective arguments
handleDeleteHero
& handleSelectHero
(Green 4).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
return (
<div>
<CardContent name={hero.name} description={hero.description} />
<footer>
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={() => console.log("handleDeleteHero")}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={() => console.log("handleSelectHero")}
/>
</footer>
</div>
);
}
Now is a good time to refactor the
onClick
events into functions and add styles (Refactor 4).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
);
}
Wrap the top
div
in a ul
and li
. The component still renders. What we need is a list / array of data that we can map over.// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroList() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul>
{/* need some data here, an array of objects */}
<li>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
</li>
</ul>
);
}
Remember the statement we made about data while developing the
HeroDetail
component; "Instead of the hard coded hero
object in the component, we can pass in data with a prop. We either manipulate our components via props or what wraps them, and a prop is the easier choice at the moment." We can stay consistent with that approach and pass a prop to the component; an array of 2 hero
objects.We start by modifying the test. Instead of one
hero
object, we have a heroes
array of 2 hero
objects. To check the string values for name
and description
, we refer to heroes[0]
instead of hero
. The test still passes but have a TS error because the prop heroes
does not exist yet in the component (Red 5).// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import { Hero } from "models/Hero";
import "../styles.scss";
describe("HeroList", () => {
const heroes: Hero[] = [
{
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
},
{
id: "HeroBjorn",
name: "Bjorn Ironside",
description: "king of 9th century Sweden",
},
];
it("should render the item layout", () => {
cy.mount(<HeroList heroes={heroes} />);
cy.getByCy("card-content");
cy.contains(heroes[0].name);
cy.contains(heroes[0].description);
cy.get("footer").first().within(()
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
context("handleDelete, handleEdit", () => {
beforeEach(() => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(<HeroList heroes={heroes} />);
});
it("should handle delete", () => {
cy.getByCy("delete-button").click();
cy.get("@log").should("have.been.calledWith", "handleDeleteHero");
});
it("should handle edit", () => {
cy.getByCy("edit-button").click();
cy.get("@log").should("have.been.calledWith", "handleSelectHero");
});
});
});
We pass the
heroes
array as a prop, and our prop type has to be an array of heroes (Green 5).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
type HeroListProps = {
heroes: Hero[];
};
export default function HeroList({ heroes }: HeroListProps) {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "warrior queen",
};
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul>
{/* an array of objects */}
<li>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
</li>
</ul>
);
}
We have addressed the compiler error, but we are still using the hard coded
hero
object in the component and not using the data passed to the component with the heroes
prop. Removing the hero
object fails the test and gives compiler errors for the usage of hero.name
and hero.description
(Red 6).To address the failures temporarily, we use
heroes[0]
instead of the hero
reference (Green 6).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
type HeroListProps = {
heroes: Hero[];
};
export default function HeroList({ heroes }: HeroListProps) {
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul>
{/* an array of objects */}
<li>
<div className="card">
<CardContent
name={heroes[0].name}
description={heroes[0].description}
/>
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
</li>
</ul>
);
}
This begs the question; how do we display multiple list items? Do we have to copy paste the entire
li
and reference heroes[1]
in it? This works (try it out) but we all know that is not good because it is not DRY and it does not scale.What we need is to render a list in a smarter way. In React, similar to JS, we do this by mapping over the array / the data. The only difference is the need to use JSX notation to wrap the
li
with syntax. Think of these {
}
like a template literal ${ }
without the dollar sign. Now instead of referencing array indexes, we can reference what the map yields; a single hero
that maps to each index of the array. If map
is confusing, think of it like a better version of forEach
that returns and does not mutate, but creates a new array (Refactor 6).// src/heroes/HeroList.tsx
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
type HeroListProps = {
heroes: Hero[];
};
export default function HeroList({ heroes }: HeroListProps) {
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul>
{heroes.map((hero) => (
<li>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteHero}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectHero}
/>
</footer>
</div>
</li>
))}
</ul>
);
}
That was a good refactor, the render is looking good but the test is failing for
Delete
and Edit
clicks because now there are multiples of them we can make a quick tweak to use the first()
instance on the list when clicking. We can also move the data out to a Cypress fixture, a json file under ./cypress/fixtures
. Create the file ./cypress/fixtures/heroes.json
and paste the below content to it.[
{
"id": "HeroAslaug",
"name": "Aslaug",
"description": "warrior queen"
},
{
"id": "HeroBjorn",
"name": "Bjorn Ironside",
"description": "king of 9th century Sweden"
},
{
"id": "HeroIvar",
"name": "Ivar the Boneless",
"description": "commander of the Great Heathen Army"
},
{
"id": "HeroLagertha",
"name": "Lagertha the Shieldmaiden",
"description": "aka Hlaðgerðr"
},
{
"id": "HeroRagnar",
"name": "Ragnar Lothbrok",
"description": "aka Ragnar Sigurdsson"
},
{
"id": "HeroThora",
"name": "Thora Town-hart",
"description": "daughter of Earl Herrauðr of Götaland"
}
]
We can refactor the test to use this data instead (Refactor 6).
// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
import heroes from "../../cypress/fixtures/heroes.json";
describe("HeroList", () => {
it("should render the item layout", () => {
cy.mount(<HeroList heroes={heroes} />);
cy.getByCy("card-content");
cy.contains(heroes[0].name);
cy.contains(heroes[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
context("handleDelete, handleEdit", () => {
beforeEach(() => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(<HeroList heroes={heroes} />);
});
it("should handle delete", () => {
cy.getByCy("delete-button").first().click();
cy.get("@log").should("have.been.calledWith", "handleDeleteHero");
});
it("should handle edit", () => {
cy.getByCy("edit-button").first().click();
cy.get("@log").should("have.been.calledWith", "handleSelectHero");
});
});
});

HeroList-Refactor5
Let's add a new test that verifies that the length of the list is as long as the length of the data (Red 7)
// src/heroes/HeroList.cy.tsx
import HeroList from "./HeroList";
import "../styles.scss";
import heroes from "../../cypress/fixtures/heroes.json";
describe("HeroList", () => {
it("should render the item layout", () => {
cy.mount(<HeroList heroes={heroes} />);
cy.getByCyLike("hero-list-item").should("have.length", heroes.length);
cy.getByCy("card-content");
cy.contains(heroes[0].name);
cy.contains(heroes[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
context("handleDelete, handleEdit", () => {
beforeEach(() => {
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.mount(<HeroList heroes={heroes} />);
});
it("should handleDeleteHero", () => {
cy.getByCy("delete-button").first().click();
cy.get("@log").should("have.been.calledWith", "handleDeleteHero");
});
it("should handleSelectHero", () => {
cy.getByCy("edit-button").first().click();
cy.get("@log").should("have.been.calledWith", "handleSelectHero");
});
});
});
While adding the
data-cy
attribute to the component, we will also cover an important topic about the key
attribute.JS'
map
takes a second argument index
. We can utilize this with a template literal and be able to use data-cy
selectors on nth
item in the list like so :// component
{heroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} >
}
// component test
cy.getByCy(`hero-list-item-2`);
When React is (re)rendering a list, the
key
attribute is used to determine which list items have changed. Per the docs the recommended way is to use a unique value for the key as opposed to the index, because using the index can negatively impact performance.// not preferred
{heroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} key={index}>
}
// preferred, because hero.id is always unique
{heroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} key={hero.id}>
}
We will modify the component with a
data-cy
attribute to pass the test, and add a key
attribute with a value that will always be unique (Green 7).import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
type HeroListProps = {
heroes: Hero[];
};
export default function HeroList({ heroes }: HeroListProps) {
const handleDeleteHero = () => console.log("handleDeleteHero");
const handleSelectHero = () => console.log("handleSelectHero");
return (
<ul data-cy="hero-list" className="list">
{heroes.map((hero, index) => (
<li data-cy={`hero-list-item-${index}`} key={hero.id}>
<div className="card">
<CardContent name={hero.name} description={hero.description} />
<footer className="card-footer">