ch10-HeroDetail

We are done with the simpler, child components. From now on we will be focusing on higher level components that use the child components and consider application state.
In the Angular version of the app, we see a component slightly more involved than the others so far. When a component is looking complicated, it is easier to make sense out of it starting at the top level, and moving down layer by layer.
  • header
    • p with the hero name.
  • div/div
    • 3 fields using the InputDetail component. The first is readonly.
  • footer
    • 2 footers using the ButtonFooter component; a Cancel and a Save variant.
Test driven design, engineering and the scientific method are all bound together; breaking the problem down into smaller parts, verifying our progress via tests through short feedback cycles and iterating quickly is common in all these disciplines. What makes Cypress component testing a good fit is the quality and the speed of the feedback cycles. We are developing the front-end, and we are engineering the component with the lights on.
HeroDetail-initial
Create a branch feat/HeroDetail. Create 2 files under src/heroes/ folder; HeroDetail.cy.tsx, HeroDetail.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/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
});
});
// src/heroes/HeroDetail.tsx
export default function HeroDetail() {
return <div>hello</div>;
}
Looking at the component high level layers, we can begin to write a failing test with the outline (Red 1).
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
cy.getByCy("hero-detail").contains("my hero");
cy.contains("header", "my hero");
});
});
We start with the minimal requirement for now; all we need to pass this test is a data-cy attribute and header tag which contains a hard coded value (Green 1).
// src/heroes/HeroDetail.tsx
export default function HeroDetail() {
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
</div>
);
}

The 3 form fields

The next tags are the 3 fields for InputDetail components, which will constitute the forms fields. Let's check that their length should be 3 (Red 2).
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
cy.getByCy("hero-detail").contains("my hero");
cy.contains("header", "my hero");
cy.getByCyLike("input-detail").should("have.length", 3);
});
});
We add the InputDetail components under two layers of divs, and get a passing test (Green 2).
// src/heroes/HeroDetail.tsx
import InputDetail from "../components/InputDetail";
export default function HeroDetail() {
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
<div>
<div>
<InputDetail></InputDetail>
<InputDetail></InputDetail>
<InputDetail></InputDetail>
</div>
</div>
</div>
);
}
HeroDetail-Green2
TS is helping us out, notifying that InputDetail component should come with some props. If we use the compiler and auto-fix it, it adds the mandatory props (Red 3, Green 3).
// src/heroes/HeroDetail.tsx
import InputDetail from "../components/InputDetail";
export default function HeroDetail() {
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
<div>
<div>
<InputDetail name={""} value={""}></InputDetail>
<InputDetail name={""} value={""}></InputDetail>
<InputDetail name={""} value={""}></InputDetail>
</div>
</div>
</div>
);
}
We can take a look at the specification to begin enhancing our test. We know that the first InputDetail field will be readonly. The two writable fields should have placeholder texts. Testing Library examples include two helpful commands to check for text in form fields; findByDisplayValue, findByPlaceholderText (Red 4).
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
cy.getByCy("hero-detail").contains("my hero");
cy.contains("header", "my hero");
cy.getByCyLike("input-detail").should("have.length", 3);
cy.findByDisplayValue("HeroAslaug").should("be.visible");
cy.findByPlaceholderText("e.g. Colleen").should("be.visible");
cy.findByPlaceholderText("e.g. dance fight!").should("be.visible");
});
});
We need to make the test pass. We can grab the field names from the initial application screen shot; the names of the fields should be id, name, description. The value of the first field id can be hard-coded for now. The second and third fields have an empty value, and placeholder instead. This is the minimal to get a green test (Green 4).
// src/heroes/HeroDetail.tsx
import InputDetail from "../components/InputDetail";
export default function HeroDetail() {
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
<div>
<div>
<InputDetail name={"id"} value={"HeroAslaug"}></InputDetail>
<InputDetail
name={"name"}
value=""
placeholder={"e.g. Colleen"}
></InputDetail>
<InputDetail
name={"description"}
value=""
placeholder={"e.g. dance fight!"}
></InputDetail>
</div>
</div>
</div>
);
}
Before we start the refactor, it is worthwhile to talk about the shape of the hero data at this point. We will be reading id (readonly) from the network, and we will be writing name and description to the network. Our data is an object with 3 string properties, such as:
{
"id": "HeroAslaug",
"name": "Aslaug",
"description": "warrior queen"
}
Let's create an interface with that shape, because we will be using it everywhere. Create a folder and file ./src/models/Hero.ts and paste the following code:
// /src/models/Hero.ts
export interface Hero {
id: string;
name: string;
description: string;
}
In our component, placeholder texts are okay being hard-coded, but the value props stick out. This gives the hint for a need of state in our app. For now we can hard-code it into the component (Refactor 4).
// src/heroes/HeroDetail.tsx
import { Hero } from "../models/Hero";
import InputDetail from "../components/InputDetail";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
<div>
<div>
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
</div>
);
}
We can take advantage of TypeScript paths to avoid needing too many ../ folder references. Ensure that src/tsconfig.json is as such:
{
"compilerOptions": {
"target": "esnext",
"lib": ["esnext", "dom"],
"types": ["cypress", "node", "@testing-library/cypress"],
"baseUrl": "./"
},
"include": ["**/*.ts*", "../cypress.d.ts"],
"extends": "../tsconfig.json"
}
Now we can import files from components, models, and in the future hooks in an easier way.
// src/heroes/HeroDetail.tsx
import { Hero } from "models/Hero";
import InputDetail from "components/InputDetail";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>my hero</p>
</header>
<div>
<div>
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
</div>
);
}
We are still hard coding "my-hero" into the test and the component. This is obviously hero.name, and in the application screen shot it is not even displayed. We need a mechanism to display it whether the network data exists or not. Since the data is hard coded into the component, we will not be able to control it with the tests for now, so we can disable the text checks with contains('my hero') for the time being and work on the component.
Our test and component are looking like so at this time:
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
cy.getByCy("hero-detail");
cy.getByCyLike("input-detail").should("have.length", 3);
cy.findByDisplayValue("HeroAslaug").should("be.visible");
cy.findByPlaceholderText("e.g. Colleen").should("be.visible");
cy.findByPlaceholderText("e.g. dance fight!").should("be.visible");
});
});
// src/heroes/HeroDetail.tsx
import { Hero } from "models/Hero";
import InputDetail from "components/InputDetail";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
</div>
);
}
If we enter a string for hero.name we can toggle the p in the component test runner. We need a similar logic for the id field, because if the data does not exist for id, then it does not make sense to display it. We can achieve this with conditional rendering.
// src/heroes/HeroDetail.tsx
import { Hero } from "models/Hero";
import InputDetail from "components/InputDetail";
export default function HeroDetail() {
const hero: Hero = {
id: "",
name: "",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
</div>
);
}
Toggle the hero.id value, and the field should also be toggled. We need to tweak the test to be okay with 2 fields or more for now (Refactor 4).
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should", () => {
cy.mount(<HeroDetail />);
// cy.getByCy('hero-detail').contains('my hero')
// cy.contains('header', 'my hero')
// cy.getByCyLike('input-detail').should('have.length', 3)
cy.getByCy("hero-detail");
cy.getByCyLike("input-detail").should("have.length.gte", 2);
// cy.findByDisplayValue('HeroAslaug').should('be.visible')
cy.findByPlaceholderText("e.g. Colleen").should("be.visible");
cy.findByPlaceholderText("e.g. dance fight!").should("be.visible");
});
});
When there is no id, the id field will be disabled vice versa. When there is a name, p will show vice versa. These are all test cases we will cover later. It is important to note that while we cannot use the tests to verify the design we need, the fact that the component test is a mini UI application is helping us out for the time being.
HeroDetail-Refactor4
HeroDetail-Refactor4.1
We will delay the decisions about state until after we have the full UI layout.
The footer consists of a footer tag wrapping 2 ButtonFooter components; buttons for Cancel and Save. Let's write a failing test for it. We are looking at ButtonFooter component with the relevant selector; save-button, `cancel-button (Red 5)
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should verify the layout of the component", () => {
cy.mount(<HeroDetail />);
// cy.getByCy('hero-detail').contains('my hero')
// cy.contains('header', 'my hero')
// cy.getByCyLike('input-detail').should('have.length', 3)
cy.getByCy("hero-detail");
cy.getByCyLike("input-detail").should("have.length.gte", 2);
// cy.findByDisplayValue('HeroAslaug').should('be.visible')
cy.findByPlaceholderText("e.g. Colleen").should("be.visible");
cy.findByPlaceholderText("e.g. dance fight!").should("be.visible");
cy.getByCy("save-button").should("be.visible");
cy.getByCy("cancel-button").should("be.visible");
});
});
Adding the ButtonFooter child components, we get TS errors as well as a failing test (Red 5).
// src/heroes/HeroDetail.tsx
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
<footer>
<ButtonFooter />
<ButtonFooter />
</footer>
</div>
);
}
ButtonFooter props are label, IconClass and onClick. TS as well as the component test we wrote for it, /components/ButtonFooter.cy.tsx serve as documentation. Let's add the missing props looking at that component test. For now, the click handlers can be empty functions. We can grab the icons from react-icons. The label can be any string (Green 5).
// src/heroes/HeroDetail.tsx
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import { FaUndo, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
<footer>
<ButtonFooter label="Cancel" IconClass={FaUndo} onClick={() => {}} />
<ButtonFooter label="Save" IconClass={FaRegSave} onClick={() => {}} />
</footer>
</div>
);
}
HeroDetail-Green5
When saving or cancelling this form, we will be modifying the state, therefore an event should occur. We can write failing tests that spies on console.log for now (Red 6).
// src/heroes/HeroDetail.cy.tsx
import HeroDetail from "./HeroDetail";
import "../styles.scss";
describe("HeroDetail", () => {
it("should verify the layout of the component", () => {
cy.mount(<HeroDetail />);
// cy.getByCy('hero-detail').contains('my hero')
// cy.contains('header', 'my hero')
// cy.getByCyLike('input-detail').should('have.length', 3)
cy.getByCy("hero-detail");
cy.getByCyLike("input-detail").should("have.length.gte", 2);
// cy.findByDisplayValue('HeroAslaug').should('be.visible')
cy.findByPlaceholderText("e.g. Colleen").should("be.visible");
cy.findByPlaceholderText("e.g. dance fight!").should("be.visible");
cy.getByCy("save-button").should("be.visible");
cy.getByCy("cancel-button").should("be.visible");
});
it("should handle Save", () => {
cy.mount(<HeroDetail />);
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.getByCy("save-button").click();
cy.get("@log").should("have.been.calledWith", "handleSave");
});
it("should handle Cancel", () => {
cy.mount(<HeroDetail />);
cy.window()
.its("console")
.then((console) => cy.spy(console, "log").as("log"));
cy.getByCy("cancel-button").click();
cy.get("@log").should("have.been.calledWith", "handleCancel");
});
});
We can make the test pass by adding console.logs for the handlers (Green 6).
// src/heroes/HeroDetail.tsx
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import { FaUndo, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "",
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
<footer>
<ButtonFooter
label="Cancel"
IconClass={FaUndo}
onClick={() => {
console.log("handleCancel");
}}
/>
<ButtonFooter
label="Save"
IconClass={FaRegSave}
onClick={() => {
console.log("handleSave");
}}
/>
</footer>
</div>
);
}
We can refactor the console.logs into helper functions (Refactor 6)
// src/heroes/HeroDetail.tsx
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import { FaUndo, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "",
};
const handleCancel = () => console.log("handleCancel");
const handleSave = () => console.log("handleSave");
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value={hero.name}
placeholder="e.g. Colleen"
></InputDetail>
<InputDetail
name={"description"}
value={hero.description}
placeholder="e.g. dance fight!"
></InputDetail>
</div>
</div>
<footer>
<ButtonFooter
label="Cancel"
IconClass={FaUndo}
onClick={handleCancel}
/>
<ButtonFooter label="Save" IconClass={FaRegSave} onClick={handleSave} />
</footer>
</div>
);
}
When we save a hero, we will either be creating or updating a hero. If there is no hero.name we should be creating it. If there is a hero.name we should be updating the hero. Let's create functions for update and save, and enhance handleSave with logic. To test it, for now we cantoggle the name property, and see the console toggle between updateHero and createHero when handleSave is invoked.
// src/heroes/HeroDetail.tsx
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import { FaUndo, FaRegSave } from "react-icons/fa";
import { Hero } from "models/Hero";
export default function HeroDetail() {
const hero: Hero = {
id: "HeroAslaug",
name: "Aslaug",
description: "",
};
const handleCancel = () => console.log("handleCancel");
const updateHero = () => console.log("updateHero");
const createHero = () => console.log("createHero");
const handleSave = () => {
console.log("handleSave");
return hero.name ? updateHero() : createHero();
};
return (
<div data-cy="hero-detail">
<header>
<p>{hero.name}</p>
</header>
<div>
<div>
{hero.id && (
<InputDetail
name={"id"}
value={hero.id}
readOnly={true}
></InputDetail>
)}
<InputDetail
name={"name"}
value=