Search code examples
javascriptreactjstypescriptreact-routerreact-testing-library

How to test React component with useParams?


I'm trying to develop BBS App with TypeScript, React, React Router, React Testing Library, but a component with useParams doesn't pass a test. It looks like work correctly on the browser.

This is my component Thread.tsx

import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import RedichanNav from 'RedichanNav';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'Thread.css';
import Stack from 'react-bootstrap/Stack';
import Nav from 'react-bootstrap/Nav';
import Navbar from 'react-bootstrap/Navbar';
import consola from 'consola';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';

interface Post {
  id: number;
  poster: string;
  text: string;
  UTCTimeStamp: Date;
}

const Thread = (): JSX.Element => {
  const { id } = useParams();
  // id's type is string | undefined.
  if (id === undefined) {
    throw new TypeError(`Expected 'id' to be defined, but received undefined`);
  }

  const threadURI = `http://localhost:4000/api/thread/${id}`;

  const [thread, setThread] = useState(new Array<Post>());

  useEffect(() => {
    const fetchThread = async () => {
      const response = await fetch(threadURI);
      const result = (await response.json()) as Array<Post>;
      setThread(result);
    };
    fetchThread().catch((err) => consola.error(err));
  }, [id]);

  return (
    <div className="Board mx-auto">
      <RedichanNav />
      <Stack className="mb-5">
        {thread.map((post, index) => (
          <Container key={post.id} className="bg-light border">
            <Row>
              <Col>{index}</Col>
            </Row>
            <Row>
              <Col className="post-text">{post.text}</Col>
            </Row>
          </Container>
        ))}
        {/* br for layout. */}
        <br />
      </Stack>
      <Navbar bg="light" variant="light" fixed="bottom">
        <Nav className="ms-auto">
          <Nav.Link href="#post">➕</Nav.Link>
        </Nav>
      </Navbar>
    </div>
  );
};

export default Thread;

And my index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import 'index.css';
import Home from 'Home';
import Board from 'Board';
import Thread from 'Thread';
import reportWebVitals from 'reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/board/en/news" element={<Board name="enNews" />} />
      <Route path="/board/ja/news" element={<Board name="jaNews" />} />
      <Route path="/thread/:id" element={<Thread />} />
    </Routes>
  </BrowserRouter>
);

reportWebVitals();

And my test Thread.test.tsx

import { render, screen } from '@testing-library/react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './Home';
import Thread from './Thread';

test('renders thread', () => {
  render(
    <BrowserRouter>
      <Routes>
        <Route path="/thread/0" element={<Thread />} />
      </Routes>
    </BrowserRouter>
  );

  const post= screen.getByText('➕');

  expect(post).toBeInTheDocument();
});

And I got

 FAIL  src/Thread.test.tsx
  ✕ renders thread (168 ms)

  ● renders thread

    TestingLibraryElementError: Unable to find an element with the text: ➕. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    Ignored nodes: comments, script, style
    <body>
      <div />
    </body>

      13 |   );
      14 |
    > 15 |   const post= screen.getByText('➕');
         |                      ^
      16 |
      17 |   expect(post).toBeInTheDocument();
      18 | });

      at Object.getElementError (node_modules/@testing-library/dom/dist/config.js:40:19)
      at node_modules/@testing-library/dom/dist/query-helpers.js:90:38
      at node_modules/@testing-library/dom/dist/query-helpers.js:62:17
      at getByText (node_modules/@testing-library/dom/dist/query-helpers.js:111:19)
      at Object.<anonymous> (src/Thread.test.tsx:15:22)
      at TestScheduler.scheduleTests (node_modules/@jest/core/build/TestScheduler.js:333:13)
      at runJest (node_modules/@jest/core/build/runJest.js:404:19)

It looks like the component is empty.

Environment

  • macOS 13.0
  • Node.js 20.0.0
  • TypeScript 4.8.4
  • React 18.2.0
  • React Router 6.4.3
  • React Testing Library 8.19.0

Solution

  • The BrowserRouter sort of depends on a DOM environment, i.e. like the browser, to function correctly. This doesn't work well in unit tests though. For unit testing we'll often use the MemoryRouter which uses its own memoryHistory object instead of relying on synchronizing with a browser's history stack.

    Import the MemoryRouter for use in unit tests to provide the routing context. Remember that the default initial entry is "/", so if you have other routes you should also pass an initialEntries prop (e.g. synonymous to a "history stack"), and if necessary the initialIndex for the entry you want to render with. initialIndex defaults to the last entry of `initialEntries.

    Example:

    import { render, screen } from '@testing-library/react';
    import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
    import Home from './Home';
    import Thread from './Thread';
    
    test('renders thread', () => {
      render(
        <Router initialEntries={["/thread/0"]}>
          <Routes>
            <Route path="/thread/:id" element={<Thread />} />
          </Routes>
        </Router>
      );
    
      const post= screen.getByText('➕');
    
      expect(post).toBeInTheDocument();
    });