How to test loading status using Suspense with Mock Service Worker?

128 views Asked by At

I'm trying to add the test to my current frontend project. The project is currently using Vite, React, TypeScript, and test done with Vitest, React Testing Library, and Mock Service Worker.

  • Trying to do: check if MainComponent renders UI correctly.

    • If data is loading, show <CarouselSkeleton />
    • If data is ready, show <Carousel />
  • The problem is when giving a delay to mocked data response, it's not able to find <CarouselSkeleton /> Component.

  • Below is how my MainContent, Carousel, CarouselSkeleton components look like.

MainContent

    const MainContent = () => {
      return (
        <Container aria-label="main-content">
          {categories.map(category => (
            <Suspense key={category} fallback={<CarouselSkeleton category={category} />}>
              <Carousel category={category} />
            </Suspense>
          ))}
        </Container>
      );
    };

Carousel

// ...
const Carousel = ({ category }: { category: string }) => {
  const { data } = useCategorizedRecipes(category);
  const [currentPage, setCurrentPage] = useState(0);
  const observer = useObserver(data);

  const handleClick = (type: string) => () => {
    if (type === 'prev') setCurrentPage(currentPage - 1);
    if (type === 'next') setCurrentPage(currentPage + 1);
  };

  return (
    <Container aria-label={`${category}-recipes-carousel`}>
      <CarouselTitle>{capitalizeFirstLetter(category)}</CarouselTitle>
      <CarouselWindow>
        <CarouselSlides $currentpage={currentPage}>
          {data
            ?.slice(0, CAROUSEL_DATA_SIZE)
            .map((recipe: Recipe) => (
              <RecipeCard
                key={recipe.recipeId}
                recipe={recipe}
                $style={{ margin: '0 1rem' }}
                observer={observer}
              />
            ))}
        </CarouselSlides>
        <IconContainer>
          <PrevIcon
            disabled={currentPage === 0}
            role="button"
            onClick={handleClick('prev')}
            title="previous page"
          />
          {Array.from({ length: 4 }, (_, idx) => idx).map(val => (
            <IndexEmpty key={val} />
          ))}
          <IndexFill $currentpage={currentPage} />
          <NextIcon
            role="button"
            title="next page"
            disabled={
              currentPage === Math.floor(CAROUSEL_DATA_SIZE / CAROUSEL_DATA_SIZE_PER_PAGE) - 1
            }
            onClick={handleClick('next')}
          />
        </IconContainer>
      </CarouselWindow>
    </Container>
  );
};

const Container = styled.section`
  margin: 2rem 0 6rem 0;
  width: 100%;
  height: 28rem;
  position: relative;
`;

const CarouselWindow = styled.div`
  overflow-x: hidden;
  width: calc(15rem * 5 + 1rem * 10 - 0.2rem);
  height: 100%;
  position: relative;
`;

const CarouselTitle = styled.h2`
  font-weight: 400;
  font-size: 2rem;
  padding: 1rem 0;
`;

const CarouselSlides = styled.div<{ $currentpage: number }>`
  display: flex;
  transition: transform 0.4s ease-in;
  transform: ${({ $currentpage }) => `translate3D(calc(${$currentpage} * -100%), 0, 0)`};
`;

interface IconProps {
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
  disabled: boolean;
}

const IconStyle = css<IconProps>`
  cursor: pointer;
  opacity: ${({ disabled }) => (disabled ? '0.2' : '')};
  pointer-events: ${({ disabled }) => (disabled ? 'none' : 'initial')};

  &:hover {
    opacity: ${({ disabled }) => (disabled ? '' : '0.7')};
  }
`;

const PrevIcon = styled(BsFillArrowLeftCircleFill)<IconProps>`
  ${IconStyle}
`;

const NextIcon = styled(BsFillArrowRightCircleFill)<IconProps>`
  ${IconStyle}
`;

const IconContainer = styled.div`
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  margin: 0 auto;
  width: 12rem;
  border-radius: 1rem;
  border: 3px dashed black;

  display: flex;
  align-items: center;
  justify-content: space-between;
`;

const IndexEmpty = styled(GoDot)`
  width: 1.3rem;
  color: var(--font-color);
`;

interface IndexFillProps {
  $currentpage: number;
}

const IndexFill = styled(GoDotFill)<IndexFillProps>`
  width: 1.3rem;
  position: absolute;
  top: 50%;
  left: calc(100% / 4 - 0.4rem);
  color: var(--font-color);

  transform: ${({ $currentpage }) => `translate3d(calc(${$currentpage}*140%), -50%, 0)`};
  transition: transform 0.4s ease-in-out;
`;

export default Carousel;

CarouselSkeleton

// ...
const CarouselSkeleton = ({ category }: { category: string }) => {
  return (
    <Container aria-label={`${category}-skeleton`}>
      <CarouselTitle>{capitalizeFirstLetter(category)}</CarouselTitle>
      <CarouselWindow>
        <CarouselSlides>
          {Array.from({ length: CAROUSEL_DATA_SIZE_PER_PAGE }, (_, idx) => idx).map(val => (
            <SkeletonContainer key={val}>
              <CarouselRecipeCardSkeleton />
            </SkeletonContainer>
          ))}
        </CarouselSlides>
      </CarouselWindow>
    </Container>
  );
};

const Container = styled.section`
  margin: 2rem 0 6rem 0;
  width: 100%;
  height: 28rem;
  position: relative;
`;

const CarouselWindow = styled.div`
  overflow-x: hidden;
  width: calc(15rem * 5 + 1rem * 10 - 0.2rem);
  height: 100%;
  position: relative;
`;

const CarouselTitle = styled.h2`
  font-weight: 400;
  font-size: 2rem;
  padding: 1rem 0;
`;

const CarouselSlides = styled.div`
  display: flex;
`;

const SkeletonContainer = styled.div`
  padding: 0 1rem;
`;

const CarouselRecipeCardSkeleton = styled(Skeleton)`
  width: 15rem;
  min-width: 15rem;
  height: 15rem;
`;

export default CarouselSkeleton;
  • What I've tried: 1. Test Carousel Component based on useQuery's state
describe('Carousel', () => {
  describe('#Request', () => {
    it('displays Carousel Skeleton when recipe data is loading', async () => {
      const IntersectionObserverMock = vi.fn(() => ({
        disconnect: vi.fn(),
        observe: vi.fn(),
        takeRecords: vi.fn(),
        unobserver: vi.fn(),
      }));

      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);

      const { result } = renderHook(() => useCategorizedRecipes('balanced'));

      render(
        <Suspense fallback={<CarouselSkeleton category="balanced" />}>
          <Carousel category="balanced" />
        </Suspense>,
      );

      await waitFor(() => expect(result.current.isLoading).toBe(true));


      // Unable to find role="region" and name `/skeleton/i`
      const skeleton = await screen.findByRole('region', { name: /skeleton/i });
      expect(skeleton).toBeInTheDocument();

      await waitFor(() => expect(result.current.isSuccess).toBe(true));
      console.log('succeeded');
    });
  });
})

2. intercept the call with server.use()

  • It doesn't seem like server.use() is working. Even thought I put it, MainContent still renders Carousel component.
// ...
import { render, screen } from '../../../test-utils/testing-library-utils';
import { EDAMA_BASE_URL } from '../../../tools/tests/commonHandlers';
import { server } from '../../../tools/tests/server';

describe('MainContent', () => {
  describe('#Request', () => {
    it.only('renders <CarouselSkeleton /> when data is loading', async () => {
      server.use(
        http.get(EDAMA_BASE_URL, async () => {
          await delay();

          return HttpResponse.json({ status: 200 });
        }),
      );

      const IntersectionObserverMock = vi.fn(() => ({
        disconnect: vi.fn(),
        observe: vi.fn(),
        takeRecords: vi.fn(),
        unobserver: vi.fn(),
      }));
      vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);

      render(<MainContent />);

      // Unable to find role="region" and name `/skeleton/i
      const carouselSkeleton = await screen.findByRole('region', { name: /skeleton/i });
      expect(carouselSkeleton).toHaveLength(4);
    });

  });
});
  • FYI, below are my setup and util files.

// tools/tests/commonHandler.ts
// ...

export const EDAMA_BASE_URL = 'https://api.edamam.com/api/recipes/v2';

export const hits = Array.from({ length: 50 }, () => recipe).map((recipe, idx) => ({
  recipe,
  _links: {
    self: {
      href: `https://api.edama.com/api/v2/abcde${idx}`,
    },
  },
}));

export const commonHandlers = [
  http.get(EDAMA_BASE_URL, async () => {
    //  Tried this but not working
    // await delay()

    return HttpResponse.json({
      hits,
    });
  }),
];
// tools/tests/server.ts

import { setupServer } from 'msw/node';
import { commonHandlers } from './commonHandlers';

export const server = setupServer(...commonHandlers);
server.events.on('request:start', ({ request }) => {
  //   console.log('Outgoing:', request.method, request.url);
});

// tools/tests/setupTests.ts
/* eslint-disable import/no-extraneous-dependencies */

import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './server';

beforeAll(() => server.listen());

afterEach(() => server.resetHandlers());

afterAll(() => server.close());
// test-utils/testing-library-utils.tsx

// ...

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        cacheTime: 0,
      },
    },
  });

  return function ({ children }: { children: React.ReactNode }) {
    return (
      <RecoilRoot>
        <QueryClientProvider client={queryClient}>
          <Router>{children}</Router>
        </QueryClientProvider>
      </RecoilRoot>
    );
  };
};

type RenderParams = Parameters<typeof render>;
type RenderHookParams = Parameters<typeof renderHook>;

const renderWithContext = (ui: RenderParams[0], options?: RenderParams[1]) =>
  render(ui, { wrapper: createWrapper(), ...options });

const renderHookWithContext = (hook: RenderHookParams[0], options?: RenderHookParams[1]) =>
  renderHook(hook, { wrapper: createWrapper(), ...options });

// re-export everything
export * from '@testing-library/react';

// override render method
export { renderWithContext as render };
export { renderHookWithContext as renderHook };

I wonder which way(server.use() or using React Query's state) is correct, and why is the CarouselSkeleton component is not found.

Thank you, have a good day!

0

There are 0 answers