Prerequisite
Create new interfaces and types that will be utilized throughout the hero and villain groups of components.
// src/models/Boy.ts
export interface Boy {
id: string;
name: string;
description: string;
}
/* istanbul ignore file */
// src/models/types.ts
import { Hero } from "./Hero";
import { Villain } from "./Villain";
import { Boy } from "./Boy";
export type HeroProperty = Hero["name"] | Hero["description"] | Hero["id"];
export type VillainProperty =
| Villain["name"]
| Villain["description"]
| Villain["id"];
export type BoyProperty = Boy["name"] | Boy["description"] | Boy["id"];
export type EntityRoute = "heroes" | "villains" | "boys";
export type EntityType = "hero" | "villain" | "boy";
/** Returns the corresponding route for the entity;
*
* `hero` -> `/heroes`, `villain` -> `/villains`, `boy` -> `/boys` */
export const entityRoute = (entityType: EntityType) =>
entityType === "hero"
? "heroes"
: entityType === "villain"
? "villains"
: "boys";
/* istanbul ignore file */
Move api.ts
to its own folder
api.ts
to its own folderFrom ./src/hooks/api.ts
to ./src/api/api.ts
. Your IDE should update the dependencies should automatically.
Update the hooks
useDeleteEntity
gets updated for Boy
..
// src/hooks/useDeleteEntity.ts
import { Boy } from "models/Boy";
import { Hero } from "models/Hero";
import { EntityType, entityRoute } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { deleteItem } from "../api/api";
/**
* Helper for DELETE to `/heroes`, `/villains` or 'boys' routes.
* @returns {object} {deleteEntity, isDeleting, isDeleteError, deleteError}
*/
export function useDeleteEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation(
(item: Hero | Villain | Boy) => deleteItem(`${route}/${item.id}`),
{
// on success receives the original item as a second argument
// if you recall, the first argument is the created item
onSuccess: (_, deletedEntity: Hero | Villain | Boy) => {
// get all the entities from the cache
const entities: Hero[] | Villain[] | Boy[] =
queryClient.getQueryData([`${route}`]) || [];
// set the entities cache without the delete one
queryClient.setQueryData(
[`${route}`],
entities.filter((h) => h.id !== deletedEntity.id)
);
navigate(`/${route}`);
},
}
);
return {
deleteEntity: mutation.mutate,
isDeleting: mutation.isLoading,
isDeleteError: mutation.isError,
deleteError: mutation.error,
};
}
useGetEntities
just needs the comment to be updated
Helper for GET to
/heroes
or/villains
routesHelper for GET to
/heroes
,/villains
or/boys
routes.
usePostEntity
gets updated for Boy
.
// src/hooks/usePostEntity.ts
import { Boy } from "models/Boy";
import { Hero } from "models/Hero";
import { EntityType, entityRoute } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "../api/api";
/**
* Helper for simple POST to `/heroes`, `/villains`, `/boys` routes
* @returns {object} {mutate, status, error}
*/
export function usePostEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation((item: Hero | Villain | Boy) => createItem(route, item), {
onSuccess: (newData: Hero | Villain | Boy) => {
// use queryClient's setQueryData to set the cache
// takes a key as the first arg, the 2nd arg is a cb that takes the old query cache and returns the new one
queryClient.setQueryData(
[route],
(oldData: Hero[] | Villain[] | Boy[] | undefined) => [
...(oldData || []),
newData,
]
);
return navigate(`/${route}`);
},
});
}
usePutEntity
gets updated for Boys
.
// src/hooks/usePutEntity.ts
import { Hero } from "models/Hero";
import { Boy } from "models/Boy";
import { Villain } from "models/Villain";
import { EntityType, entityRoute } from "models/types";
import { useMutation, useQueryClient } from "react-query";
import type { QueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { editItem } from "../api/api";
/**
* Helper for PUT to `/heroes` route
* @returns {object} {updateHero, isUpdating, isUpdateError, updateError}
*/
export function usePutEntity(entityType: EntityType) {
const route = entityRoute(entityType);
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutation = useMutation(
(item: Hero | Villain | Boy) => editItem(`${route}/${item.id}`, item),
{
onSuccess: (updatedEntity: Hero | Villain | Boy) => {
updateEntityCache(entityType, updatedEntity, queryClient);
navigate(`/${route}`);
},
}
);
return {
updateEntity: mutation.mutate,
isUpdating: mutation.isLoading,
isUpdateError: mutation.isError,
updateError: mutation.error,
};
}
/** Replace a hero in the cache with the updated version. */
function updateEntityCache(
entityType: EntityType,
updatedEntity: Hero | Villain | Boy,
queryClient: QueryClient
) {
const route = entityRoute(entityType);
// get all the heroes from the cache
let entityCache: Hero[] | Villain[] | Boy[] =
queryClient.getQueryData(route) || [];
// find the index in the cache of the hero that's been edited
const entityIndex = entityCache.findIndex((h) => h.id === updatedEntity.id);
if (entityIndex !== -1) {
// if the entity is found, replace the pre-edited entity with the updated one
// this is just replacing an array item in place,
// while not mutating the original array
entityCache = entityCache.map((preEditedEntity) =>
preEditedEntity.id === updatedEntity.id ? updatedEntity : preEditedEntity
);
console.log("entityCache is", entityCache);
// use queryClient's setQueryData to set the cache
// takes a key as the first arg, the 2nd arg is the new cache
return queryClient.setQueryData([route], entityCache);
}
}
Modify the e2e commands
Between the 3 files under ./cypress/support
, we can compartmentalize the imports in favor of relevance
commands.ts
: all plugin imports, and commands common to e2e & CT (ex:cy.getByCY
).component.ts
: the commands specific to component tests (ex:cy.mount
,cy.wrappedMount
).e2e.ts
: the commands specific to e2e (ex:cy.crud
).
// cypress/support/commands.ts
import "@testing-library/cypress/add-commands";
import "@bahmutov/cypress-code-coverage/support";
Cypress.Commands.add("getByCy", (selector, ...args) =>
cy.get(`[data-cy="${selector}"]`, ...args)
);
Cypress.Commands.add("getByCyLike", (selector, ...args) =>
cy.get(`[data-cy*=${selector}]`, ...args)
);
Cypress.Commands.add("getByClassLike", (selector, ...args) =>
cy.get(`[class*=${selector}]`, ...args)
);
component.tsx
and e2e.ts
will import commands.ts
so that CT and e2e files have access to common commands and plugins.
// cypress/support/component.tsx
import "./commands";
import { mount } from "cypress/react18";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { ErrorBoundary } from "react-error-boundary";
import ErrorComp from "../../src/components/ErrorComp";
import PageSpinner from "../../src/components/PageSpinner";
import { Suspense } from "react";
Cypress.Commands.add("mount", mount);
Cypress.Commands.add(
"wrappedMount",
(WrappedComponent: React.ReactNode, options = {}) => {
const wrapped = (
<QueryClientProvider client={new QueryClient()}>
<ErrorBoundary fallback={<ErrorComp />}>
<Suspense fallback={<PageSpinner />}>
<BrowserRouter>{WrappedComponent}</BrowserRouter>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
return cy.mount(wrapped, options);
}
);
// cypress/support/e2e.ts
import "./commands";
import { Villain } from "./../../src/models/Villain";
import { Hero } from "../../src/models/Hero";
import { Boy } from "../../src/models/Boy";
import {
entityRoute,
EntityRoute,
EntityType,
HeroProperty,
VillainProperty,
BoyProperty,
} from "../../src/models/types";
import data from "../fixtures/db.json";
Cypress.Commands.add(
"crud",
(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | Villain | Boy | object; allowedToFail?: boolean } = {}
) =>
cy.request<(Hero[] & Hero) | (Villain[] & Villain) | (Boy[] & Boy)>({
method: method,
url: `${Cypress.env("API_URL")}/${route}`,
body: method === "POST" || method === "PUT" ? body : undefined,
retryOnStatusCodeFailure: !allowedToFail,
failOnStatusCode: !allowedToFail,
})
);
Cypress.Commands.add("resetData", () =>
cy.crud("POST", "reset", { body: data })
);
const { _ } = Cypress;
const propExists =
(property: HeroProperty | VillainProperty | BoyProperty) =>
(entity: Hero | Villain | Boy) =>
entity.name === property ||
entity.description === property ||
entity.id === property;
const getEntities = (entityRoute: EntityRoute) =>
cy.crud("GET", entityRoute).its("body");
Cypress.Commands.add(
"getEntityByProperty",
(
entityType: EntityType,
property: HeroProperty | VillainProperty | BoyProperty
) =>
getEntities(entityRoute(entityType)).then((entities) =>
_.find(entities, propExists(property))
)
);
Cypress.Commands.add(
"findEntityIndex",
(
entityType: EntityType,
property: HeroProperty | VillainProperty | BoyProperty
) =>
getEntities(entityRoute(entityType)).then(
(body: Hero[] | Villain[] | Boy[]) => ({
entityIndex: _.findIndex(body, propExists(property)),
entityArray: body,
})
)
);
Cypress.Commands.add("visitStubbedEntities", (entityRoute: EntityRoute) => {
cy.intercept("GET", `${Cypress.env("API_URL")}/${entityRoute}`, {
fixture: `${entityRoute}.json`,
}).as(`stubbed${_.startCase(entityRoute)}`);
cy.visit(`/${entityRoute}`);
cy.wait(`@stubbed${_.startCase(entityRoute)}`);
return cy.location("pathname").should("eq", `/${entityRoute}`);
});
Cypress.Commands.add("visitEntities", (entityRoute: EntityRoute) => {
cy.intercept("GET", `${Cypress.env("API_URL")}/${entityRoute}`).as(
`get${_.startCase(entityRoute)}`
);
cy.visit(`/${entityRoute}`);
cy.wait(`@get${_.startCase(entityRoute)}`);
return cy.location("pathname").should("eq", `/${entityRoute}`);
});
Update the type definitions in the repo root.
// cypress.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MountOptions, MountReturn } from "cypress/react";
import { HeroProperty, VillainProperty, EntityType } from "models/types";
import type { Hero } from "./models/types/Hero";
import type { Villain } from "./models/types/Villain";
import type { Boy } from "./models/types/Boy";
export {};
declare global {
namespace Cypress {
interface Chainable {
/** Yields elements with a data-cy attribute that matches a specified selector.
* ```
* cy.getByCy('search-toggle') // where the selector is [data-cy="search-toggle"]
* ```
*/
getByCy(qaSelector: string, args?: any): Chainable<JQuery<HTMLElement>>;
/** Yields elements with data-cy attribute that partially matches a specified selector.
* ```
* cy.getByCyLike('chat-button') // where the selector is [data-cy="chat-button-start-a-new-claim"]
* ```
*/
getByCyLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Yields the element that partially matches the css class
* ```
* cy.getByClassLike('StyledIconBase') // where the class is class="StyledIconBase-ea9ulj-0 lbJwfL"
* ```
*/
getByClassLike(
qaSelector: string,
args?: any
): Chainable<JQuery<HTMLElement>>;
/** Mounts a React node
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
mount(
component: React.ReactNode,
options?: MountOptions
): Cypress.Chainable<MountReturn>;
/** Mounts the component wrapped by all the providers:
* QueryClientProvider, ErrorBoundary, Suspense, BrowserRouter
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
wrappedMount(
component: React.ReactNode,
options?: MountOptions
): Cypress.Chainable<MountReturn>;
/** Visits heroes or villains routes, uses real network, verifies path */
visitEntities(entityRoute: EntityRoute): Cypress.Chainable<string>;
/** Visits heroes or villains routes, uses stubbed network, verifies path */
visitStubbedEntities(entityRoute: EntityRoute): Cypress.Chainable<string>;
/**
* Gets an entity by name.
* ```js
* cy.getEntityByName(newHero.name).then(myHero => ...)
* ```
* @param name: Hero['name']
*/
getEntityByProperty(
entityType: EntityType,
property: HeroProperty | VillainProperty
): Cypress.Chainable<Hero | Villain | Boy>;
/**
* Given a hero property (name, description or id),
* returns the index of the hero, and the entire collection, as an object.
*/
findEntityIndex(
entityType: EntityType,
property: HeroProperty
): Cypress.Chainable<{
entityIndex: number;
entityArray: Hero[] | Villain[] | Boy[];
}>;
/**
* Performs crud operations GET, POST, PUT and DELETE.
*
* `body` and `allowedToFail are optional.
*
* If they are not passed in, body is empty but `allowedToFail` still is `false`.
*
* If the body is passed in and the method is `POST` or `PUT`, the payload will be taken,
* otherwise undefined for `GET` and `DELETE`.
* @param method
* @param route
* @param options: {body?: Hero | object; allowedToFail?: boolean}
*/
crud(
method: "GET" | "POST" | "PUT" | "DELETE",
route: string,
{
body,
allowedToFail = false,
}: { body?: Hero | object; allowedToFail?: boolean } = {}
): Cypress.Chainable<Response<Hero[] & Hero>>;
/**
* Resets the data in the database to the initial data.
*/
resetData(): Cypress.Chainable<
Response<(Hero[] & Hero) | (Villain[] & Villain) | (Boy[] & Boy)>
>;
}
}
}
Create a boys mirror of the heroes
Update ./db.json
./db.json
Add boys
group.
{
"heroes": [
{
"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"
}
],
"villains": [
{
"id": "VillainMadelyn",
"name": "Madelyn",
"description": "the cat whisperer"
},
{
"id": "VillainHaley",
"name": "Haley",
"description": "pen wielder"
},
{
"id": "VillainElla",
"name": "Ella",
"description": "fashionista"
},
{
"id": "VillainLandon",
"name": "Landon",
"description": "Mandalorian mauler"
}
],
"boys": [
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
}
Mirror it to ./cypress/fixtures/db.json
so that we can reset the db state correctly.
{
"heroes": [
{
"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"
}
],
"villains": [
{
"id": "VillainMadelyn",
"name": "Madelyn",
"description": "the cat whisperer"
},
{
"id": "VillainHaley",
"name": "Haley",
"description": "pen wielder"
},
{
"id": "VillainElla",
"name": "Ella",
"description": "fashionista"
},
{
"id": "VillainLandon",
"name": "Landon",
"description": "Mandalorian mauler"
}
],
"boys": [
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
}
Add fixture ./cypress/fixtures/boys.json
./cypress/fixtures/boys.json
[
{
"id": "BoyHomelander",
"name": "Homelander",
"description": "Like Superman, but a jerk."
},
{
"id": "BoyAnnieJanuary",
"name": "Annie January",
"description": "The Defender of Des Moines."
},
{
"id": "BoyBillyButcher",
"name": "Billy Butcher",
"description": "A former member of the British special forces turned vigilante."
},
{
"id": "BoyBlackNoir",
"name": "Black Noir",
"description": "Master Martial Artist, expert hand-to-hand combatant highly trained in various forms of martial arts."
}
]
Mirror the components
We are creating 3 components for boys, mirroring heroes group as they are. We also have to update some of the base components.
For ListHeader
only the type changes for title
.
// src/components/ListHeader.tsx
import { MouseEvent } from "react";
import { NavLink } from "react-router-dom";
import { FiRefreshCcw } from "react-icons/fi";
import { GrAdd } from "react-icons/gr";
type ListHeaderProps = {
title: "Heroes" | "Villains" | "Boys" | "About";
handleAdd: (e: MouseEvent<HTMLButtonElement>) => void;
handleRefresh: (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function ListHeader({
title,
handleAdd,
handleRefresh,
}: ListHeaderProps) {
return (
<div data-cy="list-header" className="content-title-group">
<NavLink data-cy="title" to={title}>
<h2>{title}</h2>
</NavLink>
<button data-cy="add-button" onClick={handleAdd} aria-label="add">
<GrAdd />
</button>
<button
data-cy="refresh-button"
onClick={handleRefresh}
aria-label="refresh"
>
<FiRefreshCcw />
</button>
</div>
);
}
The rest are mirrors. Create a folder ./src/boys/
and the 3 mirror files under it.
// src/boys/BoyDetail.tsx
import { useState, ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FaUndo, FaRegSave } from "react-icons/fa";
import InputDetail from "components/InputDetail";
import ButtonFooter from "components/ButtonFooter";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import { useEntityParams } from "hooks/useEntityParams";
import { usePostEntity } from "hooks/usePostEntity";
import { Boy } from "models/Boy";
import { usePutEntity } from "hooks/usePutEntity";
export default function BoyDetail() {
const { id } = useParams();
const { name, description } = useEntityParams();
const [boy, setBoy] = useState({ id, name, description });
const { mutate: createBoy, status, error: postError } = usePostEntity("boy");
const {
updateEntity: updateBoy,
isUpdating,
isUpdateError,
} = usePutEntity("boy");
const navigate = useNavigate();
const handleCancel = () => navigate("/boys");
const handleSave = () =>
name ? updateBoy(boy as Boy) : createBoy(boy as Boy);
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setBoy({ ...boy, name: e.target.value });
};
const handleDescriptionChange = (e: ChangeEvent<HTMLInputElement>) => {
setBoy({ ...boy, description: e.target.value });
};
if (status === "loading" || isUpdating) {
return <PageSpinner />;
}
if (postError || isUpdateError) {
return <ErrorComp />;
}
return (
<div data-cy="boy-detail" className="card edit-detail">
<header className="card-header">
<p className="card-header-title">{name}</p>
</header>
<div className="card-content">
<div className="content">
{id && (
<InputDetail name={"id"} value={id} readOnly={true}></InputDetail>
)}
<InputDetail
name={"name"}
value={name ? name : ""}
placeholder="e.g. Colleen"
onChange={handleNameChange}
></InputDetail>
<InputDetail
name={"description"}
value={description ? description : ""}
placeholder="e.g. dance fight!"
onChange={handleDescriptionChange}
></InputDetail>
</div>
</div>
<footer className="card-footer">
<ButtonFooter
label="Cancel"
IconClass={FaUndo}
onClick={handleCancel}
/>
<ButtonFooter label="Save" IconClass={FaRegSave} onClick={handleSave} />
</footer>
</div>
);
}
// src/boys/BoyList.tsx
import { useNavigate } from "react-router-dom";
import CardContent from "components/CardContent";
import ButtonFooter from "components/ButtonFooter";
import { FaEdit, FaRegSave } from "react-icons/fa";
import {
ChangeEvent,
MouseEvent,
useTransition,
useEffect,
useState,
useDeferredValue,
} from "react";
import { Boy } from "models/Boy";
import { BoyProperty } from "models/types";
type BoyListProps = {
boys: Boy[];
handleDeleteBoy: (boy: Boy) => (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function BoyList({ boys, handleDeleteBoy }: BoyListProps) {
const deferredBoys = useDeferredValue(boys);
const isStale = deferredBoys !== boys;
const [filteredBoys, setFilteredBoys] = useState(deferredBoys);
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();
// needed to refresh the list after deleting a boy
useEffect(() => setFilteredBoys(deferredBoys), [deferredBoys]);
// currying: the outer fn takes our custom arg and returns a fn that takes the event
const handleSelectBoy = (boyId: string) => () => {
const boy = deferredBoys.find((b: Boy) => b.id === boyId);
navigate(
`/boys/edit-boy/${boy?.id}?name=${boy?.name}&description=${boy?.description}`
);
};
/** returns a boolean whether the boy properties exist in the search field */
const searchExists = (searchProperty: BoyProperty, searchField: string) =>
String(searchProperty).toLowerCase().indexOf(searchField.toLowerCase()) !==
-1;
/** given the data and the search field, returns the data in which the search field exists */
const searchProperties = (searchField: string, data: Boy[]) =>
[...data].filter((item: Boy) =>
Object.values(item).find((property: BoyProperty) =>
searchExists(property, searchField)
)
);
/** filters the boys data to see if the any of the properties exist in the list */
const handleSearch =
(data: Boy[]) => (event: ChangeEvent<HTMLInputElement>) => {
const searchField = event.target.value;
return startTransition(() =>
setFilteredBoys(searchProperties(searchField, data))
);
};
return (
<div
style={{
opacity: isPending ? 0.5 : 1,
color: isStale ? "dimgray" : "black",
}}
>
{deferredBoys.length > 0 && (
<div className="card-content">
<span>Search </span>
<input data-cy="search" onChange={handleSearch(deferredBoys)} />
</div>
)}
<ul data-cy="boy-list" className="list">
{filteredBoys.map((boy, index) => (
<li data-cy={`boy-list-item-${index}`} key={boy.id}>
<div className="card">
<CardContent name={boy.name} description={boy.description} />
<footer className="card-footer">
<ButtonFooter
label="Delete"
IconClass={FaRegSave}
onClick={handleDeleteBoy(boy)}
/>
<ButtonFooter
label="Edit"
IconClass={FaEdit}
onClick={handleSelectBoy(boy.id)}
/>
</footer>
</div>
</li>
))}
</ul>
</div>
);
}
// src/boys/Boys.tsx
import { useState } from "react";
import { useNavigate, Routes, Route } from "react-router-dom";
import ListHeader from "components/ListHeader";
import ModalYesNo from "components/ModalYesNo";
import ErrorComp from "components/ErrorComp";
import BoyList from "./BoyList";
import BoyDetail from "./BoyDetail";
import { useGetEntities } from "hooks/useGetEntities";
import { useDeleteEntity } from "hooks/useDeleteEntity";
import { Boy } from "models/Boy";
export default function Boys() {
const [showModal, setShowModal] = useState<boolean>(false);
const { entities: boys, getError } = useGetEntities("boys");
const [boyToDelete, setBoyToDelete] = useState<Boy | null>(null);
const { deleteEntity: deleteBoy, isDeleteError } = useDeleteEntity("boy");
const navigate = useNavigate();
const addNewBoy = () => navigate("/boys/add-boy");
const handleRefresh = () => navigate("/boys");
const handleCloseModal = () => {
setBoyToDelete(null);
setShowModal(false);
};
// currying: the outer fn takes our custom arg and returns a fn that takes the event
const handleDeleteBoy = (boy: Boy) => () => {
setBoyToDelete(boy);
setShowModal(true);
};
const handleDeleteFromModal = () => {
boyToDelete ? deleteBoy(boyToDelete) : null;
setShowModal(false);
};
if (getError || isDeleteError) {
return <ErrorComp />;
}
return (
<div data-cy="boys">
<ListHeader
title="Boys"
handleAdd={addNewBoy}
handleRefresh={handleRefresh}
/>
<div>
<div>
<Routes>
<Route
path=""
element={
<BoyList boys={boys} handleDeleteBoy={handleDeleteBoy} />
}
/>
<Route path="/add-boy" element={<BoyDetail />} />
<Route path="/edit-boy/:id" element={<BoyDetail />} />
<Route
path="*"
element={
<BoyList boys={boys} handleDeleteBoy={handleDeleteBoy} />
}
/>
</Routes>
</div>
</div>
{showModal && (
<ModalYesNo
message="Would you like to delete the boy?"
onNo={handleCloseModal}
onYes={handleDeleteFromModal}
/>
)}
</div>
);
}
Add Boys route to App.tsx
// src/App.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import { ErrorBoundary } from "react-error-boundary";
import HeaderBar from "components/HeaderBar";
import NavBar from "components/NavBar";
import PageSpinner from "components/PageSpinner";
import ErrorComp from "components/ErrorComp";
import Villains from "villains/Villains";
import Boys from "boys/Boys";
import "./styles.scss";
const Heroes = lazy(() => import("heroes/Heroes"));
const NotFound = lazy(() => import("components/NotFound"));
const About = lazy(() => import("About"));
const queryClient = new QueryClient();
export default function App() {
return (
<BrowserRouter>
<HeaderBar />
<div className="section columns">
<NavBar />
<main className="column">
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<ErrorComp />}>
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/" element={<Navigate replace to="/heroes" />} />
<Route path="/heroes/*" element={<Heroes />} />
<Route path="/villains/*" element={<Villains />} />
<Route path="/boys/*" element={<Boys />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
</main>
</div>
</BrowserRouter>
);
}
Add Boys
to NavBar
component.
// src/components/NavBar.tsx
import { NavLink } from "react-router-dom";
export default function NavBar() {
const linkIsActive = (link: { isActive: boolean }) =>
link.isActive ? "active-link" : "";
return (
<nav data-cy="nav-bar" className="column is-2 menu">
<p className="menu-label">Menu</p>
<ul data-cy="menu-list" className="menu-list">
<NavLink to="/heroes" className={linkIsActive}>
Heroes
</NavLink>
<NavLink to="/villains" className={linkIsActive}>
Villains
</NavLink>
<NavLink to="/boys" className={linkIsActive}>
Boys
</NavLink>
<NavLink to="/about" className={linkIsActive}>
About
</NavLink>
</ul>
</nav>
);
}
Mirror the e2e tests
We create 3 new e2e tests, which are boy mirrors of the hero versions.
// cypress/e2e/create-boy.cy.ts
import { faker } from "@faker-js/faker";
describe("Create boy", () => {
before(cy.resetData);
const navToAddBoy = () => {
cy.location("pathname").should("eq", "/boys");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/boys/add-boy");
cy.getByCy("boy-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
};
it("should go through the refresh flow (ui-integration)", () => {
cy.visitStubbedEntities("boys");
navToAddBoy();
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/boys");
cy.getByCy("boy-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation (ui-integration)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/boys`, {
fixture: "boys",
}).as("stubbedGetBoys");
cy.visit("/boys/add-boy");
cy.wait("@stubbedGetBoys");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/boys");
cy.getByCy("boy-list").should("be.visible");
});
it("should go through the add boy flow (ui-e2e)", () => {
cy.visitEntities("boys");
navToAddBoy();
const newBoy = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newBoy.name);
cy.getByCy("input-detail-description").type(newBoy.description);
cy.getByCy("save-button").click();
cy.location("pathname").should("eq", "/boys");
cy.getByCy("boys").should("be.visible");
cy.getByCyLike("boy-list-item").should("have.length.gt", 0);
cy.getByCy("boy-list")
.should("contain", newBoy.name)
.and("contain", newBoy.description);
cy.getEntityByProperty("boy", newBoy.name).then((myBoy) =>
cy.crud("DELETE", `boys/${myBoy.id}`)
);
});
});
// cypress/e2e/delete-boy.cy.ts
import { faker } from "@faker-js/faker";
import { Boy } from "../../src/models/Boy";
describe("Delete boy", () => {
before(cy.resetData);
const yesOnModal = () =>
cy.getByCy("modal-yes-no").within(() => cy.getByCy("button-yes").click());
it("should go through the cancel flow (ui-integration)", () => {
cy.visitStubbedEntities("boys");
cy.getByCy("delete-button").first().click();
cy.getByCy("modal-yes-no").within(() => cy.getByCy("button-no").click());
cy.getByCy("boys").should("be.visible");
cy.get("modal-yes-no").should("not.exist");
});
it("should go through the edit flow (ui-e2e)", () => {
const boy: Boy = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.crud("POST", "boys", { body: boy });
cy.visitEntities("boys");
cy.findEntityIndex("boy", boy.id).then(
({ entityIndex: boyIndex, entityArray: boyArray }) => {
cy.getByCy("delete-button").eq(boyIndex).click();
yesOnModal();
cy.getByCy("boy-list")
.should("be.visible")
.should("not.contain", boyArray[boyIndex].name)
.and("not.contain", boyArray[boyIndex].description);
}
);
});
});
// cypress/e2e/edit-boy.cy.ts
import { faker } from "@faker-js/faker";
import { Boy } from "../../src/models/Boy";
describe("Edit boy", () => {
before(cy.resetData);
/** Verifies boy info on Edit page */
const verifyBoy = (boys: Boy[], heroIndex: number) => {
cy.location("pathname").should("include", "/boys/edit-boy/");
cy.getByCy("boy-detail").should("be.visible");
cy.getByCy("input-detail-id").should("be.visible");
cy.findByDisplayValue(boys[heroIndex].id);
cy.findByDisplayValue(boys[heroIndex].name);
cy.findByDisplayValue(boys[heroIndex].description);
};
const randomBoyIndex = (boys: Boy[]) => Cypress._.random(0, boys.length - 1);
it("should go through the cancel flow for a random boy (ui-integration)", () => {
cy.visitStubbedEntities("boys");
cy.fixture("boys").then((boys) => {
const heroIndex = randomBoyIndex(boys);
cy.getByCy("edit-button").eq(heroIndex).click();
verifyBoy(boys, heroIndex);
});
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/boys");
cy.getByCy("boy-list").should("be.visible");
});
it("should go through the PUT error flow (ui-integration)", () => {
cy.visitStubbedEntities("boys");
cy.fixture("boys").then((boys) => {
const heroIndex = randomBoyIndex(boys);
cy.getByCy("edit-button").eq(heroIndex).click();
verifyBoy(boys, heroIndex);
});
cy.intercept("PUT", `${Cypress.env("API_URL")}/boys/*`, {
statusCode: 500,
delay: 100,
}).as("isUpdateError");
cy.getByCy("save-button").click();
cy.getByCy("spinner");
cy.wait("@isUpdateError");
cy.getByCy("error");
});
it("should navigate to add from an existing boy (ui-integration)", () => {
cy.visitStubbedEntities("boys");
cy.fixture("boys").then((boys) => {
const heroIndex = randomBoyIndex(boys);
cy.getByCy("edit-button").eq(heroIndex).click();
verifyBoy(boys, heroIndex);
cy.getByCy("add-button").click();
cy.getByCy("input-detail-id").should("not.exist");
cy.findByDisplayValue(boys[heroIndex].name).should("not.exist");
cy.findByDisplayValue(boys[heroIndex].description).should("not.exist");
});
});
it("should go through the edit flow (ui-e2e)", () => {
const newBoy: Boy = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.crud("POST", "boys", { body: newBoy });
cy.visit(`boys/edit-boy/${newBoy.id}`, {
qs: { name: newBoy.name, description: newBoy.description },
});
const editedBoy = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name")
.find(".input")
.clear()
.type(`${editedBoy.name}`);
cy.getByCy("input-detail-description")
.find(".input")
.clear()
.type(`${editedBoy.description}`);
cy.getByCy("save-button").click();
cy.getByCy("boy-list")
.should("be.visible")
.should("contain", editedBoy.name)
.and("contain", editedBoy.description);
cy.getEntityByProperty("boy", newBoy.id).then((myBoy: Boy) =>
cy.crud("DELETE", `boys/${myBoy.id}`)
);
});
});
The backend test needs a new block to cover villains.
// cypress/e2e/backend/crud.cy.ts
import { faker } from "@faker-js/faker";
import { Hero } from "../../../src/models/Hero";
import { Villain } from "../../../src/models/Villain";
import { Boy } from "../../../src/models/Boy";
describe("Backend e2e", () => {
const assertProperties = (entity: Hero | Villain | Boy) => {
expect(entity.id).to.be.a("string");
expect(entity.name).to.be.a("string");
expect(entity.description).to.be.a("string");
};
before(() => cy.resetData());
it("should GET heroes and villains ", () => {
cy.crud("GET", "heroes")
.its("body")
.should("have.length.gt", 0)
.each(assertProperties);
cy.crud("GET", "villains")
.its("body")
.should("have.length.gt", 0)
.each(assertProperties);
});
it("should CRUD a new hero entity", () => {
const newHero = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.crud("POST", "heroes", { body: newHero })
.its("status")
.should("eq", 201);
cy.crud("GET", "heroes")
.its("body")
.then((body) => {
expect(body.at(-1)).to.deep.eq(newHero);
});
const editedHero = { ...newHero, name: "Murat" };
cy.crud("PUT", `heroes/${editedHero.id}`, { body: editedHero })
.its("status")
.should("eq", 200);
cy.crud("GET", `heroes/${editedHero.id}`)
.its("body")
.should("deep.eq", editedHero);
cy.crud("DELETE", `heroes/${editedHero.id}`)
.its("status")
.should("eq", 200);
cy.crud("GET", `heroes/${editedHero.id}`, { allowedToFail: true })
.its("status")
.should("eq", 404);
});
it("should CRUD a new villain entity", () => {
const newVillain = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.crud("POST", "villains", { body: newVillain })
.its("status")
.should("eq", 201);
cy.crud("GET", "villains")
.its("body")
.then((body) => {
expect(body.at(-1)).to.deep.eq(newVillain);
});
const editedVillain = { ...newVillain, name: "Murat" };
cy.crud("PUT", `villains/${editedVillain.id}`, { body: editedVillain })
.its("status")
.should("eq", 200);
cy.crud("GET", `villains/${editedVillain.id}`)
.its("body")
.should("deep.eq", editedVillain);
cy.crud("DELETE", `villains/${editedVillain.id}`)
.its("status")
.should("eq", 200);
cy.crud("GET", `villains/${editedVillain.id}`, { allowedToFail: true })
.its("status")
.should("eq", 404);
});
it("should CRUD a new boy entity", () => {
const newVillain = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.crud("POST", "boys", { body: newVillain })
.its("status")
.should("eq", 201);
cy.crud("GET", "boys")
.its("body")
.then((body) => {
expect(body.at(-1)).to.deep.eq(newVillain);
});
const editedVillain = { ...newVillain, name: "Murat" };
cy.crud("PUT", `boys/${editedVillain.id}`, { body: editedVillain })
.its("status")
.should("eq", 200);
cy.crud("GET", `boys/${editedVillain.id}`)
.its("body")
.should("deep.eq", editedVillain);
cy.crud("DELETE", `boys/${editedVillain.id}`)
.its("status")
.should("eq", 200);
cy.crud("GET", `boys/${editedVillain.id}`, { allowedToFail: true })
.its("status")
.should("eq", 404);
});
});
The routes-nav needs a new test to cover villains route.
// cypress/e2e/routes-nav.cy.ts
describe("routes navigation (ui-integration)", () => {
beforeEach(() => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
});
it("should land on baseUrl, redirect to /heroes", () => {
cy.visit("/");
cy.getByCy("header-bar").should("be.visible");
cy.getByCy("nav-bar").should("be.visible");
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("heroes").should("be.visible");
});
it("should direct-navigate to /heroes", () => {
const route = "/heroes";
cy.visit(route);
cy.location("pathname").should("eq", route);
cy.getByCy("heroes").should("be.visible");
});
it("should direct-navigate to /villains", () => {
const route = "/villains";
cy.visit(route);
cy.location("pathname").should("eq", route);
cy.getByCy("villains").should("be.visible");
});
it("should land on not found when visiting an non-existing route", () => {
const route = "/route48";
cy.visit(route);
cy.location("pathname").should("eq", route);
cy.getByCy("not-found").should("be.visible");
});
it("should direct-navigate to about", () => {
const route = "/about";
cy.visit(route);
cy.location("pathname").should("eq", route);
cy.getByCy("about").contains("CCTDD");
});
it("should cover route history with browser back and forward", () => {
cy.visit("/about");
const routes = ["villains", "heroes", "about"];
cy.wrap(routes).each((route: string) =>
cy.get(`[href="/${route}"]`).click()
);
const lastIndex = routes.length - 1;
cy.location("pathname").should("include", routes[lastIndex]);
cy.go("back");
cy.location("pathname").should("include", routes[lastIndex - 1]);
cy.go("back");
cy.location("pathname").should("include", routes[lastIndex - 2]);
cy.go("forward").go("forward");
cy.location("pathname").should("include", routes[lastIndex]);
});
});
Mirror the Cypress component tests
First, update Navbar
and App
tests. For Navbar.cy
we just need to add boys
to the routes
array
// src/components/NavBar.cy.tsx
import NavBar from "./NavBar";
import { BrowserRouter } from "react-router-dom";
import "../styles.scss";
describe("NavBar", () => {
it("should navigate to the correct routes", () => {
cy.mount(
<BrowserRouter>
<NavBar />
</BrowserRouter>
);
cy.contains("p", "Menu");
cy.getByCy("menu-list").children().should("have.length", routes.length);
routes.forEach((route: string) => {
cy.get(`[href="/${route}"]`)
.contains(route, { matchCase: false })
.click()
.should("have.class", "active-link")
.siblings()
.should("not.have.class", "active-link");
cy.url().should("contain", route);
});
});
});
For App.cy
we need to intercept the boys
route and add a check for boys
.
// src/App.cy.tsx
import App from "./App";
describe("ct sanity", () => {
it("should render the App", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes.json",
}).as("getHeroes");
cy.intercept("GET", `${Cypress.env("API_URL")}/villains`, {
fixture: "villains.json",
}).as("getVillains");
cy.intercept("GET", `${Cypress.env("API_URL")}/boys`, {
fixture: "boys.json",
});
cy.mount(<App />);
cy.getByCy("not-found").should("be.visible");
cy.contains("Heroes").click();
cy.getByCy("heroes").should("be.visible");
cy.contains("Villains").click();
cy.getByCy("villains").should("be.visible");
cy.contains("Boys").click();
cy.getByCy("boys").should("be.visible");
cy.contains("About").click();
cy.getByCy("about").should("be.visible");
});
});
We need 3 new component tests for the 3 components under boys
.
// src/boys/BoyDetail.cy.tsx
import BoyDetail from "./BoyDetail";
import "../styles.scss";
import React from "react";
import * as postHook from "hooks/usePostEntity";
describe("BoyDetail", () => {
beforeEach(() => {
cy.wrappedMount(<BoyDetail />);
});
it("should handle Save", () => {
// example of testing implementation details
cy.spy(React, "useState").as("useState");
cy.spy(postHook, "usePostEntity").as("usePostEntity");
// instead prefer to test at a higher level
cy.intercept("POST", "*", { statusCode: 200 }).as("postBoy");
cy.getByCy("save-button").click();
// test at a higher level
cy.wait("@postBoy");
// test implementation details (what not to do)
cy.get("@useState").should("have.been.called");
cy.get("@usePostEntity").should("have.been.called");
});
it("should handle non-200 Save", () => {
cy.intercept("POST", "*", { statusCode: 400, delay: 100 }).as("postBoy");
cy.getByCy("save-button").click();
cy.getByCy("spinner");
cy.wait("@postBoy");
cy.getByCy("error");
});
it("should handle Cancel", () => {
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/boys");
});
it("should handle name change", () => {
const newBoyName = "abc";
cy.getByCy("input-detail-name").type(newBoyName);
cy.findByDisplayValue(newBoyName).should("be.visible");
});
it("should handle description change", () => {
const newBoyDescription = "123";
cy.getByCy("input-detail-description").type(newBoyDescription);
cy.findByDisplayValue(newBoyDescription).should("be.visible");
});
it("id: false, name: false - should verify the minimal state of the component", () => {
cy.get("p").then(($el) => cy.wrap($el.text()).should("equal", ""));
cy.getByCyLike("input-detail").should("have.length", 2);
cy.getByCy("input-detail-id").should("not.exist");
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");
});
});
// src/boys/BoyList.cy.tsx
import BoyList from "./BoyList";
import "../styles.scss";
import boys from "../../cypress/fixtures/boys.json";
describe("BoyList", () => {
it("no boys should not display a list nor search bar", () => {
cy.wrappedMount(
<BoyList boys={[]} handleDeleteBoy={cy.stub().as("handleDeleteBoy")} />
);
cy.getByCy("boy-list").should("exist");
cy.getByCyLike("boy-list-item").should("not.exist");
cy.getByCy("search").should("not.exist");
});
context("with boys in the list", () => {
beforeEach(() => {
cy.wrappedMount(
<BoyList
boys={boys}
handleDeleteBoy={cy.stub().as("handleDeleteBoy")}
/>
);
});
it("should render the boy layout", () => {
cy.getByCyLike("boy-list-item").should("have.length", boys.length);
cy.getByCy("card-content");
cy.contains(boys[0].name);
cy.contains(boys[0].description);
cy.get("footer")
.first()
.within(() => {
cy.getByCy("delete-button");
cy.getByCy("edit-button");
});
});
it("should search and filter boy by name and description", () => {
cy.getByCy("search").type(boys[0].name);
cy.getByCyLike("boy-list-item")
.should("have.length", 1)
.contains(boys[0].name);
cy.getByCy("search").clear().type(boys[2].description);
cy.getByCyLike("boy-list-item")
.should("have.length", 1)
.contains(boys[2].description);
});
it("should handle delete", () => {
cy.getByCy("delete-button").first().click();
cy.get("@handleDeleteBoy").should("have.been.called");
});
it("should handle edit", () => {
cy.getByCy("edit-button").first().click();
cy.location("pathname").should("eq", "/boys/edit-boy/" + boys[0].id);
});
});
});
// src/boys/Boys.cy.tsx
import Boys from "./Boys";
import "../styles.scss";
describe("Boys", () => {
it("should see error on initial load with GET", () => {
Cypress.on("uncaught:exception", () => false);
cy.clock();
cy.intercept("GET", `${Cypress.env("API_URL")}/boys`, {
statusCode: 400,
delay: 100,
}).as("notFound");
cy.wrappedMount(<Boys />);
cy.getByCy("page-spinner").should("be.visible");
Cypress._.times(3, () => {
cy.tick(5000);
cy.wait("@notFound");
});
cy.tick(5000);
cy.getByCy("error");
});
context("200 flows", () => {
beforeEach(() => {
cy.intercept("GET", `${Cypress.env("API_URL")}/boys`, {
fixture: "boys.json",
}).as("getBoys");
cy.wrappedMount(<Boys />);
});
it("should display the boy list on render, and go through boy add & refresh flow", () => {
cy.wait("@getBoys");
cy.getByCy("list-header").should("be.visible");
cy.getByCy("boy-list").should("be.visible");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/boys/add-boy");
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/boys");
});
const invokeBoyDelete = () => {
cy.getByCy("delete-button").first().click();
cy.getByCy("modal-yes-no").should("be.visible");
};
it("should go through the modal flow, and cover error on DELETE", () => {
cy.getByCy("modal-yes-no").should("not.exist");
cy.log("do not delete flow");
invokeBoyDelete();
cy.getByCy("button-no").click();
cy.getByCy("modal-yes-no").should("not.exist");
cy.log("delete flow");
invokeBoyDelete();
cy.intercept("DELETE", "*", { statusCode: 500 }).as("deleteBoy");
cy.getByCy("button-yes").click();
cy.wait("@deleteBoy");
cy.getByCy("modal-yes-no").should("not.exist");
cy.getByCy("error").should("be.visible");
});
});
});
Mirror the RTL tests
We just need to replicate what was done with Cy CT to RTL.
// src/boys/BoyDetail.test.tsx
import BoyDetail from "./BoyDetail";
import "@testing-library/jest-dom";
import { wrappedRender, act, screen, waitFor } from "test-utils";
import userEvent from "@testing-library/user-event";
describe("BoyDetail", () => {
beforeEach(() => {
wrappedRender(<BoyDetail />);
});
// with msw, it is not recommended to use verify XHR calls going out of the app
// instead, the advice is the verify the changes in the UI
// alas, sometimes there are no changes in the component itself
// therefore we cannot test everything 1:1 versus Cypress component test
// should handle Save and should handle non-200 Save have no RTL mirrors
it("should handle Cancel", async () => {
// code that causes React state updates (ex: BrowserRouter)
// should be wrapped into act(...):
// userEvent.click(await screen.findByTestId('cancel-button')) // won't work
act(() => screen.getByTestId("cancel-button").click());
expect(window.location.pathname).toBe("/boys");
});
it("should handle name change", async () => {
const newBoyName = "abc";
const inputDetailName = await screen.findByPlaceholderText("e.g. Colleen");
userEvent.type(inputDetailName, newBoyName);
await waitFor(async () =>
expect(inputDetailName).toHaveDisplayValue(newBoyName)
);
});
const inputDetailDescription = async () =>
screen.findByPlaceholderText("e.g. dance fight!");
it("should handle description change", async () => {
const newBoyDescription = "123";
userEvent.type(await inputDetailDescription(), newBoyDescription);
await waitFor(async () =>
expect(await inputDetailDescription()).toHaveDisplayValue(
newBoyDescription
)
);
});
it("id: false, name: false - should verify the minimal state of the component", async () => {
expect(await screen.findByTestId("input-detail-name")).toBeVisible();
expect(await screen.findByTestId("input-detail-description")).toBeVisible();
expect(screen.queryByTestId("input-detail-id")).not.toBeInTheDocument();
expect(await inputDetailDescription()).toBeVisible();
expect(await screen.findByTestId("save-button")).toBeVisible();
expect(await screen.findByTestId("cancel-button")).toBeVisible();
});
});
// src/boys/BoyList.test.tsx
import BoyList from "./BoyList";
import { wrappedRender, screen, waitFor } from "test-utils";
import userEvent from "@testing-library/user-event";
import { boys } from "../../db.json";
describe("BoyList", () => {
const handleDeleteBoy = jest.fn();
it("no boys should not display a list nor search bar", async () => {
wrappedRender(<BoyList boys={[]} handleDeleteBoy={handleDeleteBoy} />);
expect(await screen.findByTestId("boy-list")).toBeInTheDocument();
expect(screen.queryByTestId("boy-list-item-1")).not.toBeInTheDocument();
expect(screen.queryByTestId("search-bar")).not.toBeInTheDocument();
});
describe("with boys in the list", () => {
beforeEach(() => {
wrappedRender(<BoyList boys={boys} handleDeleteBoy={handleDeleteBoy} />);
});
const cardContents = async () => screen.findAllByTestId("card-content");
const deleteButtons = async () => screen.findAllByTestId("delete-button");
const editButtons = async () => screen.findAllByTestId("edit-button");
it("should render the boy layout", async () => {
expect(
await screen.findByTestId(`boy-list-item-${boys.length - 1}`)
).toBeInTheDocument();
expect(await screen.findByText(boys[0].name)).toBeInTheDocument();
expect(await screen.findByText(boys[0].description)).toBeInTheDocument();
expect(await cardContents()).toHaveLength(boys.length);
expect(await deleteButtons()).toHaveLength(boys.length);
expect(await editButtons()).toHaveLength(boys.length);
});
it("should search and filter boy by name and description", async () => {
const search = await screen.findByTestId("search");
userEvent.type(search, boys[0].name);
await waitFor(async () => expect(await cardContents()).toHaveLength(1));
await screen.findByText(boys[0].name);
userEvent.clear(search);
await waitFor(async () =>
expect(await cardContents()).toHaveLength(boys.length)
);
userEvent.type(search, boys[2].description);
await waitFor(async () => expect(await cardContents()).toHaveLength(1));
});
it("should handle delete", async () => {
userEvent.click((await deleteButtons())[0]);
expect(handleDeleteBoy).toHaveBeenCalled();
});
it("should handle edit", async () => {
userEvent.click((await editButtons())[0]);
await waitFor(() =>
expect(window.location.pathname).toEqual("/boys/edit-boy/" + boys[0].id)
);
});
});
});
// src/boys/Boys.test.tsx
import Boys from "./Boys";
import { wrappedRender, screen, waitForElementToBeRemoved } from "test-utils";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { boys } from "../../db.json";
describe("Boys", () => {
// mute the expected console.error message, because we are mocking non-200 responses
// eslint-disable-next-line @typescript-eslint/no-empty-function
jest.spyOn(console, "error").mockImplementation(() => {});
beforeEach(() => wrappedRender(<Boys />));
it("should see error on initial load with GET", async () => {
const handlers = [
rest.get(
`${process.env.REACT_APP_API_URL}/boys`,
async (_req, res, ctx) => res(ctx.status(400))
),
];
const server = setupServer(...handlers);
server.listen({
onUnhandledRequest: "warn",
});
jest.useFakeTimers();
expect(await screen.findByTestId("page-spinner")).toBeVisible();
jest.advanceTimersByTime(25000);
await waitForElementToBeRemoved(
() => screen.queryByTestId("page-spinner"),
{
timeout: 25000,
}
);
expect(await screen.findByTestId("error")).toBeVisible();
jest.useRealTimers();
server.resetHandlers();
server.close();
});
describe("200 flows", () => {
const handlers = [
rest.get(
`${process.env.REACT_APP_API_URL}/boys`,
async (_req, res, ctx) => res(ctx.status(200), ctx.json(boys))
),
rest.delete(
`${process.env.REACT_APP_API_URL}/boys/${boys[0].id}`, // use /.*/ for all requests
async (_req, res, ctx) =>
res(ctx.status(400), ctx.json("expected error"))
),
];
const server = setupServer(...handlers);
beforeAll(() => {
server.listen({
onUnhandledRequest: "warn",
});
});
afterEach(server.resetHandlers);
afterAll(server.close);
it("should display the boy list on render, and go through boy add & refresh flow", async () => {
expect(await screen.findByTestId("list-header")).toBeVisible();
expect(await screen.findByTestId("boy-list")).toBeVisible();
await userEvent.click(await screen.findByTestId("add-button"));
expect(window.location.pathname).toBe("/boys/add-boy");
await userEvent.click(await screen.findByTestId("refresh-button"));
expect(window.location.pathname).toBe("/boys");
});
const deleteButtons = async () => screen.findAllByTestId("delete-button");
const modalYesNo = async () => screen.findByTestId("modal-yes-no");
const maybeModalYesNo = () => screen.queryByTestId("modal-yes-no");
const invokeBoyDelete = async () => {
userEvent.click((await deleteButtons())[0]);
expect(await modalYesNo()).toBeVisible();
};
it("should go through the modal flow, and cover error on DELETE", async () => {
expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument();
await invokeBoyDelete();
await userEvent.click(await screen.findByTestId("button-no"));
expect(maybeModalYesNo()).not.toBeInTheDocument();
await invokeBoyDelete();
await userEvent.click(await screen.findByTestId("button-yes"));
expect(maybeModalYesNo()).not.toBeInTheDocument();
expect(await screen.findByTestId("error")).toBeVisible();
expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument();
});
});
});
App.test
needs a msw
handler and a new route check.
// src/App.test.tsx
iimport {act, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
import {heroes, villains, boys} from '../db.json'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
describe('200 flow', () => {
const handlers = [
rest.get(
`${process.env.REACT_APP_API_URL}/heroes`,
async (_req, res, ctx) => res(ctx.status(200), ctx.json(heroes)),
),
rest.get(
`${process.env.REACT_APP_API_URL}/villains`,
async (_req, res, ctx) => res(ctx.status(200), ctx.json(villains)),
),
rest.get(`${process.env.REACT_APP_API_URL}/boys`, async (_req, res, ctx) =>
res(ctx.status(200), ctx.json(boys)),
),
]
const server = setupServer(...handlers)
beforeAll(() => {
server.listen({
onUnhandledRequest: 'warn',
})
})
afterEach(server.resetHandlers)
afterAll(server.close)
test('renders tour of heroes', async () => {
render(<App />)
await act(() => new Promise(r => setTimeout(r, 0))) // spinner
await userEvent.click(screen.getByText('About'))
expect(await screen.findByTestId('about')).toBeVisible()
await userEvent.click(screen.getByText('Heroes'))
expect(await screen.findByTestId('heroes')).toBeVisible()
await userEvent.click(screen.getByText('Villains'))
expect(await screen.findByTestId('villains')).toBeVisible()
await userEvent.click(screen.getByText('Boys'))
expect(await screen.findByTestId('boys')).toBeVisible()
})
})
Navbar.test
needs the same routes
variable update to include Boys
.
// src/components/NavBar.test.tsx
import NavBar from "./NavBar";
import { render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import "@testing-library/jest-dom";
const routes = ["Heroes", "Villains", "Boys", "About"];
describe("NavBar", () => {
beforeEach(() => {
render(
<BrowserRouter>
<NavBar />
</BrowserRouter>
);
});
it("should verify route layout", async () => {
expect(await screen.findByText("Menu")).toBeVisible();
const menuList = await screen.findByTestId("menu-list");
expect(within(menuList).queryAllByRole("link").length).toBe(routes.length);
routes.forEach((route) => within(menuList).getByText(route));
});
it.each(routes)("should navigate to route %s", async (route: string) => {
const link = async (name: string) => screen.findByRole("link", { name });
const activeRouteLink = await link(route);
userEvent.click(activeRouteLink);
await waitFor(() => expect(activeRouteLink).toHaveClass("active-link"));
expect(window.location.pathname).toEqual(`/${route.toLowerCase()}`);
const remainingRoutes = routes.filter((r) => r !== route);
remainingRoutes.forEach(async (inActiveRoute) => {
expect(await link(inActiveRoute)).not.toHaveClass("active-link");
});
});
});
Last updated