TDD asistido por IA. Domina TDD haciendo pair programming con ChatGPT
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:
- Utilizar un icono SVG para el estado sin marcar y otro para el estado marcado.
- Permitir contenido HTML en el texto que acompaña al checkbox, como negrita o enlaces.
- Con el objetivo de mejorar la experiencia del usuario, decidí que al hacer clic en la etiqueta el checkbox también debería cambiar su estado.
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
oraria-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"
/>
)}
<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:
- Tuve que centrarme primero en la funcionalidad y en la conformidad a estándares. Normalmente, lo primero que haría sería diseñar el componente para que se pareciera al diseño y luego cumplir la funcionalidad.
- El resultado fue mejor en términos de calidad. Si sólo me hubiera centrado en cumplir la funcionalidad, probablemente me habría saltado algunos problemas de accesibilidad o casos aislados.
- Conseguí una gran colección de tests de regresión para asegurarme de que mi componente mantiene el mismo comportamiento en el futuro.
- La IA me ayudó mucho a crear los tests, pero algunas no tenían sentido y tuve que cambiarlas o simplificarlas.
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.