Cómo probar un Navbar en React: ejemplo real con React Testing Library

Gabriel Jiménez | Hace 8 días

En un artículo anterior desarrollamos un Navbar desde 0 con React, aprendimos qué es un Navbar, qué componentes lo forman y cómo desarrollarlo paso a paso. Si no lo has visto, puedes revisarlo aquí Cómo hacer un Navbar con react desde 0. 


Para este artículo, nos enfocaremos en aprender porque es importante probarlo, que herramientas necesito y terminaremos con un ejemplo práctico.

¿Qué es un Navbar y cómo esta compuesto?

Un navbar es un componente UI utilizado principalmente para permitir al usuario navegar a través de nuestra aplicación.


Un componente Navbar se componente de otros sub componentes:


Componente Brand

Encargado de mostrar el nombre y/o logo de la aplicación.

export const Brand = ({ title, logo }) => {
  if (!children && !logo) {
    throw new Error("Brand requiere al menos un texto o un logo.")
  }

  if (children && typeof children !== "string") {
    throw new Error("Brand solo acepta texto como children.")
  }

  return (
    <>
      {logo && <img src={logo} alt="logo" />}
      {children && <span>{title}</span>}
    </>
  )
}


Componente Nav

Agrupa una lista de elementos de navegación.

export const NavItem = ({ to, children }) => {
  if (typeof to !== "string" || to.trim() === "") {
    throw new Error("NavItem requiere un prop 'to' no vacío.")
  }

  if (typeof children !== "string") {
    throw new Error("NavItem solo acepta texto como children.")
  }

  return (
    <a href={to} className="nav-item">
      {children}
    </a>
  )
}

export const Nav = ({ children }) => {
  const items = Children.toArray(children)

  items.forEach((child) => {
    if (!React.isValidElement(child) || child.type !== NavItem) {
      throw new Error("Nav solo puede renderizar hijos de tipo <NavItem />.")
    }
  })

  return <div className="navbar-nav">{items}</div>
}


Componente Toggler

Permite ocultar o mostrar el menú de navegación.

export const Toggler = ({ initialOpen, onChange, icon = "☰" }) => {
  const [open, setOpen] = useState(initialOpen)

  const handleClick = () => {
    const next = !open
    setOpen(next)
    if (onChange) onChange(next)
  }

  return (
    <button
      type="button"
      className="navbar-toggler"
      onClick={handleClick}
    >
      {icon}
    </button>
  )
}


Componente Actions

Agrupa elementos que no son necesariamente elementos de navegación como por ejemplo: formularios, botones, buscadores, entre otros.

export const Action = ({ children }) => {
  return <div className="action">{children}</div>
}

export const Actions = ({ children }) => {
  const items = Children.toArray(children)

  items.forEach((child) => {
    if (!React.isValidElement(child) || child.type !== Action) {
      throw new Error("Actions solo puede renderizar hijos de tipo <Action />.")
    }
  })

  return <div className="navbar-actions">{items}</div>
}


Componente NavBar

Contiene la lógica que se encarga de orquestar como los componentes trabajan entre si.

export const NavBar = ({ children }) => {
  let brand = null
  let actions = null
  let nav = null
  let toggler = null

  const items = Children.toArray(children)

  items.forEach((child) => {
    if (!React.isValidElement(child)) {
      throw new Error("NavBar solo puede renderizar componentes válidos.")
    }

    switch (child.type) {
      case Brand:
        brand = child
        break
      case Actions:
        actions = child
        break
      case Nav:
        nav = child
        break
      case Toggler:
        toggler = child
        break
      default:
        throw new Error(
          "NavBar solo puede renderizar hijos de tipo <Brand />, <Actions />, <Nav /> o <Toggler />."
        )
    }
  })

  const initialOpen = toggler ? !!toggler.props?.initialOpen : true
  const [isOpen, setIsOpen] = useState(initialOpen)

  const handleToggle = (next) => {
    setIsOpen(next)
    if (toggler?.props?.onChange) toggler.props.onChange(next)
  }

  const togglerWithHandlers =
    toggler &&
    cloneElement(toggler, {
      onChange: handleToggle,
      initialOpen: initialOpen,
    })

  return (
    <nav className="navbar">
      {brand}
      {isOpen && nav}
      {actions}
      {togglerWithHandlers}
    </nav>
  )
}

export default NavBar

¿Por qué es importante probar un componente Navbar?

Es importante porque puede contener lógica de negocio como: un formulario para crear un recurso, un buscador para localizar un producto, acciones que desencadenen funcionalidades específicas. Además, es un componente que puede ser reutilizado en diferentes lugares de nuestra aplicación.


¿Qué herramientas se necesitan para probar un componente Navbar?

Las herramientas más utilizadas para probar nuestros componentes en React son:


Jest. Permitir escribir, ejecutar y evaluar nuestras pruebas.

React Testing Library. Facilita probar nuestros componentes React, al proporcionarnos funciones específicas para montar, buscar, trabajar con peticiones asíncronas, y más.


Si quieres profundizar en cada herramienta, puedes revisar cómo funciona Jest en React

y cómo utilizar React Testing Library para probar componentes de forma más realista.


Caso práctico: Cómo probar un Navbar usando React Testing Library

Como vimos anteriormente, el Navbar se componente sub componentes. Podemos probar cada uno de ellos de forma aislada o probar directamente el componente Navbar. Cualquiera de las dos opciones es totalmente valida, para este ejemplo práctico probaremos cada uno de forma individual.


Prueba del componente Brand

Para el componente Brand, vamos a probar lo siguiente:


  1. Alguno de los props (title y logo) son obligatorios.
  1. El prop title debe ser un cadena.
  1. Validar que el logo y el title son obligatorios.

File: src/Navbar/Navbar.test.jsx
. . .
    4: describe("Brand", () => {
    5:   it("lanza error si no se envía ni title ni logo", () => {
    6:     expect(() => {
    7:       {/* No se envía ningún prop válido, debe lanzar error */}
    8:       render(<Brand />)
    9:     }).toThrow("Brand requiere al menos un texto o un logo.")
   10:   })
   11: 
   12:   it("lanza error si title no es una cadena", () => {
   13:     expect(() => {
   14:       {/* title no es texto, esto provoca el error */}
   15:       render(<Brand title={<button>Clic</button>} />)
   16:     }).toThrow("Brand solo acepta texto como title.")
   17:   })
   18: 
   19:   it("renderiza correctamente cuando se envía title o logo", () => {
   20:     render(
   21:       <Brand title="Mi App" logo="logo" />
   22:     )
   23: 
   24:     expect(document.body.textContent).toContain("Mi App")
   25: 
   26:     const img = screen.getByRole("img", { name: /logo/i })
   27:     expect(img).toBeInTheDocument()
   28:   })
   29: })

Prueba del componente Nav

El componente Nav es un poco más complejo que el Brand, ya que se componente de otro componente. Podríamos probarlos de forma aislada cada uno, sin embargo, para este caso veamos como probar únicamente uno de ellos.


Para decidir que componente probar, solo debemos analizar que componente es el padre, en este caso, Nav utiliza a NavItem como proveedor.


Probaremos lo siguiente:


  1. El componente Nav solo debe permitir componentes de tipo NavItem.
  1. El componente NavItem recibe un prop “to” de tipo cadena.
  1. El componente NavItem recibe un prop “children” de tipo cadena.
  1. El componente Nav se renderiza correctamente.

File: src/Navbar/Navbar.test.jsx
. . .
   29: describe("Nav", () => {
   30:   it("Nav solo debe permitir componentes de tipo NavItem", () => {
   31:     expect(() => {
   32:       render(
   33:         <Nav>
   34:           <NavItem to="/home">Home</NavItem>
   35:           {/* Este hijo no es un NavItem, debe lanzar un error */}
   36:           <div>Otro</div>
   37:         </Nav>
   38:       )
   39:     }).toThrow("Nav solo puede renderizar hijos de tipo <NavItem />.")
   40:   })
   41: 
   42:   it("NavItem recibe un prop 'to' de tipo cadena", () => {
   43:     expect(() => {
   44:       render(
   45:         <Nav>
   46:           {/* 'to' no es string, esto debe provocar el error */}
   47:           <NavItem to={123}>Home</NavItem>
   48:         </Nav>
   49:       )
   50:     }).toThrow("NavItem requiere un prop 'to' no vacío.")
   51:   })
   52: 
   53:   it("NavItem recibe un prop 'children' de tipo cadena", () => {
   54:     expect(() => {
   55:       render(
   56:         <Nav>
   57:           <NavItem to="/home">
   58:             {/* children no es texto, debe lanzar error */}
   59:             <span>Home</span>
   60:           </NavItem>
   61:         </Nav>
   62:       )
   63:     }).toThrow("NavItem solo acepta texto como children.")
   64:   })
   65: 
   66:   it("NavItem se renderiza correctamente con 'to' y 'children'", () => {
   67:     render(
   68:       <Nav>
   69:         <NavItem to="/home">Home</NavItem>
   70:       </Nav>
   71:     )
   72: 
   73:     const link = screen.getByRole("link", { name: "Home" })
   74:     expect(link).toHaveAttribute("href", "/home")
   75:   })
   76: })

Prueba del componente Toggler

Para este componente, simularemos que al hacer clic el componente Nav se muestre y al volver a hacer clic se oculte.

File: src/Navbar/Navbar.test.jsx
. . .
   80: describe("Toggler", () => {
   81:   it("al hacer clic alterna entre mostrar y ocultar el Nav", () => {
   82:     const onChange = jest.fn()
   83: 
   84:     render(<Toggler initialOpen={false} onChange={onChange} />)
   85: 
   86:     const button = screen.getByRole("button")
   87: 
   88:     // Primer clic: se abre
   89:     fireEvent.click(button)
   90:     expect(onChange).toHaveBeenLastCalledWith(true)
   91: 
   92:     // Segundo clic: se cierra
   93:     fireEvent.click(button)
   94:     expect(onChange).toHaveBeenLastCalledWith(false)
   95:   })
   96: })

Prueba del componente Actions

Probaremos que: 


  1. Solo se permitan listar componentes de tipo Action dentro del componente Actions.
  1. Se renderice correctamente el componente Actions

File: src/Navbar/Navbar.test.jsx
. . .
   98: describe("Actions", () => {
   99:   it("Actions solo permite componentes de tipo Action", () => {
  100:     expect(() => {
  101:       render(
  102:         <Actions>
  103:           <Action>Guardar</Action>
  104:           {/* Este hijo no es un Action, debe lanzar error */}
  105:           <div>Otro</div>
  106:         </Actions>
  107:       )
  108:     }).toThrow("Actions solo puede renderizar hijos de tipo <Action />.")
  109:   })
  110: 
  111:   it("Actions se renderiza correctamente con componentes Action", () => {
  112:     render(
  113:       <Actions>
  114:         <Action>Guardar</Action>
  115:         <Action>Cancelar</Action>
  116:       </Actions>
  117:     )
  118: 
  119:     const bodyTextContent = document.body.textContent
  120:     expect(bodyTextContent).toContain("Guardar")
  121:     expect(bodyTextContent).toContain("Cancelar")
  122:   })
  123: })

Prueba del componente Navbar

Para el componente padre, validaremos:


  1. Solo permita componentes de tipo Brand, Nav, Actions y Toggler.
  1. Que se renderice correctamente.

File: src/Navbar/Navbar.test.jsx
. . .
  125: describe("NavBar", () => {
  126:   it("solo permite componentes Brand, Nav, Actions y Toggler", () => {
  127:     expect(() => {
  128:       render(
  129:         <NavBar>
  130:           <Brand title="Mi App" />
  131:           <Nav>
  132:             <NavItem to="/home">Home</NavItem>
  133:           </Nav>
  134:           <Actions>
  135:             <Action>Guardar</Action>
  136:           </Actions>
  137:           <div>Invalido</div>
  138:           <Toggler initialOpen={true} />
  139:         </NavBar>
  140:       )
  141:     }).toThrow(
  142:       "NavBar solo puede renderizar hijos de tipo <Brand />, <Actions />, <Nav /> o <Toggler />."
  143:     )
  144:   })
  145: 
  146:   it("se renderiza correctamente y el Nav se muestra/oculta con el Toggler", () => {
  147:     render(
  148:       <NavBar>
  149:         <Brand title="Mi App" />
  150:         <Nav>
  151:           <NavItem to="/home">Home</NavItem>
  152:         </Nav>
  153:         <Actions>
  154:           <Action>Guardar</Action>
  155:         </Actions>
  156:         <Toggler initialOpen={false} icon="☰" />
  157:       </NavBar>
  158:     )
  159: 
  160:     // Render base
  161:     expect(screen.getByText("Mi App")).toBeInTheDocument()
  162:     expect(screen.getByText("Guardar")).toBeInTheDocument()
  163: 
  164:     // initialOpen=false => Nav oculto
  165:     expect(screen.queryByRole("link", { name: "Home" })).toBeNull()
  166: 
  167:     // Click => Nav visible
  168:     fireEvent.click(screen.getByRole("button", { name: "☰" }))
  169:     expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument()
  170: 
  171:     // Click otra vez => Nav oculto
  172:     fireEvent.click(screen.getByRole("button", { name: "☰" }))
  173:     expect(screen.queryByRole("link", { name: "Home" })).toBeNull()
  174:   })
  175: })

Conclusión

Al probar nuestros componentes de forma aislada en muchos de los casos es mejor, ya que cada uno tiene su propia lógica. En algunos casos conviene probarlos en conjunto como en el caso del componente Nav, donde su componente hijo era bastante sencillo. Lo importante es comenzar a escribir nuestras primeras pruebas para facilitar la escalabilidad de nuestros componentes con el tiempo.

¿Quieres aprender más casos reales de testing en React?
Explora el hub de Testing en React con guías, ejemplos y buenas prácticas paso a paso. 


Ver el hub de Testing en React