1. Introducción
Este artículo está hecho en conjunto con Dionisio Cortés y es una continuación de Acelera tu desarrollo en Spring Boot con GitHub Copilot en el que comentamos como crear una aplicación de Spring Boot con ayuda de Github Copilot. Ahora procederemos a implementar los tests en este proyecto con ayuda de la misma herramienta, para conocer su capacidad en este apartado, empezaremos por los tests unitarios y por último veremos qué pasa con los tests de integración. Para ello usaremos el proyecto que teníamos comenzado en el anterior tutorial.
2. Tests unitarios
Para empezar, recordemos que un test unitario es aquel que prueba específicamente una parte del código, como por ejemplo una clase, de forma aislada. El objetivo es asegurarse de que cada unidad de código funciona correctamente y cumple con los requisitos esperados antes de integrarse con el resto del sistema.
Antes de pedirle a Github Copilot (que si no conoces la herramienta te recomiendo este tutorial) que escriba los tests tendremos que crear los ficheros, ya que esta herramienta no puede, empezamos por los tests del servicio StudentService (que por si te lo has saltado, proviene de este tutorial en el que creamos el proyecto de Spring Boot).
Crearemos un fichero llamado StudentServiceImplTest, debajo de src/test, y una vez tenemos el archivo empezaremos a escribir en él para que Github Copilot nos ayude.
Después de hacer algunas pruebas, en general parece que Github Copilot no está especialmente acertado con los tests, pero dentro de Copilot Labs tenemos testpilot, que por el momento se encuentra disponible solo en JavaScript y TypeScript, pero por lo que vemos parece muy prometedor. Pero aclara que estamos usando Github Copilot en IntelliJ para este ejemplo.
Hemos empezado escribiendo este prompt al comienzo del fichero que hemos creado:
// create all tests for StudentServiceImpl class
Para ver las sugerencias nos dirigimos al botón de la barra derecha en el IDE, como puedes ver en la imagen:
La mayoría de las sugerencias que nos devolvió eran incompletas o simplemente comentarios, aquí nos preguntamos, ¿será una práctica habitual comentar los tests para que pasen? Ya que esta IA esta entrenada con datos humanos.
De todas las respuestas, la más interesante fue la siguiente:
imports... class StudentServiceImplTest { private StudentServiceImpl studentService; @Mock private StudentRepository studentRepository; @BeforeEach void setUp() { MockitoAnnotations.initMocks(this); studentService = new StudentServiceImpl(studentRepository); } @Test void findById() { // given Student student = new Student(); student.setId(1L); when(studentRepository.findById(anyLong())).thenReturn(Optional.of(student)); // when Student foundStudent = studentService.findById(1L); // then assertNotNull(foundStudent); assertEquals(1L, foundStudent.getId()); verify(studentRepository, times(1)).findById(anyLong()); } @Test void findByIdNotFound() { // given when(studentRepository.findById(anyLong())).thenReturn(Optional.empty()); // when NoSuchElementException noSuchElementException = assertThrows(NoSuchElementException.class, () -> studentService.findById(1L)); // then assertEquals("No value present", noSuchElementException.getMessage()); verify(studentRepository, times(1)).findById(anyLong()); } @Test void save() { // given Student student = new Student(); student.setId(1L); when(studentRepository.save(any())).thenReturn(student); // when Student savedStudent = studentService.save(student); // then assertNotNull(savedStudent); assertEquals(1L, savedStudent.getId()); verify(studentRepository, times(1)).save(any()); } @Test void delete() { // given Student student = new Student(); student.setId(1L); // when studentService.delete(student); // then verify(studentRepository, times(1)).delete(any()); } @Test void deleteById() { // given // when studentService.deleteById(1L); // then verify(studentRepository, times(1)).deleteById(anyLong()); } }
Aunque como vemos, en algunos métodos como deleteById, no da una muy buena respuesta, también nos hemos fijado que no usa buenos nombres para los tests como: given…should o should return… simplemente referencia al método que está probando.
También observamos que estos imports:
import com.example.copilot.domain.Student; import com.example.copilot.service.impl.StudentServiceImpl;
No se encuentran en la paquetería correcta y por último también vemos que este método está deprecado:
MockitoAnnotations.initMocks(this);
Pero, ¿qué podría ocurrir si ajustamos un poco más el prompt?
// create all tests for StudentServiceImpl with Mockito and jUnit5 for spring boot application using studentRepository and student model
Pues nos devolvería algo más acertado:
imports... class StudentServiceImplTest { private StudentRepository studentRepository; private StudentService studentService; @BeforeEach void setUp() { studentRepository = mock(StudentRepository.class); studentService = new StudentServiceImpl(studentRepository); } @Test void getAllStudents() { // given List<Student> students = List.of(new Student(), new Student()); when(studentRepository.findAll()).thenReturn(students); // when studentService.getAllStudents(); // then verify(studentRepository).findAll(); } @Test void getStudentById() { // given Long id = 1L; when(studentRepository.findById(id)).thenReturn(Optional.of(new Student())); // when studentService.getStudentById(id); // then verify(studentRepository).findById(id); } @Test void createStudent() { // given Student student = new Student(); when(studentRepository.save(student)).thenReturn(student); // when studentService.createStudent(student); // then verify(studentRepository).save(student); } @Test void updateStudent() { // given Student student = new Student(); when(studentRepository.save(student)).thenReturn(student); // when studentService.updateStudent(student); // then verify(studentRepository).save(student); } @Test void deleteStudent() { // given Long id = 1L; // when studentService.deleteStudent(id); // then verify(studentRepository).deleteById(id); } }
Esto y con unos pequeños ajustes ya lo tendríamos, pero nos ha costado un poco más de lo esperado, quizá en este caso al ser tests tan sencillos podríamos haber acabado antes haciendo esto a mano, parece que a Github Copilot le cuesta dar respuestas enteras, como una clase, con exactitud.
Vamos a hacer una prueba más, todo esto es muy básico, imaginemos que le ponemos un atributo nuevo special a la entidad Student que teníamos previamente creada.
public class Student { ... private Boolean special; // añadimos este atributo }
Y que a los estudiantes que se llamen «pepe», que son excelentes personas, les hacemos siempre especiales cuando les creamos, por lo que modificamos el StudentService para tener el siguiente método:
@Override public Student createStudent(Student student) { student.setSpecial(student.getName().equals("pepe")); return studentRepository.save(student); }
Ahora, cada vez que cree a un student, lo pondrá como special, si su nombre es igual a «pepe», pero esto nos da un problema, y es que ahora sabe mucho de «pepe», tal vez demasiado. Si vamos a nuestro fichero de tests y seguimos con más pruebas, donde solo debería crear una aserción más o un caso de prueba más, no lo hace y piensa que todos los casos de prueba son con pepe. También está poniendo siempre el flag special que es algo que debería rellenarse en el servicio como lógica de negocio, no pasarlo como parámetro.
imports... @SpringBootTest class StudentServiceImplTest { private final StudentRepository studentRepository = Mockito.mock(StudentRepository.class); private final StudentService studentService = new StudentServiceImpl(studentRepository); @Test void getAllStudents() { List<Student> studentList = studentService.getAllStudents(); assertEquals(0, studentList.size()); } @Test void getStudentById() { Student student = new Student(); student.setId(1L); student.setName("pepe"); student.setSpecial(true); Mockito.when(studentRepository.findById(1L)).thenReturn(java.util.Optional.of(student)); Student studentFromDB = studentService.getStudentById(1L); assertEquals(student.getName(), studentFromDB.getName()); assertEquals(student.getSpecial(), studentFromDB.getSpecial()); } @Test void createStudent() { Student student = new Student(); student.setId(1L); student.setName("pepe"); student.setSpecial(true); Mockito.when(studentRepository.save(student)).thenReturn(student); Student studentFromDB = studentService.createStudent(student); assertEquals(student.getName(), studentFromDB.getName()); assertEquals(student.getSpecial(), studentFromDB.getSpecial()); } @Test void updateStudent() { Student student = new Student(); student.setId(1L); student.setName("pepe"); student.setSpecial(true); Mockito.when(studentRepository.save(student)).thenReturn(student); Student studentFromDB = studentService.updateStudent(student); assertEquals(student.getName(), studentFromDB.getName()); assertEquals(student.getSpecial(), studentFromDB.getSpecial()); } @Test void deleteStudent() { Student student = new Student(); student.setId(1L); student.setName("pepe"); student.setSpecial(true); studentService.deleteStudent(student.getId()); Mockito.verify(studentRepository, Mockito.times(1)).deleteById(student.getId()); } }
3. Tests de integración
Como en el anterior punto, recordemos primero a qué nos referimos con test de integración. A diferencia de los test unitarios que prueban solo una parte específica del código, un test de integración prueba todo en su conjunto, es decir, tiene como objetivo comprobar que las distintas partes de un sistema interactúan correctamente entre sí. En este tipo de pruebas se comprueba que los módulos, componentes o servicios se comunican adecuadamente y que sus funciones se integran de manera efectiva. Los tests de integración suelen realizarse después de los test unitarios.
Al igual que con los tests unitarios, creamos un fichero llamado ServiceControllerIT, debajo de la misma raíz que el anterior fichero.
En este caso, Github Copilot nos va ayudando a generar las clases pero tenemos que ser muy específicos y omitir sugerencias porque no nos termina de convencer. Una vez que tenemos todo mas o menos hecho, el siguiente prompt si que es útil:
// webTestClient empty response webTestClient.get().uri("/api/v1/student").exchange().expectStatus().isOk();
Nos deja el siguiente test:
@Test void shouldGetStudents() { webTestClient.get().uri("/api/v1/student").exchange().expectStatus().isOk(); }
Una vez tenemos ese test, si que nos genera uno nuevo
@Test void shouldGetStudentById() { webTestClient.get().uri("/api/v1/students/1").exchange().expectStatus().isOk(); }
Pero falla al no tener datos. Así que procedemos a insertar unos cuantos datos a modo de prueba, para ello necesitamos la configuración de h2, así que creamos un fichero application.properties dentro de la carpeta de test.
spring.sql.init.mode=always spring.sql.init.platform=h2 spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.defer-datasource-initialization=true
En este caso, no ha sido capaz de encontrar la última línea de configuración, que era necesaria para que todo funcionase, así que hemos tenido que añadirla nosotros mismos.
En cuanto a los datos para añadir en la base de datos, tenemos que generarlos nosotros mismos, lo que sí hace es una vez visto el patrón, es que nos sugiere más, por lo que nos puede ahorrar trabajo. Esto lo hacemos dentro del fichero data.sql debajo de src/main/resources, este es un fichero que por defecto lee h2 para cargar los datos.
INSERT INTO student (name, special) VALUES ('Juan', true); INSERT INTO student (name, special) VALUES ('Pedro', false); INSERT INTO student (name, special) VALUES ('Maria', true);
Hemos probado a pedirle que nos genere unos cuantos más, en este caso, Github Copilot crea un bucle para ello:
// generate 10 students INSERT INTO student (name, special) SELECT 'Student ' || SEQUENCE.NEXTVAL, false FROM SEQUENCE LIMIT 10;
Si le pedimos ayuda con la generación, lo que nos ofrece como sugerencia es que lo ha generado liquibase.
Y si nos adentramos en las sugerencias, vemos cosas que no son de gran ayuda.
Eso si, si ya tenemos parte de los tests hechos es capaz de ver el modelo y seguir con la misma tendencia hasta completar una batería de tests de integración sencilla. Pero aún así, le siguen faltando ciertas cosas.
Nos generó también este código en el test de createStudent:
@Test void shouldCreateStudent() { webTestClient.post().uri("/api/v1/students").exchange().expectStatus().isOk(); }
pero sería algo más de este estilo:
@Test void shouldCreateStudent() { final Student student = new Student(); student.setName("John"); webTestClient.post().uri("/api/v1/students").body(BodyInserters.fromValue(student)).exchange().expectStatus().isOk(); }
4. Conclusiones
Realmente no tiene la capacidad de crear código a nivel de ChatGPT por ejemplo, funciona más como un asistente que te puede indicar en ciertos casos que código debería ir a continuación, pero siempre necesita supervisión por parte del desarrollador. Por ejemplo, nos dimos cuenta de que por ejemplo al hacer el test no tenía en cuenta que en un POST necesitaba el body, con lo cual ese test no era válido, pero nos ahorró algo de escritura. Otra cosa es que es capaz de seguir patrones, por ejemplo a partir de una instrucción SQL es capaz de generar el resto si le pedimos más casos similares.
Para terminar, creemos que es una herramienta prometedora, pero su verdadero potencial seguramente llegue con Github Copilot X, o a medida que se vaya desarrollando Copilot Labs.
5. Referencias
https://githubnext.com/projects/copilot-labs/
https://github.com/features/copilot