TDD asistido por IA. Domina TDD haciendo pair programming con ChatGPT

28 de febrero de 2024

Hace tiempo que vengo usando ChatGPT para la generación de tests unitarios de funciones puras, entre otras cosas. Por otro lado, tenía curiosidad por el desarrollo basado en TDD. Siempre me pareció difícil de llevar a la práctica, ya que a menudo trabajamos con requisitos incompletos, y vamos formando la definición completa de la tarea sobre la marcha.

Hoy tenía una tarea relativamente simple y bien definida, por lo que he decidido probar TDD, y para ello he pensado hacer pair programming con ChatGPT: he pensado que sea él quien defina y escriba los tests unitarios y yo quien complete la funcionalidad.

La tarea consistía en generar un checkbox con estilos, con los siguientes requisitos generales:

A partir de este conjunto -siempre incompleto- de requisitos humanos, elaboré la siguiente lista de tests vacíos:

describe('checkbox component', () => {
  describe('display props', () => {
    it('should render the text', () => {});

    it('should render HTML elements in the text', () => {});

    it('should render the unchecked state by default', () => {});

    it('should render the unchecked state if set', () => {});

    it('should render the checked state if set', () => {});
  });

  describe('state changes', () => {
    it('should populate checked state', () => {});

    it('should populate unchecked state', () => {});

    it('should change state on checkbox click', () => {});

    it('should change state on text click', () => {});
  });
});

Le comuniqué a ChatGPT mis requisitos y mi lista de tests, y me sugirió algunos tests adicionales, entre los que me parecieron interesantes éstos:

Accessibility Tests:

  • Test that the checkbox input has the correct aria-label or aria-labelledby attribute for screen readers.
  • Test that the checkbox input has the correct aria-checked attribute value based on its state.

Edge Cases and Error Handling:

  • Test that the component gracefully handles edge cases, such as missing or invalid props.
  • Test that the component renders correctly when provided with different combinations of props.

Así que añadí algunos de estos tests a mi lista de tests vacíos para obtener un conjunto más completo y conforme a estándares:

describe('checkbox component', () => {
  describe('display input props', () => {
    it('should render the text', () => {});

    it('should render HTML elements in the text', () => {});

    it('should render a checkbox without text if text is not set', () => {});

    it('should render the unchecked state if set', () => {});

    it('should render the checked state if set', () => {});

    it('should render the unchecked state if the initial state is not set', () => {});
  });

  describe('accessibility', () => {
    it('should include the correct aria-label attribute for screen readers', () => {});

    it('should include the correct aria-checked attribute based on its state', () => {});
  });

  describe('state changes', () => {
    it('should populate checked state', () => {});

    it('should populate unchecked state', () => {});

    it('should change state on checkbox click', () => {});

    it('should change state on text click', () => {});
  });
});

Luego le pasé a ChatGPT las instrucciones para generar los tests para mí. Como eran muchos, le dije que estaba bien que sólo devolviera tests para el primer grupo 'display input props'.

Esto es lo que devolvió ChatGPT:

  describe('display input props', () => {
    it('should render the text', () => {
      render(<StyledCheckbox text="Check me" />);
      expect(screen.getByText('Check me')).toBeInTheDocument();
    });

    it('should render HTML elements in the text', () => {
      render(<StyledCheckbox text="Check <a href='#'>link</a>" />);
      expect(screen.getByText('Check')).toBeInTheDocument();
      expect(screen.getByRole('link')).toBeInTheDocument();
    });

    it('should render a checkbox without text if text is not set', () => {
      render(<StyledCheckbox />);
      expect(screen.queryByRole('checkbox')).toBeInTheDocument();
      expect(screen.queryByText('Check me')).not.toBeInTheDocument();
    });

    it('should render the unchecked state if set', () => {
      render(<StyledCheckbox checked={false} />);
      expect(screen.getByRole('checkbox')).not.toBeChecked();
    });

    it('should render the checked state if set', () => {
      render(<StyledCheckbox checked={true} />);
      expect(screen.getByRole('checkbox')).toBeChecked();
    });

    it('should render the unchecked state if the initial state is not set', () => {
      render(<StyledCheckbox />);
      expect(screen.getByRole('checkbox')).not.toBeChecked();
    });
  });

Y he aquí el código que escribí para cumplir esos tests (lo más sencillo que se me ocurrió):

import React, { FC } from 'react';

type Props = React.InputHTMLAttributes<HTMLInputElement> & {
  checked?: boolean;
  text?: string;
};

export const StyledCheckbox: FC<Props> = ({
  checked = false,
  text = '',
  onChange,
}) => {
  return (
    <>
      <div role="checkbox" aria-checked={checked} />
      <div
        dangerouslySetInnerHTML={{
          __html: text,
        }}
      />
    </>
  );
};

Entonces pregunté por el segundo grupo de tests ('accessibility') y he aquí la respuesta:

  describe('accessibility', () => {
    it('should include the correct aria-label attribute for screen readers', () => {
      render(<StyledCheckbox text="Check me" />);
      expect(screen.getByRole('checkbox')).toHaveAttribute('aria-label', 'Check me');
    });

    it('should include the correct aria-checked attribute based on its state', () => {
      render(<StyledCheckbox checked={false} />);
      expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');

      render(<StyledCheckbox checked={true} />);
      expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
    });
  });

Para cumplir con ellos incluí un label englobando el componente para cumplir y añadí el atributo aria-label a mi div con rol checkbox:

export const StyledCheckbox: FC<Props> = ({
  checked = false,
  text = '',
  onChange,
}) => {
  return (
    <label>
      <div role="checkbox" 
        aria-checked={checked}
        aria-label={text} />
      <div
        dangerouslySetInnerHTML={{
          __html: text,
        }}
      />
    </label>
  );
};

A continuación pregunté por el tercer grupo ('state changes'), y este es el resultado:

  describe('state changes', () => {
    it('should populate checked state', () => {
      render(<StyledCheckbox />);

      // Ensure checkbox is initially unchecked
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).not.toBeChecked();

      // Simulate user interaction to check the checkbox
      fireEvent.click(checkbox);

      // Verify that the checkbox is now checked
      expect(checkbox).toBeChecked();
    });

    it('should populate unchecked state', () => {
      render(<StyledCheckbox checked />);

      // Ensure checkbox is initially checked
      const checkbox = screen.getByRole('checkbox');
      expect(checkbox).toBeChecked();

      // Simulate user interaction to uncheck the checkbox
      fireEvent.click(checkbox);

      // Verify that the checkbox is now unchecked
      expect(checkbox).not.toBeChecked();
    });

    it('should change state on checkbox click', () => {
      render(<StyledCheckbox />);

      const checkbox = screen.getByRole('checkbox');

      // Ensure checkbox is initially unchecked
      expect(checkbox).not.toBeChecked();

      // Simulate user interaction to toggle the checkbox
      fireEvent.click(checkbox);

      // Verify that the checkbox state has changed to checked
      expect(checkbox).toBeChecked();

      // Simulate user interaction to toggle the checkbox again
      fireEvent.click(checkbox);

      // Verify that the checkbox state has changed back to unchecked
      expect(checkbox).not.toBeChecked();
    });

    it('should change state on text click', () => {
      render(<StyledCheckbox text="Check me" />);

      const checkbox = screen.getByRole('checkbox');
      const text = screen.getByText('Check me');

      // Ensure checkbox is initially unchecked
      expect(checkbox).not.toBeChecked();

      // Simulate user interaction to click on the text
      fireEvent.click(text);

      // Verify that the checkbox state has changed to checked
      expect(checkbox).toBeChecked();

      // Simulate user interaction to click on the text again
      fireEvent.click(text);

      // Verify that the checkbox state has changed back to unchecked
      expect(checkbox).not.toBeChecked();
    });
  });

Con respecto a este grupo no quedé tan contento, porque hay mucha redundancia en los tests y siento que ChatGPT no entendió bien el sentido de "populate state", donde me refería a llamar al onChange en el componente padre.

Aún así, añadí únicamente el código que cumplía estos tests.

export const StyledCheckbox: FC<Props> = ({
  checked = false,
  text = '',
  onChange,
}) => {
  const [isChecked, setChecked] = useState(checked);

  return (
    <label onClick={() => setChecked(!isChecked)}>
      <div role="checkbox" 
        aria-checked={checked}
        aria-label={text} />
      <div
        dangerouslySetInnerHTML={{
          __html: text,
        }}
      />
    </label>
  );
};

Por ahora, el onChange nunca es llamado, y mi componente ni siquiera muestra iconos SVG. Pero cumple todas mis tests iniciales.

Simplifiqué entonces el grupo redundante de tests y pedí a ChatGPT que rellenara un test extra donde se debería probar el uso de onChange.

Entonces me proporcionó este test adicional:

    it('should call onChange on the parent component after a state change', () => {
      const onChangeMock = jest.fn();
      render(<StyledCheckbox onChange={onChangeMock} />);
      const checkbox = screen.getByRole('checkbox');

      fireEvent.click(checkbox);
      expect(onChangeMock).toHaveBeenCalledTimes(1);
      expect(onChangeMock).toHaveBeenCalledWith(true);

      fireEvent.click(checkbox);
      expect(onChangeMock).toHaveBeenCalledTimes(2);
      expect(onChangeMock).toHaveBeenCalledWith(false);
    });

Me quejé de que este test no sigue la filosofía de caja negra de testing library y que me gustaría tener un texto donde el checkbox fuera en realidad un hijo de algo donde se mostrara un texto condicionalmente, dependiendo del estado del chackbox. Además, yo tenía pensado que el onChange fuera un React.ChangeEvent<HTMLInputElement> estándar, por lo que no iba a recibir sólo true o false.

Aquí está la respuesta a mis quejas:

  it('should call onChange on the parent component after a state change', () => {
    const ParentComponent = () => {
      const [isChecked, setIsChecked] = useState(false);

      return (
        <div>
          <StyledCheckbox onChange={() => setIsChecked(!isChecked)} />
          <p>{isChecked ? 'Checkbox is checked' : 'Checkbox is unchecked'}</p>
        </div>
      );
    };

    render(<ParentComponent />);

    const checkbox = screen.getByRole('checkbox');
    const textElement = screen.getByText(/Checkbox is unchecked/i);

    // Initially, the checkbox should be unchecked and the text should reflect this
    expect(checkbox).not.toBeChecked();
    expect(textElement).toBeInTheDocument();

    // Click the checkbox to change its state
    fireEvent.click(checkbox);

    // After clicking, the checkbox should be checked and the text should reflect this
    expect(checkbox).toBeChecked();
    expect(textElement.textContent).toBe('Checkbox is checked');
  });

En este punto refactoricé mi componente para utilizar un elemento input estándar para mayor conformidad con los estándares HTML, y llegué a esta solución:

type Props = React.InputHTMLAttributes<HTMLInputElement> & {
  checked?: boolean;
  text?: string;
};

export const StyledCheckbox: FC<Props> = ({
  name,
  checked = false,
  text = '',
  onChange,
}) => {
  const [isChecked, setChecked] = useState(checked);

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (onChange) {
      onChange(event);
    }
  };

  return (
    <label htmlFor={name} onClick={() => setChecked(!isChecked)}>
      <input
        name={name}
        type="checkbox"
        checked={isChecked}
        aria-checked={isChecked}
        onChange={handleChange}
      />
      <div
        dangerouslySetInnerHTML={{
          __html: text,
        }}
      />
    </label>
  );
};

Finalmente le pedí ayuda para añadir los SVGs, y luego tuve que pasar por varias iteraciones hasta que pude ocultar con éxito el input sin romper los tests (si no lo ocultaba, se mostraban 2 checkbox). En el siguiente componente estoy usando clases tailwind para mostrar el input con tamaño 0x0 y opacidad 0, pero podría haber usado clases en un archivo CSS:

export const StyledCheckbox: FC<WithClassName<Props>> = ({
  className,
  name,
  checked = false,
  text = '',
  onChange,
}) => {
  const [isChecked, setChecked] = useState(checked);

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { checked } = event.target;
    setChecked(checked);
    if (onChange) {
      onChange(event);
    }
  };

  return (
    <label htmlFor={name}>
      <input
        className="opacity-0 h-0 w-0"
        name={name}
        type="checkbox"
        aria-checked={isChecked}
        aria-label={text}
        checked={isChecked}
        onChange={handleChange}
      />
      {isChecked ? (
        <img
          src="./img/checkbox_checked.svg"
          alt="Checked"
        />
      ) : (
        <img
          src="./img/checkbox_unchecked.svg"
          alt="Unchecked"
        />
      )}
      &nbsp;
      <span
        dangerouslySetInnerHTML={{ __html: text }}
      />
    </label>
  );
};

Por último, todo lo que tuve que hacer era dar el estilo adecuado a mi componente.

Ejemplos de prompts

Estos son algunos ejemplos de los prompts que he utilizado. A mi compañero Alfonso Feu le ha hecho gracia que sea tan educado con la IA y me ha pedido que los añada porque también muestra que para obtener buenas respuestas hay que proveer mucho contexto y no quedarse corto de palabras.

Para empezar, un ejemplo de mi verbosidad, casi excesiva, y educación con ChatGPT, ya que no quiero morir cuando las IAs tomen el control:

Hi, I found some redundancy on this last group of tests. I simplified them and added a new one that, in my mind, was missing, which is ensuring that the onChange is called in the parent. Can you please implement that test for me?

Por ejemplo, cuando no me gustó el test propuesto y le pedí que cambiara la forma de plantearlo. Sorprendentemente me entendió muy bien y me devolvió lo que pedía:

Sorry, that doesn't seem to follow testing library philosophy that much, can you rewrite the test to actually include the checkbox in a parent component and find something, a text element for instance, which depends on the state of the checkbox?

Aquí un ejemplo en el que le pido que me justifique si debería mantener la etiqueta <input> aunque esté oculta:

is it a good idea to keep the input, for standards?

Conclusiones

Lo que me gustó de este enfoque:

Por otro lado, fueron necesarias muchas iteraciones para llegar a la solución final. Podría haber hecho el trabajo más rápido y crear un conjunto de tests que cubrieran los escenarios más relevantes para el proyecto.

Hoy en día se discute mucho sobre si realmente es necesaria una gran cobertura en un proyecto, y los equipos de trabajo deben definir cuándo y dónde realizar tests.

En mi opinión, teniendo la ayuda de la IA, creo que usar TDD en funciones puras es algo que no hay que pensar; basta con escribir las descripciones de los tests y dejar que la IA trabaje por ti generando los tests. Con cosas como componentes, creo que depende de la complejidad y de las necesidades de cobertura de tests del equipo.

Créditos

Fotografía de cabecera por Steve Johnson en Unsplash.

Sobre el autor: Juangui Jordán

Desarrollador full stack, cada vez más especializado en frontend y UX. Siempre dispuesto a aprender y enseñar todo lo que tengo que ofrecer a los demás.

Comments
Únete a nosotros