Hook'larımızı daha genel hale getirerek, kötü adam bileşenlerinde sorunsuz bir şekilde kullanılabilmesini sağlamak istiyoruz. Kısacası, useCRUDhero hook'larını useCRUDentity ile değiştireceğiz.
useDeleteEntity, useDeleteHero yerine geçer.
useEntityParams, useHeroParams yerine geçer..
useGetEntities, useGetHeroes yerine geçer.
usePostEntity, usePostHero yerine geçer..
usePutEntity, usePutHero yerine geçer..
Kahraman bileşenlerini güncelleyin
Hook'ları değiştirdikten sonra, kahraman bileşenleri küçük değişikliklere ihtiyaç duyar.
Uygulama ayrıntıları test etmeyle ilgili not
Cypress e2e, CT veya RTL testlerinde MSW ile herhangi bir uygulama ayrıntısı test etmediğimiz için bu testlerde herhangi bir değişiklik yapmaya gerek yoktur. Şu anki halleriyle çalışacaklardır.
cy.intercept ve MSW ile, ağ isteğinin dışarı çıktığını kontrol ettik ve işlemi gerçekleştiren hook'un çağrıldığını kontrol ettik. Bu nedenle, hook'ları değiştirmek, testlerin veya işlevselliğin üzerinde herhangi bir etkisi olmamıştır. İşte bu yüzden biraz daha yüksek bir soyutlama düzeyinde test etmek istiyoruz ve uygulama ayrıntılarına karşı uygulamanın sonuçlarını doğrulamak istiyoruz.
E2e komutlarını daha genel hale getirin
Kötü adamların e2e testleri tamamen aynı görünecek, ancak komutlarımız kahramanlara özgüdür. Aynı zamanda daha genel hale getirilebilirler, böylece kahramanlar grubundaki testleri kötü adamlara yansıtmak daha kolay olur.
Komutlar dosyasında, hero ifadelerini entity ile değiştiriyoruz ve türler için villain çeşitlerini hero yanına ekliyoruz. Bu değişiklik, tür tanımları ve e2e testlerine küçük güncellemeler gerektirecektir.
Kahramanların kötü adamların aynası oluşturun
Kitabın temasına uygun olarak, önce kötü adamlarla ilgili testler oluşturacağız.
Yerel kahramanlar test kopyanız biraz farklıysa, isteğe bağlı olarak onları düzenleyebilirsiniz.
E2e testlerinin aynasını oluşturun
Kahraman versiyonlarının kötü adamların aynası olan 3 yeni e2e testi oluşturuyoruz. Ayrıca, kalan testleri kötü adamlarla ilgili özellikleri kontrol etmek için geliştiriyoruz.
Arka uç testi, kötü adamları kapsamak için yeni bir bloğa ihtiyaç duyar.
Yönlendirme-gezinme, kötü adamlar rotasını kapsamak için yeni bir teste ihtiyaç duyar.
Cypress bileşen testlerinin aynasını oluşturun.
cypress/fixtures/villains.json adresinde kötü adamlar için yeni bir düzeltme oluşturun.
RTL testlerinin aynasını oluşturun.
App.cy.tsx ve App.test.tsx'yi geliştirin.
3 kahraman bileşenini kötü adam bileşenlerine yansıtın
Kötü adamlar için 3 bileşen oluşturuyoruz, kahramanlar grubunu olduğu gibi yansıtıyoruz.
// 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();
});
});