// 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,
};
}
// 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}`);
},
});
}
// 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);
}
}
// 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)>
>;
}
}
}
{
"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."
}
]
}
{
"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."
}
]
}
[
{
"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."
}
]
// 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");
});
});
});
// 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();
});
});
});