We want to make our hooks more generic so that they can seamlessly be used in the villains group of components. In short, we will replace useCRUDhero hooks with useCRUDentity.
useDeleteEntity replaces useDeleteHero.
useEntityParams replaces useHeroParams.
useGetEntities replaces useGetHeroes.
usePostEntity replaces usePostHero.
usePutEntity replaces usePutHero.
Update the hero components
Having changed the hooks, the hero group of components need slight modifications.
Note about testing implementation details
There are no modifications needed to Cypress e2e, CT, or RTL tests with MSW because we did not test implementation details in any of them. They will all work as they are at the moment.
With cy.intercept and MSW we checked that a network request goes out versus checking that the operation caused a hook to be called. Consequently changing the hooks had no impact on the tests, or the functionality. This is why we want to test at a slightly higher level of abstraction, and why we want to verify the consequences of the implementation vs the implementation details themselves.
Modify the e2e commands to be more generic
The e2e tests for villains will look exactly the same, however our commands are a specific to heroes. They can also be more generic so that mirroring the hero group of tests into villains is easier.
In the commands file we will change most references to hero to entity, and for types we will include the villain varieties next to hero. The change will require small updates to type definitions and e2e tests.
Create a villains mirror of the heroes
Aligned with the theme of the book, we will first create villain related tests.
If your local copy of heroes versions of the test are slightly different, you can optionally tweak them.
Mirror the e2e tests
We create 3 new e2e tests, which are villain mirrors of the hero versions. We also enhance the remaining tests to check for villain related features.
The backend test needs a new block to cover villains.
The routes-nav needs a new test to cover villains route.
Mirror the Cypress component tests
Create a new fixture for villains at cypress/fixtures/villains.json.
Mirror the RTL tests
Enhance App.cy.tsx and App.test.tsx
Mirror the 3 hero components to villain components
We are creating 3 components for villains, mirroring heroes group as they are.
// src/hooks/useDeleteEntity.ts
import { Hero } from "models/Hero";
import { EntityType } from "models/types";
import { Villain } from "models/Villain";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { deleteItem } from "./api";
/**
* Helper for DELETE to `/heroes` or `/villains` routes.
* @returns {object} {deleteEntity, isDeleting, isDeleteError, deleteError}
*/
export function useDeleteEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation(
(item: Hero | Villain) => deleteItem(`${entityRoute}/${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) => {
// get all the entities from the cache
const entities: Hero[] | Villain[] =
queryClient.getQueryData([`${entityRoute}`]) || [];
// set the entities cache without the delete one
queryClient.setQueryData(
[`${entityRoute}`],
entities.filter((h) => h.id !== deletedEntity.id)
);
navigate(`/${entityRoute}`);
},
}
);
return {
deleteEntity: mutation.mutate,
isDeleting: mutation.isLoading,
isDeleteError: mutation.isError,
deleteError: mutation.error,
};
}
// src/hooks/useEntityParams.ts
import { useSearchParams } from "react-router-dom";
export function useEntityParams() {
const [searchParams] = useSearchParams();
const name = searchParams.get("name");
const description = searchParams.get("description");
return { name, description };
}
// src/hooks/useGetEntities.ts
import { EntityRoute } from "models/types";
import { useQuery } from "react-query";
import { getItem } from "./api";
/**
* Helper for GET to `/heroes` or `/villains` routes
* @returns {object} {entities, status, getError}
*/
export const useGetEntities = (entityRoute: EntityRoute) => {
const query = useQuery(entityRoute, () => getItem(entityRoute), {
suspense: true,
});
return {
entities: query.data,
status: query.status,
getError: query.error,
};
};
// src/hooks/usePostEntity.ts
import { Hero } from "models/Hero";
import { EntityType } from "models/types";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createItem } from "./api";
/**
* Helper for simple POST to `/heroes` route
* @returns {object} {mutate, status, error}
*/
export function usePostEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation((item: Hero) => createItem(entityRoute, item), {
onSuccess: (newData: Hero) => {
// 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([entityRoute], (oldData: Hero[] | undefined) => [
...(oldData || []),
newData,
]);
return navigate(`/${entityRoute}`);
},
});
}
// src/hooks/usePutEntity.ts
import { Hero } from "models/Hero";
import { useMutation, useQueryClient } from "react-query";
import type { QueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { editItem } from "./api";
import { Villain } from "models/Villain";
import { EntityType } from "models/types";
/**
* Helper for PUT to `/heroes` route
* @returns {object} {updateHero, isUpdating, isUpdateError, updateError}
*/
export function usePutEntity(entityType: EntityType) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
const queryClient = useQueryClient();
const navigate = useNavigate();
const mutation = useMutation(
(item: Hero) => editItem(`${entityRoute}/${item.id}`, item),
{
onSuccess: (updatedEntity: Hero) => {
updateEntityCache(entityType, updatedEntity, queryClient);
navigate(`/${entityRoute}`);
},
}
);
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,
queryClient: QueryClient
) {
const entityRoute = entityType === "hero" ? "heroes" : "villains";
// get all the heroes from the cache
let entityCache: Hero[] | Villain[] =
queryClient.getQueryData(entityRoute) || [];
// 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 hero is found, replace the pre-edited hero 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([entityRoute], entityCache);
} else return null;
}
// 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 "./cypress/support/commands";
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>;
/**
* 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[] }>;
/**
* 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>>;
}
}
}
// cypress/e2e/create-hero.cy.ts
import { faker } from "@faker-js/faker";
describe("Create hero", () => {
before(cy.resetData);
const navToAddHero = () => {
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("add-button").click();
cy.location("pathname").should("eq", "/heroes/add-hero");
cy.getByCy("hero-detail").should("be.visible");
cy.getByCy("input-detail-id").should("not.exist");
};
it("should go through the refresh flow (ui-integration)", () => {
cy.visitStubbedEntities("heroes");
navToAddHero();
cy.getByCy("refresh-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the cancel flow and perform direct navigation (ui-integration)", () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
fixture: "heroes",
}).as("stubbedGetHeroes");
cy.visit("/heroes/add-hero");
cy.wait("@stubbedGetHeroes");
cy.getByCy("cancel-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("hero-list").should("be.visible");
});
it("should go through the add hero flow (ui-e2e)", () => {
cy.visitEntities("heroes");
navToAddHero();
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
};
cy.getByCy("input-detail-name").type(newHero.name);
cy.getByCy("input-detail-description").type(newHero.description);
cy.getByCy("save-button").click();
cy.location("pathname").should("eq", "/heroes");
cy.getByCy("heroes").should("be.visible");
cy.getByCyLike("hero-list-item").should("have.length.gt", 0);
cy.getByCy("hero-list")
.should("contain", newHero.name)
.and("contain", newHero.description);
cy.getEntityByProperty("hero", newHero.name).then((myHero) =>
cy.crud("DELETE", `heroes/${myHero.id}`)
);
});
});
// src/villains/VillainDetail.test.tsx
import VillainDetail from "./VillainDetail";
import "@testing-library/jest-dom";
import { wrappedRender, act, screen, waitFor } from "test-utils";
import userEvent from "@testing-library/user-event";
describe("VillainDetail", () => {
beforeEach(() => {
wrappedRender(<VillainDetail />);
});
// 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("/villains");
});
it("should handle name change", async () => {
const newVillainName = "abc";
const inputDetailName = await screen.findByPlaceholderText("e.g. Colleen");
userEvent.type(inputDetailName, newVillainName);
await waitFor(async () =>
expect(inputDetailName).toHaveDisplayValue(newVillainName)
);
});
const inputDetailDescription = async () =>
screen.findByPlaceholderText("e.g. dance fight!");
it("should handle description change", async () => {
const newVillainDescription = "123";
userEvent.type(await inputDetailDescription(), newVillainDescription);
await waitFor(async () =>
expect(await inputDetailDescription()).toHaveDisplayValue(
newVillainDescription
)
);
});
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();
});
});