Explorando la colaboración en pair programming y Github Copilot para testing

0
567
  1. Introducción
  2. Tests unitarios
  3. Tests integración
  4. Conclusiones
  5. Referencias

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:

Boton de Github Copilot en el IDE de IntelliJ

Sugerencias de Github Copilot

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 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

 

 

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad