App de perritos con React (P1)

21 de noviembre de 2022

by Karsten Winegeart
Photo by Karsten Winegeart on Unsplash

Esta va a ser una serie de artículos en la que vamos a crear una aplicación web desde cero. Con esta serie de artículos, la intención es generar conocimiento en los siguientes áreas de desarrollo con React:

  1. ¿Cómo podemos crear una aplicación web con React fácilmente?
  2. ¿Cómo estructuramos esos componentes en React?
  3. ¿Cómo estilizamos esos componentes?
  4. ¿Cómo conectamos nuestra aplicación con una API externa?
  5. ¿Cómo podemos mantener un estado global en nuestra aplicación, sin requerir una librería externa?
  6. ¿Cómo podemos escribir tests para esta aplicación?

En esta primera parte vamos a crear los componentes básicos, crearle unas hojas de estilos, utilizar PostCSS con Tailwind.

#.¿De dónde sacarmos la información de las razas y las fotos?

Para esto, vamos a utilizar Dog API, que es un API abierto con una colección fantástica de fotografías de perros.

Puedes leer la página del API para tener más información.

#.Creando la escructura base de la aplicación

Vamos a utilizar Create React App para generar la estructura base de nuestra aplicación. Para esto, vamos a ejecutar el siguiente comando:

npx create-react-app fotos-perritos

Si no tienes disponible Create React App, el comando te preguntará primero si quieres instalarlo.

Una vez que se haya generado la estructura base de la aplicación, vamos a entrar a la carpeta de la aplicación y vamos a instalar las dependencias, en mi caso utilizaré yarn:

yarn

#.Estilizando la aplicación

Para estilizar la aplicación, sin tanto engorro, vamos a utilizar Tailwind CSS. Para esto, vamos a instalar las dependencias de Tailwind CSS e iniciamos la configuración base de Tailwind:

yarn add -D tailwindcss@latest postcss@latest autoprefixer@latest
// ...
npx tailwindcss init -p

Esto nos creará un archivo tailwind.config.js en la raíz de nuestro proyecto. En este archivo, vamos a asegurarnos de que la propiedad content esté mapeando los archivos js,jsx de nuestra aplicación:

module.exports = {
  content: [    "./src/**/*.{js,jsx}",  ],  theme: {
    extend: {},
  },
  plugins: [],
}

Por último, vamos a nuestro archivo index.css, eliminamos todo lo que ya contiene y vamos a importar Tailwind CSS:

@tailwind base;
@tailwind components;
@tailwind utilities;

#.Cambiando la estructura base de la aplicación

Vamos a cambiar la estructura base de nuestro archivo App.js, para que tengamos una idea simple de lo que queremos hacer. Para esto, abrimos el archivo src/App.js y lo modificamos a lo siguiente:

App.js
import "./App.css";

function App() {
  return (
    <div className="container mx-auto mt-8 flex flex-col gap-3">
      <h1 className="text-2xl">Fotos Aleatorias de Perritos</h1>
      <h2 className="text-xl">
        Escoge una raza de perritos y te desplegaremos una fotografía aleatoria
      </h2>

      <section>
        <form action="#">
          <select
            name="breed"
            id="breed"
            className="p-2 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
          >
            <option selected>Seleccionar</option>
            <option value="Raza 1">Raza 1</option>
          </select>
        </form>
      </section>
    </div>
  );
}

export default App;

Todo el contenido dentro de App.css lo podemos eliminar, ya que no lo vamos a utilizar.

Por ahora, la aplicación solo mostrará un título (h1), un subtítulo (h2) y un select con una opción base por defecto.

#.Estructurando en múltiples componentes

Por ahora, todo reside dentro del App.js, pero vamos a cambiar esto. La razón de esto es que debemos intentar que nuestros componentes realicen una única tarea o función (Single Responsibility Principle). Aplicaremos el mismo principio también para cualquier función que creemos.

Esto también nos va a ayudar mucho a la hora de probar nuestros componentes y a la hora de mantenerlos.

La estructura base debería ser similar a la siguiente:

<App>
  <Titulos />
  <Formulario>
    <Select />
  </Formulario>
</App>

Vamos a crear los componentes Title.js y Form.js dentro de la carpeta src/components:

src/components/Title.js
import React from "react";

const Title = () => {
  return (
    <section>
      <h1 className="text-2xl">Fotos Aleatorias de Perritos</h1>
      <h2 className="text-xl">
        Escoge una raza de perritos y te desplegaremos una fotografía aleatoria
      </h2>
    </section>
  );
};

export default Title;
src/components/Form.js
import React from "react";

const Form = () => {
  return (
    <section>
      <form action="#">
        <select
          name="breed"
          id="breed"
          className="p-2 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
          defaultValue={null}
        >
          <option>Seleccionar</option>
          <option value="Raza 1">Raza 1</option>
        </select>
      </form>
    </section>
  );
};

export default Form;

Y, por último, vamos a modificar el App.js para que utilice estos componentes:

App.js
import "./App.css";
import Form from "./components/Form";
import Title from "./components/Title";

function App() {
  return (
    <div className="container mx-auto mt-8 flex flex-col gap-3">
      <Title />
      <Form/>
    </div>
  );
}

export default App;

Si corremos nuestra aplicación, utilizando yarn start, vamos a ver lo siguiente:

img1

#.Corrigiendo esos imports

Esos imports relativos se ven un poco feos, vamos a corregirlos antes que esto se ponga peor. Para esto, vamos a crear un archivo jsconfig.json en la raíz de nuestro proyecto:

{
  "compilerOptions": {
    "baseUrl": "src"
  },
  "include": ["src"]
}

Esto lo que le dice a nuestro proyecto es que, cuando importemos un archivo, no lo busque en la carpeta src, sino que lo busque en la raíz del proyecto.

Ahora podemos cambiar los dos imports en nuestro archivo App.js:

App.js
// ...
import Form from "components/Form";import Title from "components/Title";
function App() {
  // ...

Si volvemos a ejecutar nuestro proyecto, todo deberá seguir funcionando igual.

#.Eliminando el texto plano dentro de nuestro código

Tenemos texto plano dentro de nuestra aplicación, vamos a escribir pruebas contra ese mismo texto y realmente ese texto deberíamos evitar tenerlo dentro de nuestro código (imagínate que luego quieras cambiar un texto, quieras traer ese texto en un idioma distinto, etc).

Para ello, por cada componente vamos a crear un archivo en formato .json con el mismo nombre del componente. Por ejemplo, para el componente Title.js, vamos a crear un archivo Title.constants.json:

Title.constants.json
{
  "title": "Fotos Aleatorias de Perritos",
  "subtitle": "Escoge una raza de perritos y te desplegaremos una fotografía aleatoria"
}

Y un archivo Form.constants.json:

Form.constants.json
{
  "form": {
    "defaultOption": "Seleccionar"
  }
}

Ahora, en cada uno de nuestros componentes, vamos a importar el archivo correspondiente y vamos a utilizar los valores que necesitemos:

Title.js
import React from "react";
import CONSTANTS from "./Title.constants.json";

const Title = () => {
  return (
    <section>
      <h1 className="text-2xl">{CONSTANTS.title}</h1>
      <h2 className="text-xl">{CONSTANTS.subtitle}</h2>
    </section>
  );
};

export default Title;
Form.js
import React from "react";
import CONSTANTS from "./Form.constants.json";

const Form = () => {
  return (
    <section>
      <form action="#">
        <select
          name="breed"
          id="breed"
          className="p-2 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
          defaultValue={null}
        >
          <option>{CONSTANTS.form.defaultOption}</option>
          <option value="Raza 1">Raza 1</option>
        </select>
      </form>
    </section>
  );
};

export default Form;

#.Probando estos componentes

Ahora vamos a probar estos componentes, una parte escencial a la hora de escribir nuestros sistemas. Create React App ya viene configurado para poder ejecutar pruebas, para ello vamos a ejecutar las pruebas (y verlas fallar) con el siguiente comando:

yarn test

...

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        4.072 s
Ran all test suites related to changed files.

Si vamos al archivo App.test.js, vamos a ver que tenemos una prueba que falla. Esta prueba utiliza Jest con React Testing Library.

Vamos a corregir esta prueba para que pase, pero primero debemos preguntarnos que debemos probar. En mi concepto debemos asegurarnos que:

  • El componente renderice correctamente.
  • El componente tenga un título, con el valor esperado.
  • El componente tenga un subítulo, con el valor esperado.
  • El componente tenga un <select>, con el valor predeterminado.

Así que vamos a escribir la fundación para estas pruebas. Eliminamos el contenido de las pruebas existentes y comenzamos a crear nuestras pruebas.

App.test.js
describe("App", () => {
  it("should render", () => {
  });
})

Como estamos utilizando el mismo Single Responsibility Principle, vamos a separar las pruebas en distintas pruebas. Esto quiere decir que:

  • El App.js, debemos asegurarnos que:
    • Renderice el componente título y el form.
  • El Form.js renderice los elementos que le corresponden a él, en este caso el formulario con su <select>.
  • El Title.js renderice tanto el título como el subtítulo.

Así que terminaremos con 3 archivos de prueba, el primero, el App.test.js:

App.test.js
import { render, screen } from '@testing-library/react';
import App from './App';
import TITLE_CONSTANTS from 'components/Title.constants.json';
import FORM_CONSTANTS from 'components/Form.constants.json';

describe("App", () => {
  it("should render", () => {
    render(<App />);

    expect(screen.getByText(TITLE_CONSTANTS.title)).toBeInTheDocument();
    expect(screen.getByText(FORM_CONSTANTS.form.defaultOption)).toBeInTheDocument();
  });
});

Aquí podemos ver que estamos utilizando las constantes que creamos anteriormente para asegurarnos que el texto que estamos buscando esté presente en el DOM. Corremos nuestros tests con yarn test y ahora vemos:

 PASS  src/App.test.js
  App
    ✓ should render (41 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.771 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

Ahora vamos a crear el archivo Title.test.js:

Title.test.js
import { render, screen } from '@testing-library/react';
import Title from './Title';
import CONSTANTS from './Title.constants.json';

describe("Title", () => {
  it("renders the title", () => {
    render(<Title />);

    expect(screen.getByText(CONSTANTS.title)).toBeInTheDocument();
  });

  it("renders the subtitle", () => {
    render(<Title />);

    expect(screen.getByText(CONSTANTS.subtitle)).toBeInTheDocument();
  });
});

Y luego el archivo Form.test.js:

Form.test.js
import { render, screen } from '@testing-library/react';
import Form from './Form';
import CONSTANTS from './Form.constants.json';

describe("Form", () => {
  it("renders the default option", () => {
    render(<Form />);

    expect(screen.getByText(CONSTANTS.form.defaultOption)).toBeInTheDocument();
  });
});

Y al ejecutar las pruebas:

 PASS  src/components/Title.test.js
 PASS  src/App.test.js
 PASS  src/components/Form.test.js

Test Suites: 3 passed, 3 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        5.423 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

#.Otra forma de probar la estructura

Otra forma de probar la estructura, por ejemplo del componente Title.js es utilizando Snapshot Testing.

Esto es útil para cuando queremos asegurarnos que nuestro componente tenga la estructura que estamos esperando y que la misma no cambie sin quererlo.

Para ello, primero debemos configurar Babel para que pueda compilar el código de React. Para ello vamos a instalar las siguientes dependencias. Como Create React App ya utiliza Babel, solo debemos configurarlo agregando un archivo .babelrc en la raíz del proyecto:

{
  "env": {
    "test": { "presets": ["react-app"] }
  }
}

Luego, podemos agregar las siguientes pruebas en Form.test.js:

Form.test.js
import { render, screen } from '@testing-library/react';
import Form from './Form';
import CONSTANTS from './Form.constants.json';

describe("Form", () => {
  it("renders the default option", () => {
    render(<Form />);

    expect(screen.getByText(CONSTANTS.form.defaultOption)).toBeInTheDocument();
  });

  it("renders the markup as expected", () => {    const { container } = render(<Form />);    expect(container).toMatchSnapshot();  });});

Esto lo que va a hacer es crear un archivo __snapshots__/Form.test.js.snap con la estructura de nuestro componente al momento que la línea expect(container).toMatchSnapshot(); es ejecutada.

⚠️ Si la estructura del componente cambia repentinamente, el test va a fallar y nos va a mostrar la diferencia entre la estructura actual y la que se esperaba.

#.Aclaratoria al escribir las pruebas

En este pequeño tutorial he escrito la funcionalidad primero y luego las pruebas. Esto puede ser considerado una mala práctica.

Lo ideal es que escribamos nuestras pruebas primero y luego escribamos la funcionalidad (aunque en la práctica, luego de más de 10 años escribiendo código, rara vez veo esto 🤣). Esto nos va a ayudar a pensar en la funcionalidad que queremos que tenga nuestro componente y luego escribir el código que lo haga posible.

#.Recomendaciones sobre los tests con snapshots

Un detalle que me parece oportuno mencionar sobre los snapshots es que si el componente tiene un valor dinámico, por ejemplo, un id o un key, el snapshot va a fallar porque el valor va a ser diferente cada vez que se ejecute el test. Para evitar esto, podemos utilizar la función jest.mock() para que el valor sea siempre el mismo. Lo mismo ocurre si utilizamos algo como una fecha. Esto quiere decir que nuestros tests deben ser determinísticos.

Igualmente, creo que es oportuno mencionar que los snapshots deben ser tratados como código. Lo que quiero indicar con esto es que debemos usar nuestro juicio para determinar si un snapshot es extremadamente complejo de analizar y, de ser así, deberíamos reemplazarlo por un test más simple.

Ambas recomendaciones se mencionan en la guía original de Jest en su apartado de Best Practices (Buenas prácticas).

Básicamente: los snapshots pueden ser tu aliado o tu enemigo, tu eliges.

#.Próximos pasos

En el próximo tutorial vamos a cambiar el componente de formulario, el Form.js y vamos a:

  • Conectar el API.
  • Crear una forma centralizada de manejar el estado de la aplicación, sin utilizar ninguna librería externa.
  • Probar nuestro API y nuestra integración con el manejador del estado.
  • Vamos a explorar algunas recomendaciones al utilizar React Testing Library, puesto que al principio puede ser un poco confuso en cómo debemos escribir estas pruebas, sobre todo si estamos acostumbrados a escribir pruebas unitarias.