Yeni özelliklerden daha iyi anlamak için, 3 bileşen - ErrorComp, PageSpinner, Spinner - ve HeroList için yeni bir arama filtresi özelliği ekliyoruz. Suspense ve ErrorBoundary kullanım durumları için bileşenlere ihtiyacımız olacak.
HeroList için kahramanların isimlerine veya açıklamalarına göre arama ve filtreleme yapabilecek yeni bir özellik istiyoruz, böylece daha sonra yeni React 18 kancaları useTransition ve useDeferredValue için bir kullanım durumu elde edebiliriz. Bunun için HeroList.cy.tsx dosyasına yeni bir test ekleyelim. Bir kahramanın adını veya açıklamasını aramak için yazdığımızda, listede sadece o kahramanı almalıyız. Tüm testler için aynı olan dağıtımı beforeEach test kancasına da taşıyabiliriz (Kırmızı 1).
Arama alanına yazarken, kahraman verilerini isim veya açıklama listesinde mevcut olanlara göre filtrelemek istiyoruz. heroes verisini zaten bir özellik olarak alırız, bunu useState ile yönetebiliriz:
Şimdi bu durumu filtreleme mantığıyla ayarlamamız gerekiyor. İşte bize bunu yapmada yardımcı olan iki işlev:
typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the name or the description exists in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnsetFilteredHeroes(searchProperties(searchField, data)); };
heroes.map ile listeyi işlemek yerine, filteredHeroes kullanırız; bu, bir değişiklik olayında handleSearch tarafından ayarlanır.
// src/heroes/HeroList.tsximport { 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, useState } from"react";import { Hero } from"models/Hero";typeHeroListProps= { heroes:Hero[];handleDeleteHero: (hero:Hero) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionHeroList({ heroes, handleDeleteHero }:HeroListProps) {const [filteredHeroes,setFilteredHeroes] =useState(heroes);constnavigate=useNavigate();// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectHero= (heroId:string) => () => {consthero=heroes.find((h:Hero) =>h.id === heroId);navigate(`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}` ); };typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the name or the description exists in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnsetFilteredHeroes(searchProperties(searchField, data)); };return ( <div> <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(heroes)} /> </div> <uldata-cy="hero-list"className="list"> {filteredHeroes.map((hero, index) => ( <lidata-cy={`hero-list-item-${index}`} key={hero.id}> <divclassName="card"> <CardContentname={hero.name} description={hero.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteHero(hero)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectHero(hero.id)} /> </footer> </div> </li> ))} </ul> </div> );}
Başka bileşenlerde kullanılan bir bileşene özellik ekledik. Büyük özellikler eklerken, gerileme olup olmadığını kontrol etmek için CT ve e2e test paketlerinin tamamını çalıştırmak önemlidir; yarn cy:run-ct, yarn cy:run-e2e. Teoride, hiçbir şey ters gitmemeli. Bileşen hataları yok. Ancak delete-hero e2e testi, silme işleminden sonra yeni eklenen kahramanı temizlemiyor; güncellenmiş kahraman listesini görmek için yenilememiz gerekiyor. Kırılgan bir ünleri olsa bile, iyi yazılmış, kararlı e2e testlerinin yüksek hata bulma yeteneği vardır ve daha küçük bir odakta fark edilmeyen hataları yakalar.
Hataları gidermek için, heroes değiştiğinde HeroListi yeniden işlememiz gerekiyor. Bu, bağımlılık dizisindeki heroes ve useEffect ile elde edilir (Yeşil 1).
// src/heroes/HeroList.tsximport { 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, startTransition, useEffect, useState,} from"react";import { Hero } from"models/Hero";typeHeroListProps= { heroes:Hero[];handleDeleteHero: (hero:Hero) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionHeroList({ heroes, handleDeleteHero }:HeroListProps) {const [filteredHeroes,setFilteredHeroes] =useState(heroes);constnavigate=useNavigate();// needed to refresh the list after deleting a herouseEffect(() =>setFilteredHeroes(heroes), [heroes]);// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectHero= (heroId:string) => () => {consthero=heroes.find((h:Hero) =>h.id === heroId);navigate(`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}` ); };typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the name or the description exists in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnsetFilteredHeroes(searchProperties(searchField, data)); };return ( <div> <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(heroes)} /> </div> <uldata-cy="hero-list"className="list"> {filteredHeroes.map((hero, index) => ( <lidata-cy={`hero-list-item-${index}`} key={hero.id}> <divclassName="card"> <CardContentname={hero.name} description={hero.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteHero(hero)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectHero(hero.id)} /> </footer> </div> </li> ))} </ul> </div> );}
useDeferredValue ve useTransition ile Eşzamanlılık
Eşzamanlılık kavramı, React 18'de yenidir. Birden fazla durum güncellemesi eşzamanlı olarak gerçekleşirken, Eşzamanlılık, UI tepki süresini optimize etmek amacıyla bazı durum güncellemelerinin diğerlerinden daha düşük önceliğe sahip olmasını ifade eder. useDeferredValue ve useTransition kancaları React 18'de yenidir. Uygulamamızda gerekli değiller, ancak yavaş bir bağlantıda büyük miktarda veri yüklerken nerede uygun olabileceklerini göstereceğiz.
useTransition() ile hangi durum güncellemelerinin diğer tüm durum güncellemelerinden daha düşük önceliğe sahip olduğunu belirleyebiliriz.
isPending boolean bir değerdir ve düşük öncelikli durum güncellemesinin hala beklemede olup olmadığını belirtir.
startTransition düşük öncelikli durum güncellemesini sarmalayan bir işlemdir.
HeroList bileşenimizde, setFilteredHeroes düşük öncelikli bir durum güncellemesi olarak kabul edilebilir. Bu, kahraman listesi çok büyük ve ağ çok yavaş olduğunda, arama filtresi girişinin listeyi hala yüklerken duyarlı kalmasını sağlar.
İlk değişiklik, handleSearchın dönüş bölümündedir. startTransition, setFilteredHeroes döndüren bir işleve sarar.
İşte HeroList bileşenine yapılan useTransition güncellemeleri.
// src/heroes/HeroList.tsximport { 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,} from"react";import { Hero } from"models/Hero";typeHeroListProps= { heroes:Hero[];handleDeleteHero: (hero:Hero) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionHeroList({ heroes, handleDeleteHero }:HeroListProps) {const [filteredHeroes,setFilteredHeroes] =useState(heroes);constnavigate=useNavigate();const [isPending,startTransition] =useTransition();// needed to refresh the list after deleting a herouseEffect(() =>setFilteredHeroes(heroes), [heroes]);// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectHero= (heroId:string) => () => {consthero=heroes.find((h:Hero) =>h.id === heroId);navigate(`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}` ); };typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the name or the description exists in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnstartTransition(() =>setFilteredHeroes(searchProperties(searchField, data)) ); };return ( <divstyle={{ opacity: isPending ?0.5:1, }} > <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(heroes)} /> </div> <uldata-cy="hero-list"className="list"> {filteredHeroes.map((hero, index) => ( <lidata-cy={`hero-list-item-${index}`} key={hero.id}> <divclassName="card"> <CardContentname={hero.name} description={hero.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteHero(hero)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectHero(hero.id)} /> </footer> </div> </li> ))} </ul> </div> );}
useTransition ile düşük öncelikli kod üzerinde tam kontrol sağlarız. Bazen, verinin dışarıdan bir özellik olarak veya dış koddan geldiği durumlar gibi, tam kontrol sağlayamayabiliriz. Bu tür durumlarda useDeferredValue kullanabiliriz. Durum güncelleme kodunu useTransition ile sarmalamanın aksine, useDeferredValue ile etkilenen son değeri sararız. useTransition ve useDeferredValue sonuçları aynıdır; React'e hangi düşük öncelikli durum güncellemelerinin olduğunu söyleriz.
Durum güncelleme koduna erişiminiz varsa, useTransition kullanın. Koda erişiminiz yoksa, sadece son değere erişiminiz varsa, useDeferredValue kullanın.
HeroList bileşenimizde, hero verisi bir özellik olarak gelmekte ve bu durum useDeferredValue için iyi bir adaydır.
// src/heroes/HeroList.tsximport { 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 { Hero } from"models/Hero";typeHeroListProps= { heroes:Hero[];handleDeleteHero: (hero:Hero) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionHeroList({ heroes, handleDeleteHero }:HeroListProps) {constdeferredHeroes=useDeferredValue(heroes);constisStale= deferredHeroes !== heroes;const [filteredHeroes,setFilteredHeroes] =useState(deferredHeroes);constnavigate=useNavigate();const [isPending,startTransition] =useTransition();// needed to refresh the list after deleting a herouseEffect(() =>setFilteredHeroes(deferredHeroes), [deferredHeroes]);// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectHero= (heroId:string) => () => {consthero=deferredHeroes.find((h:Hero) =>h.id === heroId);navigate(`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}` ); };typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the name or the description exists in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnstartTransition(() =>setFilteredHeroes(searchProperties(searchField, data)) ); };return ( <divstyle={{ opacity: isPending ?0.5:1, color: isStale ?"dimgray":"black", }} > <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(deferredHeroes)} /> </div> <uldata-cy="hero-list"className="list"> {filteredHeroes.map((hero, index) => ( <lidata-cy={`hero-list-item-${index}`} key={hero.id}> <divclassName="card"> <CardContentname={hero.name} description={hero.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteHero(hero)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectHero(hero.id)} /> </footer> </div> </li> ))} </ul> </div> );}
Koşullu işlem başka bir ipucu verir; veri yoksa arama çubuğuna ihtiyacımız var mı? Başarısız bir testle başlayarak bu özelliği ekleyelim. HeroList.cy.tsx dosyasını biraz yeniden düzenleyeceğiz, böylece testi iki bağlamda yakalayabiliriz; kahraman verisi olmadan monte etme ve kahraman verisiyle monte etme (Kırmızı 2).
// src/heroes/HeroList.cy.tsximport { BrowserRouter } from"react-router-dom";import HeroList from"./HeroList";import"../styles.scss";import heroes from"../../cypress/fixtures/heroes.json";describe("HeroList", () => {it("no heroes should not display a list nor search bar", () => {cy.mount( <BrowserRouter> <HeroListheroes={[]}handleDeleteHero={cy.stub().as("handleDeleteHero")} /> </BrowserRouter> );cy.getByCy("hero-list").should("exist");cy.getByCyLike("hero-list-item").should("not.exist");cy.getByCy("search").should("not.exist"); });context("with heroes in the list", () => {beforeEach(() => {cy.mount( <BrowserRouter> <HeroListheroes={heroes}handleDeleteHero={cy.stub().as("handleDeleteHero")} /> </BrowserRouter> ); });it("should render the hero layout", () => {cy.getByCyLike("hero-list-item").should("have.length",heroes.length);cy.getByCy("card-content");cy.contains(heroes[0].name);cy.contains(heroes[0].description);cy.get("footer").first().within(() => {cy.getByCy("delete-button");cy.getByCy("edit-button"); }); });it("should search and filter hero by name and description", () => {cy.getByCy("search").type(heroes[0].name);cy.getByCyLike("hero-list-item").should("have.length",1).contains(heroes[0].name);cy.getByCy("search").clear().type(heroes[2].description);cy.getByCyLike("hero-list-item").should("have.length",1).contains(heroes[2].description); });it("should handle delete", () => {cy.getByCy("delete-button").first().click();cy.get("@handleDeleteHero").should("have.been.called"); });it("should handle edit", () => {cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/"+ heroes[0].id); }); });});
Testi tatmin etmek için, arama çubuğu için koşullu işlem yapmamız yeterlidir.
// src/heroes/HeroList.tsximport { 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 { Hero } from"models/Hero";typeHeroListProps= { heroes:Hero[];handleDeleteHero: (hero:Hero) => (e:MouseEvent<HTMLButtonElement>) =>void;};exportdefaultfunctionHeroList({ heroes, handleDeleteHero }:HeroListProps) {constdeferredHeroes=useDeferredValue(heroes);constisStale= deferredHeroes !== heroes;const [filteredHeroes,setFilteredHeroes] =useState(deferredHeroes);constnavigate=useNavigate();const [isPending,startTransition] =useTransition();// needed to refresh the list after deleting a herouseEffect(() =>setFilteredHeroes(deferredHeroes), [deferredHeroes]);// currying: the outer fn takes our custom arg and returns a fn that takes the eventconsthandleSelectHero= (heroId:string) => () => {consthero=deferredHeroes.find((h:Hero) =>h.id === heroId);navigate(`/heroes/edit-hero/${hero?.id}?name=${hero?.name}&description=${hero?.description}` ); };typeHeroProperty=Hero["name"] |Hero["description"] |Hero["id"];/** returns a boolean whether the hero properties exist in the search field */constsearchExists= (searchProperty:HeroProperty, 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 */constsearchProperties= (searchField:string, data:Hero[]) => [...data].filter((item:Hero) =>Object.values(item).find((property:HeroProperty) =>searchExists(property, searchField) ) );/** filters the heroes data to see if the any of the properties exist in the list */consthandleSearch= (data:Hero[]) => (event:ChangeEvent<HTMLInputElement>) => {constsearchField=event.target.value;returnstartTransition(() =>setFilteredHeroes(searchProperties(searchField, data)) ); };return ( <divstyle={{ opacity: isPending ?0.5:1, color: isStale ?"dimgray":"black", }} > {deferredHeroes.length>0&& ( <divclassName="card-content"> <span>Search </span> <inputdata-cy="search"onChange={handleSearch(deferredHeroes)} /> </div> )} <uldata-cy="hero-list"className="list"> {filteredHeroes.map((hero, index) => ( <lidata-cy={`hero-list-item-${index}`} key={hero.id}> <divclassName="card"> <CardContentname={hero.name} description={hero.description} /> <footerclassName="card-footer"> <ButtonFooterlabel="Delete"IconClass={FaRegSave}onClick={handleDeleteHero(hero)} /> <ButtonFooterlabel="Edit"IconClass={FaEdit}onClick={handleSelectHero(hero.id)} /> </footer> </div> </li> ))} </ul> </div> );}
Suspense & ErrorBoundary
Kurulum
Uygulamanın başlangıcında yüklenen kod miktarını - başlangıç paketini - yönetmek için, UI tepki süresini artırmak amacıyla uygulamanın kodunu parçalara bölen ve yüklemeyi gerçekleştiren kod bölme işlemi kullanılabilir. React'te, Suspense ve tembel yükleme, kod bölme işlemini gerçekleştirmek için kullanılır. Genellikle ErrorBoundary ile birlikte anılırlar, çünkü Suspense ve ErrorBoundary bileşenleri, yükleme ve hata UI'sını bireysel bileşenlerden ayırmamıza olanak tanır. İşte ana fikirler:
Yükleme sırasında Suspense bileşenini göster, hata durumunda ErrorBoundary bileşenini göster, başarılı durumda ise göstermek istediğimiz bileşeni göster.
İlk kez işlem gördüklerinde bileşenleri yüklemek için React.lazy kullanın.
Bir bileşeni lazy işlevi ile tembel bir bileşene dönüştürme:
Suspense ve ErrorBoundary bileşenlerini, ağacında bir veya daha fazla tembel bileşen içeren UI'ı sarmak için kullanın. İşte yüksek düzeyde nasıl birlikte çalıştıkları:
useGetHeroes kancasını, yapılandırma için üçüncü bir argüman alacak şekilde güncelleyin. {suspense: true} geçmek, react-query'deki useQuery için Suspense modunu etkinleştirir.
useGetHeroes kancasını, yapılandırma için üçüncü bir argüman alacak şekilde güncelleyin. {suspense: true} geçmek, react-query'deki useQuery için Suspense modunu etkinleştirir.
Şimdi hata kenar durumları için başarısız testler yazmaya başlayabiliriz. Nereden başlarız? Pozitif durumları kapsayan, cy.intercept() ile ağı izlemeye alan veya ağı taklit eden herhangi bir bileşen testi iyi bir adaydır. Bunlar HeroDetail ve Heroes bileşen testleridir.
HeroList bileşen testine 200 olmayan bir senaryo için test ekleyin. İşlemcinin görünmesini sağlamak için gecikme seçeneği kullanırız (Kırmızı 3).
HeroDetail bileşenine bakarak, usePostHero ve usePutHero kancalarından status ve isUpdating elde edebiliriz, bunları kullanabiliriz. Bu durumlar görülürse, PageSpinner işlemcisini işleme alın (Yeşil 2).
Bileşen testini çalıştırarak, Cypress zaman yolculuğu hata ayıklama işleminin üzerine gelerek işlemciyi doğrulayabiliriz. Ayrıca taklit edilmiş POST isteğinin gittiğini de fark ederiz, bunu bir cy.wait() ile doğrulayabiliriz (Yeniden düzenleme 2). Cypress zaman yolculuğu hata ayıklayıcısı aracılığıyla bir bileşenin tüm geçişlerini görebilmek, testlerimizi geliştirmemize yardımcı olabilir.
Bileşen testinde güncelleme senaryosunu kontrol etmenin herhangi bir yolu yoktur, çünkü arka uçta bir değişikliği tetikleyecek böyle bir durumu kuramayız. Düşük düzeyde bileşen testleri ile bir testi kapsayamadığımız her durumda, ui-entegrasyon testlerine geçin. Çoğu zaman bir ui-entegrasyon testi yeterli olacak ve yeterli olmadığında arka ucu etkileyen gerçek bir e2e testi kullanabiliriz. Bizim durumumuzda, ui-entegrasyon tercih edilir, çünkü arka uç tarafından 500 yanıtı ile cevap vermenin zor olacağından. Ayrıca gerçek bir ağdan gelen yanıta ihtiyacımız yoktur. Bu nedenle, güncelleme senaryosunu kapsayan edit-hero.cy.ts e2e testine bir ui-entegrasyon testi ekleyebiliriz. Test türleri arasındaki sınırların daha ince olduğunu görüyoruz; en yüksek güveni elde etmek için en düşük maliyetli test türünü kullanırız. Piramitte nerede oldukları, yalnızca verilen bağlamda bu tür testi gerçekleştirme yeteneğiyle ilgilidir (Düzenleme 4).
Yeni test, diğer ui-entegrasyon testlerine benzerdir; ağı taklit ederiz ve ana rotayı ziyaret ederiz. Rastgele bir kahramanın düzenleme sayfasına gideriz. Güncelleme üzerinden gerçekleşecek olan ağ taklidini cy.intercept ile ayarlarız. Sonunda, bileşen testinden benzer bir işlemci -> ağı bekletme -> hata akışını tekrarlarız. Buradaki tek ayrım, PUT ve POST arasındadır.
it("should go through the PUT error flow (ui-integration)", () => {// Arrange: beginning statecy.visitStubbedHeroes();// verify that we are editing data already populatedcy.fixture("heroes").then((heroes) => {constheroIndex=randomHeroIndex(heroes);cy.getByCy("edit-button").eq(heroIndex).click();verifyHero(heroes, heroIndex); });// setup network stubcy.intercept("PUT",`${Cypress.env("API_URL")}/heroes/*`, { statusCode:500, delay:100, }).as("isUpdateError");// Actcy.getByCy("save-button").click();// Assertcy.getByCy("spinner");cy.wait("@isUpdateError");cy.getByCy("error");});
Karşılaştırma için bileşen testiyle yan yana buradadır. Eylem ve İddia aynıdır, ağ taklidi POST ve PUT ile url'yi belirtme ihtiyacının daha az olmasıdır. Özellikle Arrange'ın kurulumu farklıdır.
Yine de Axios yeniden denemeleri uzun sürüyor ve hiçbir şey işlemiyor. Ağ hatalarını cy.clock ve cy.tick kullanarak hızlandırabiliriz. Ayrıca Cypress'e yakalanmamış istisnaların beklendiğini Cypress.on('uncaught:exception', () => false) kullanarak söylüyoruz.
// src/heroes/Heroes.cy.tsxit.only("should go through the error flow", () => {Cypress.on("uncaught:exception", () =>false);cy.clock();cy.intercept("GET",`${Cypress.env("API_URL")}/heroes`, { statusCode:400, delay:100, }).as("notFound");mounter(newQueryClient());Cypress._.times(3, () => {cy.tick(5000);cy.wait("@notFound"); });cy.tick(5000);cy.getByCy("error");});
Bu değişiklikten sonra, kalan tek başarısızlık data-cy işlememesidir.
Bileşen testinin bağımsız, küçük ölçekli bir uygulama olduğunu unutmamalıyız. Uygulamamız temel düzeyde sarılıyor ve bununla birlikte ErrorBoundary ve Suspense, App altındaki her bileşene uygulanabiliyor. Bu nedenle, monte edilmiş bileşenimizi de sarmamız gerekiyor (Yeşil 5).
Bunu, herhangi bir bileşende içe aktarmadan kullanabileceğimiz bir Cypress komutu olarak yapmak en iyisidir. App.cy.tsx dışındaki bileşen test süitindeki çoğu cy.mountı değiştirebiliriz. Özel montaj gerekmeyen durumlar bile olsa, ek sargılar zarar vermez. ./cypress/support/component.ts dosyasını tsx dosyasına değiştirin. Komut sürümündeki wrappedMount ile cy.mountı daha iyi hizalıyoruz.
/** 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>
İşte cy.wrappedMount ile yapılan testin nihai sürümü. Hata öncesinde dönen çarkı kontrol etmeyi de ekledik (Yeniden Düzenleme 5). İsteğe bağlı olarak cy.wrappedMount düzenlemesini bazı bileşen testlerine uygulayabilirsiniz:
src/components/Heroes.cy.tsx
src/components/HeroList.cy.tsx
src/components/HeroDetail.cy.tsx
Kullanışlı olmasa da hâlâ mümkün (sadece BrowserRouter'ı kılıf olarak kullanırlar):
src/components/HeaderBar.cy.tsx
src/components/HeaderBarBrand.cy.tsx
src/components/ListHeader.cy.tsx
src/components/NavBar.cy.tsx
// src/heroes/Heroes.cy.tsximport Heroes from"./Heroes";import"../styles.scss";describe("Heroes", () => {it("should see error on initial load with GET", () => {Cypress.on("uncaught:exception", () =>false);cy.clock();cy.intercept("GET",`${Cypress.env("API_URL")}/heroes`, { statusCode:400, delay:100, }).as("notFound");cy.wrappedMount(<Heroes />);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")}/heroes`, { fixture:"heroes.json", }).as("getHeroes");cy.wrappedMount(<Heroes />); });it("should display the hero list on render, and go through hero add & refresh flow", () => {cy.wait("@getHeroes");cy.getByCy("list-header").should("be.visible");cy.getByCy("hero-list").should("be.visible");cy.getByCy("add-button").click();cy.location("pathname").should("eq","/heroes/add-hero");cy.getByCy("refresh-button").click();cy.location("pathname").should("eq","/heroes"); });constinvokeHeroDelete= () => {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");invokeHeroDelete();cy.getByCy("button-no").click();cy.getByCy("modal-yes-no").should("not.exist");cy.log("delete flow");invokeHeroDelete();cy.intercept("DELETE","*", { statusCode:500 }).as("deleteHero");cy.getByCy("button-yes").click();cy.wait("@deleteHero");cy.getByCy("modal-yes-no").should("not.exist");cy.getByCy("error").should("be.visible"); }); });});
// src/heroes/HeroList.cy.tsximport HeroList from"./HeroList";import"../styles.scss";import heroes from"../../cypress/fixtures/heroes.json";describe("HeroList", () => {it("no heroes should not display a list nor search bar", () => {cy.wrappedMount( <HeroListheroes={[]}handleDeleteHero={cy.stub().as("handleDeleteHero")} /> );cy.getByCy("hero-list").should("exist");cy.getByCyLike("hero-list-item").should("not.exist");cy.getByCy("search").should("not.exist"); });context("with heroes in the list", () => {beforeEach(() => {cy.wrappedMount( <HeroListheroes={heroes}handleDeleteHero={cy.stub().as("handleDeleteHero")} /> ); });it("should render the hero layout", () => {cy.getByCyLike("hero-list-item").should("have.length",heroes.length);cy.getByCy("card-content");cy.contains(heroes[0].name);cy.contains(heroes[0].description);cy.get("footer").first().within(() => {cy.getByCy("delete-button");cy.getByCy("edit-button"); }); });it("should search and filter hero by name and description", () => {cy.getByCy("search").type(heroes[0].name);cy.getByCyLike("hero-list-item").should("have.length",1).contains(heroes[0].name);cy.getByCy("search").clear().type(heroes[2].description);cy.getByCyLike("hero-list-item").should("have.length",1).contains(heroes[2].description); });it("should handle delete", () => {cy.getByCy("delete-button").first().click();cy.get("@handleDeleteHero").should("have.been.called"); });it("should handle edit", () => {cy.getByCy("edit-button").first().click();cy.location("pathname").should("eq","/heroes/edit-hero/"+ heroes[0].id); }); });});
RTL'deki cy.wrappedMount'ı yansıtmak için, src/test-utils.tsx'de özel bir render oluşturun. Bu dosya ayrıca '@testing-library/react'i dışa aktarır, böylece wrappedRender'ı kullanıyorsak buradan screen, userEvent, waitFor'i içe aktarabiliriz. Bileşen testlerine benzer şekilde, wrappedRenderheroes klasöründeki 3 bileşende en yararlı olanıdır.
// src/heroes/HeroList.test.tsximport HeroList from"./HeroList";import { wrappedRender, screen, waitFor } from"test-utils";import userEvent from"@testing-library/user-event";import { heroes } from"../../db.json";describe("HeroList", () => {consthandleDeleteHero=jest.fn();it("no heroes should not display a list nor search bar",async () => {wrappedRender(<HeroListheroes={[]} handleDeleteHero={handleDeleteHero} />);expect(awaitscreen.findByTestId("hero-list")).toBeInTheDocument();expect(screen.queryByTestId("hero-list-item-1")).not.toBeInTheDocument();expect(screen.queryByTestId("search-bar")).not.toBeInTheDocument(); });describe("with heroes in the list", () => {beforeEach(() => {wrappedRender( <HeroListheroes={heroes} handleDeleteHero={handleDeleteHero} /> ); });constcardContents=async () =>screen.findAllByTestId("card-content");constdeleteButtons=async () =>screen.findAllByTestId("delete-button");consteditButtons=async () =>screen.findAllByTestId("edit-button");it("should render the hero layout",async () => {expect(awaitscreen.findByTestId(`hero-list-item-${heroes.length-1}`) ).toBeInTheDocument();expect(awaitscreen.findByText(heroes[0].name)).toBeInTheDocument();expect(awaitscreen.findByText(heroes[0].description) ).toBeInTheDocument();expect(awaitcardContents()).toHaveLength(heroes.length);expect(awaitdeleteButtons()).toHaveLength(heroes.length);expect(awaiteditButtons()).toHaveLength(heroes.length); });it("should search and filter hero by name and description",async () => {constsearch=awaitscreen.findByTestId("search");userEvent.type(search, heroes[0].name);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1));awaitscreen.findByText(heroes[0].name);userEvent.clear(search);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(heroes.length) );userEvent.type(search, heroes[2].description);awaitwaitFor(async () =>expect(awaitcardContents()).toHaveLength(1)); });it("should handle delete",async () => {userEvent.click((awaitdeleteButtons())[0]);expect(handleDeleteHero).toHaveBeenCalled(); });it("should handle edit",async () => {userEvent.click((awaiteditButtons())[0]);awaitwaitFor(() =>expect(window.location.pathname).toEqual("/heroes/edit-hero/"+ heroes[0].id ) ); }); });});
msw ile - RTL kullanımı için cy.intercept düşünün - uygulamadan çıkan XHR çağrılarını doğrulamak önerilmez. Bunun yerine, kullanıcı arayüzündeki değişiklikleri doğrulamak önerilir. Ne yazık ki, bazen bileşende kendisinde değişiklik olmaz, bu nedenle her Cypress bileşen testini RTL ile 1:1 yansıtamayız. İşte HeroDetail.cy.tsx'in RTL yansıması.
Alternatif olarak, react-query kancalarını takip etmeyi ve çağrıldıklarını doğrulamayı düşünebiliriz. Bu, çoğu geliştiricinin alışkın olduğu şey olsa da, durum yönetimi yaklaşımımızda yapılan değişiklikler testlerin başarısız olmasına neden olacağı için uygulama ayrıntısıdır.
// src/heroes/HeroDetail.test.tsximport HeroDetail from"./HeroDetail";import { wrappedRender, act, screen, waitFor } from"test-utils";import userEvent from"@testing-library/user-event";describe("HeroDetail", () => {beforeEach(() => {wrappedRender(<HeroDetail />); });// should handle Save and should handle non-200 Save have no RTL mirrors// because of difference between msw and cy.interceptit("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 workact(() =>screen.getByTestId("cancel-button").click());expect(window.location.pathname).toBe("/heroes"); });it("should handle name change",async () => {constnewHeroName="abc";constinputDetailName=awaitscreen.findByPlaceholderText("e.g. Colleen");userEvent.type(inputDetailName, newHeroName);awaitwaitFor(async () =>expect(inputDetailName).toHaveDisplayValue(newHeroName) ); });constinputDetailDescription=async () =>screen.findByPlaceholderText("e.g. dance fight!");it("should handle description change",async () => {constnewHeroDescription="123";userEvent.type(awaitinputDetailDescription(), newHeroDescription);awaitwaitFor(async () =>expect(awaitinputDetailDescription()).toHaveDisplayValue( newHeroDescription ) ); });it("id: false, name: false - should verify the minimal state of the component",async () => {expect(awaitscreen.findByTestId("input-detail-name")).toBeVisible();expect(awaitscreen.findByTestId("input-detail-description")).toBeVisible();expect(screen.queryByTestId("input-detail-id")).not.toBeInTheDocument();expect(awaitinputDetailDescription()).toBeVisible();expect(awaitscreen.findByTestId("save-button")).toBeVisible();expect(awaitscreen.findByTestId("cancel-button")).toBeVisible(); });});
// src/heroes/Heroes.test.tsximport Heroes from"./Heroes";import { wrappedRender, screen, waitForElementToBeRemoved } from"test-utils";import userEvent from"@testing-library/user-event";import { rest } from"msw";import { setupServer } from"msw/node";import { heroes } from"../../db.json";describe("Heroes", () => {// mute the expected console.error message, because we are mocking non-200 responses// eslint-disable-next-line @typescript-eslint/no-empty-functionjest.spyOn(console,"error").mockImplementation(() => {});beforeEach(() =>wrappedRender(<Heroes />));it("should see error on initial load with GET",async () => {consthandlers= [rest.get(`${process.env.REACT_APP_API_URL}/heroes`,async (_req, res, ctx) =>res(ctx.status(500)) ), ];constserver=setupServer(...handlers);server.listen({ onUnhandledRequest:"warn", });jest.useFakeTimers();expect(awaitscreen.findByTestId("page-spinner")).toBeVisible();jest.advanceTimersByTime(25000);awaitwaitForElementToBeRemoved( () =>screen.queryByTestId("page-spinner"), { timeout:25000, } );expect(awaitscreen.findByTestId("error")).toBeVisible();jest.useRealTimers();server.resetHandlers();server.close(); });describe("200 flows", () => {consthandlers= [rest.get(`${process.env.REACT_APP_API_URL}/heroes`,async (_req, res, ctx) =>res(ctx.status(200),ctx.json(heroes)) ),rest.delete(`${process.env.REACT_APP_API_URL}/heroes/${heroes[0].id}`,// use /.*/ for all requestsasync (_req, res, ctx) =>res(ctx.status(400),ctx.json("expected error")) ), ];constserver=setupServer(...handlers);beforeAll(() => {server.listen({ onUnhandledRequest:"warn", }); });afterEach(server.resetHandlers);afterAll(server.close);it("should display the hero list on render, and go through hero add & refresh flow",async () => {expect(awaitscreen.findByTestId("list-header")).toBeVisible();expect(awaitscreen.findByTestId("hero-list")).toBeVisible();awaituserEvent.click(awaitscreen.findByTestId("add-button"));expect(window.location.pathname).toBe("/heroes/add-hero");awaituserEvent.click(awaitscreen.findByTestId("refresh-button"));expect(window.location.pathname).toBe("/heroes"); });constdeleteButtons=async () =>screen.findAllByTestId("delete-button");constmodalYesNo=async () =>screen.findByTestId("modal-yes-no");constmaybeModalYesNo= () =>screen.queryByTestId("modal-yes-no");constinvokeHeroDelete=async () => {userEvent.click((awaitdeleteButtons())[0]);expect(awaitmodalYesNo()).toBeVisible(); };it("should go through the modal flow, and cover error on DELETE",async () => {expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument();awaitinvokeHeroDelete();awaituserEvent.click(awaitscreen.findByTestId("button-no"));expect(maybeModalYesNo()).not.toBeInTheDocument();awaitinvokeHeroDelete();awaituserEvent.click(awaitscreen.findByTestId("button-yes"));expect(maybeModalYesNo()).not.toBeInTheDocument();expect(awaitscreen.findByTestId("error")).toBeVisible();expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument(); }); });});
Özet
Bu bölümde kullanılacak yeni bileşenler ekledik.
Kahraman arama / filtreleme özelliği için yeni bir test ekledik (Kırmızı 1).
HeroList bileşenine uygulamayı ekledik ve CT veya e2e regresyonlarının olmadığından emin olduk (Yeşil 1).
Bileşeni, üzerinde kontrol sahibi olduğumuz kodu (setFilteredHeroes) sarmak için useTransition ve üzerinde kontrol sahibi olmadığımız değeri sarmak için (heroes değeri prop olarak geçirilir) useDeferredValue ile geliştirdik (Yeniden düzenleme 1).
Arama-filtreleme için koşullu render ekledik (Kırmızı 2, Yeşil 2)
Uygulamayı Suspense ve ErrorBoundary için yapılandırdık
HeroDetail bileşeni için 200 olmayan / ağ hatası kenar durumu yazdık. Bu, cy.intercept gecikme seçeneğini kullanarak Suspense kodunu da vurur (Kırmızı 3, Kırmızı 4).
HeroDetail'e, POST isteğiyle meydana gelebilecek yükleme ve hata koşulları için koşullu render ekledik (Yeşil 3, Yeşil 4)
PUT isteği yükleme ve hata koşulunu kapsamak için, gerçek bir ağdan 500 yanıtı almak zorunda olmamasına rağmen arka uç değişikliği tetikleyebilecek bir durumun farkında olan bir ui-entegrasyon testi kullandık (Yeniden düzenleme 4)
HeroDetail'in üst bileşeni olan Heroes bileşeni için 200 olmayan / ağ hatası kenar durumu yazdık. Verileri almak için GET isteğini kullanır (Kırmızı 5).
Bileşen testini monte etmeyi, kök uygulamanın ErrorBoundary ve Suspense ile sarılma şeklinde gerçekleştirdik. cy.clock, cy.tick'i kullanarak ve beklenen hata atılırken test başarısızlığını kapatarak avantaj sağladık (Yeşil 5).
Dönen çarkı kontrol etmek için bileşen testini geliştirdik. POST isteği hata durumuna benzer şekilde, gerçek bir ağdan 500 yanıtı almak zorunda olmamasına rağmen arka uç değişikliği tetikleyebilecek bir durumun farkında olan bir ui-entegrasyon testinde DELETE için ağ hatası durumunu kapsadık (Yeniden düzenleme 5).
RTL birim testini Suspense ile çalışacak şekilde değiştirdik.
Çıkarılacak Dersler
Büyük özellikler eklerken, regresyon olmadığından emin olmak için CT ve e2e test süitlerini çalıştırmak önemlidir. Küçük artımlı adımlar ve güvenilir testler, hata teşhisini kolaylaştırır.
"Kırılgan" olarak ünleri olsa da, iyi yazılmış, kararlı e2e veya ui-entegrasyon testlerinin yüksek hata bulma yeteneği vardır ve izole bileşenlerde ya da birim testlerde fark edilmeyen hataları yakalar.
Eşzamanlı olarak birden fazla durum güncellemesi gerçekleşirken, Concurrency (Eşzamanlılık), UI yanıt verme hızını optimize etmek amacıyla bazı durum güncellemelerinin diğerlerine göre daha düşük önceliğe sahip olmasını ifade eder. useTransition ve useDeferredValue, daha düşük öncelikli olanı belirtmek için kullanılabilir. Durum güncelleme koduna erişiminiz varsa, useTransition'ı tercih edin ve kodu onunla sarın. Kod erişiminiz yoksa, ancak son değere erişiminiz varsa, değeri sarmak için useDeferredValue kullanın.
Suspense ile lazy yükleme, başlangıç uygulama paketini kod bölme amacıyla kullanılır ve UI yanıt verme hızını artırır. ErrorBoundary ile birlikte, yükleme ve hata UI'sini bireysel bileşenlerden ayırırlar. Yükleme sırasında Suspense bileşenini gösterin, hata durumunda ErrorBoundary bileşenini gösterin, başarı durumunda render etmek istediğimiz bileşeni gösterin.
Hata durumları için test yazmaya başlarken, ağ üzerinde casusluk etmek veya cy.intercept() ile ağı taklit etmek amacıyla pozitif akışları kapsayan herhangi bir test, başlamak için iyi bir adaydır. Bileşen düzeyinde başlayın ve daha fazla test mümkün olmadığında ui-entegrasyona geçin.
Bir bileşen testi ile düşük düzeyde bir testi kapsayamadığımız her zaman, ui-entegrasyon testlerine geçin. Çoğu zaman bir ui-entegrasyon testi yeterli olacak ve yeterli olmadığında, arka ucu etkileyen gerçek bir e2e kullanabiliriz. En yüksek güveni elde etmek için en düşük maliyetli test türünü kullanın; piramitte nerede oldukları, yalnızca verilen bağlamda o tür testi gerçekleştirme yeteneğiyle ilgilidir.
Ağ hatalarını hızlandırmak için cy.clock ve cy.tick kullanabiliriz. Hata durumunu kapsarken, Cypress'a yakalanmamış istisnaların beklendiğini Cypress.on('uncaught:exception', () => false) kullanarak söyleriz.
Bir bileşen testinin bağımsız, küçük ölçekli bir uygulama olduğunu unutmayın. Temel App bileşenini saran ne varsa, bir bileşen testi montajını da sarması gerekebilir (Providers, ErrorBoundary, Suspense, Router vb.).