Создаем юнит-тесты с помощью ИИ-инструмента

В этой статье мы воспользуемся плагином на базе искусственного интеллекта для IntelliJ, чтобы автоматически сгенерировать юнит-тесты.

Шаг 1. Установка плагина и импорт проекта

Плагин для IntelliJ IDEA полностью бесплатен для разработки программ с открытым исходным кодом. Чтобы установить его, перейдите в меню “Настройки” (Preferences) IntelliJ IDEA, в его левой части выберите Плагины (Plugins) и найдите через поиск Diffblue Cover, как показано ниже.

Установите плагин Diffblue Cover в IntelliJ IDEA

После завершения установки и при наличии существующего проекта, который импортирован в IntelliJ IDEA, плагин начнет индексировать и анализировать всю кодовую базу (т. е. классы, зависимости и т. д.) в качестве фоновой задачи. Это может занять некоторое время в зависимости от её размера и сложности.

Diffblue Cover индексирует проект

Вы также можете дополнительно настроить плагин Diffblue Cover через меню IntelliJ IDEA, чтобы подогнать его под свои нужды.

Если вам хочется поэкспериментировать, рекомендую начать с базового проекта. Ниже приведен пример проекта Spring Boot с контроллером, уровнем обслуживания, репозиторием, использующим данные spring, и резидентной (In-Memory)базой данных:

git clone https://github.com/rhamedy/sample-rest-service.git

Можете спокойно клонировать репозиторий и делать с ним, что угодно.

Шаг 2. Краткое руководство по проекту

Если вы клонировали проект-пример, приведенный выше, то этот раздел для вас. Здесь я кратко покажу классы, для которых мы собираемся создавать юнит-тесты.

Слой контроллера

Вот как выглядит контроллер Spring Boot, который мы собираемся протестировать:

/**
 * Пример контроллера (Sample Controller), который предоставляет множество конечных точек, разрешающих такие операции, как
 * CREATE, READ, DELETE и LIST относительно пользователей (users). 
 * 
 * @author Rafiullah Hamedy
 */

package com.sampleservice.demo.controller;

import java.util.*;

import com.sampleservice.demo.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import com.sampleservice.demo.dto.inbound.StudentInDTO;
import com.sampleservice.demo.dto.outbound.StudentOutDTO;
import com.sampleservice.demo.model.Student;

@RestController
@RequestMapping("/students")
public class StudentController {
	@Autowired
	private StudentService studentService;

	public StudentController() {}

	@GetMapping("list")
	public List<StudentOutDTO> list() {
		Collection<Student> studentsIterable = studentService.list();
		List<StudentOutDTO> outDTOs = new ArrayList<>(); 
		studentsIterable.forEach(entry -> outDTOs.add(new StudentOutDTO(entry)));
		return outDTOs; 
	}
	
	@GetMapping("{sid}")
	public StudentOutDTO getById(@PathVariable("sid") Long id) {
		Student student = studentService.findById(id);
		return new StudentOutDTO(student);
	}
	
	@PostMapping
	public StudentOutDTO save(@RequestBody StudentInDTO dto) {
		Student student = dto.toEntity(); 
		studentService.saveOrUpdate(student);
		return new StudentOutDTO(student); 
	}
	
	@DeleteMapping("{sid}")
	public ResponseEntity<?> delete(@PathVariable("sid") Long id) {
		Student existingStudent = studentService.findById(id);
		studentService.delete(existingStudent);
		return new ResponseEntity<>(HttpStatus.OK);
	}
}

Как показано выше, у нас есть несколько конечных точек для получения списка всех студентов, а также добавления, получения и удаления по идентификатору каждого в отдельности.

Сервисный слой

Слой сервиса соединяет контроллер со слоем репозитория, где будет находиться большая часть логики.

package com.sampleservice.demo.service;

import com.sampleservice.demo.validator.StudentValidator;
import com.sampleservice.demo.dao.StudentDAO;
import com.sampleservice.demo.model.Student;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

@Service
public class StudentServiceImpl implements StudentService {
    @Autowired
    private StudentDAO studentDAO;

    @Autowired
    private StudentValidator studentValidator;

    @Transactional(readOnly = true)
    @Override
    public Collection<Student> list() {
        return StreamSupport.stream(studentDAO.findAll().spliterator(), false)
                .collect(Collectors.toSet());
    }

    @Transactional(readOnly = true)
    @Override
    public Student findByFirstName(String firstName) {
        Optional<Student> student = studentDAO.findByFirstNameLike(firstName);

        studentValidator.validate404(student, "First Name", firstName);

        return student.get();
    }

    @Transactional(readOnly = true)
    @Override
    public Student findById(Long id) {
        Optional<Student> student = studentDAO.findById(id);

        studentValidator.validate404(student, "id", String.valueOf(id));

        return student.get();
    }

    @Transactional(readOnly = true)
    @Override
    public Student findByEmail(String email) {
        Optional<Student> student = studentDAO.findByFirstNameLike(email);

        studentValidator.validate404(student, "email", email);

       return student.get();
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public void delete(Student student) {
        studentDAO.delete(student);
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public Student saveOrUpdate(Student student) {
        return studentDAO.save(student);
    }

    @Transactional(rollbackFor = Exception.class)
    @Override
    public Collection<Student> saveAll(List<Student> students) {
        return StreamSupport.stream(studentDAO.saveAll(students).spliterator(), false)
                .collect(Collectors.toSet());
    }
}

Дальше мы попытаемся сгенерировать модульные тесты для вышеперечисленных классов с помощью плагина Diffblue Cover.

Шаг 3. Генерация юнит-тестов при помощи плагина, основанного на искусственном интеллекте

Именно здесь происходит вся магия. Другими словами, плагин, работающий на основе ИИ, вступает в дело и самостоятельно, без какой-либо помощи, генерирует юнит-тесты.

Опция для генерации юнит-тестов

Если вы уже установили плагин, щелкните правой кнопкой мыши по StudentController и выберите опцию “Написать тесты” (Write Tests) в меню действий, как показано выше.

Плагин Diffblue Cover генерирует юнит-тесты для StudentController

Плагин начнет анализировать контроллер и каждый из его методов. Наконец, он сгенерирует юнит-тесты для каждого из методов.

Генерация тестов занимает какое-то время  —  Diffblue Cover анализирует класс и его методы. Но затраты времени минимальны по сравнению с тем, сколько понадобится для написания вручную.

Ниже приведен тест StudentControllerTest, сгенерированный Diffblue Cover.

package com.sampleservice.demo.controller;

import static org.mockito.AdditionalMatchers.or;
import static org.mockito.Mockito.isA;
import static org.mockito.Mockito.isNull;
import static org.mockito.Mockito.when;

import com.sampleservice.demo.dto.inbound.StudentInDTO;
import com.sampleservice.demo.model.Student;
import com.sampleservice.demo.service.StudentServiceImpl;

import java.util.ArrayList;

import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

/**
 * Эти тесты НЕ написаны человеком.
 */
@WebMvcTest(controllers = {StudentController.class})
@RunWith(SpringRunner.class)
public class StudentControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private StudentServiceImpl studentServiceImpl;

    @Test
    public void testGetById() throws Exception {
        Student student = new Student();
        student.setLastName("Doe");
        student.setEmail("email");
        student.setKlass(1);
        student.setId(123L);
        student.setFirstName("Jane");
        when(this.studentServiceImpl.findById(or(isA(Long.class), isNull()))).thenReturn(student);
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/students/{sid}", 1L);
        ResultActions actualPerformResult = this.mockMvc.perform(requestBuilder);
        ResultActions resultActions = actualPerformResult.andExpect(MockMvcResultMatchers.status().isOk());
        ResultActions resultActions1 = resultActions
                .andExpect(MockMvcResultMatchers.content().contentType("application/json;charset=UTF-8"));
        Matcher<String> matcher = Matchers
                .containsString("{\"id\":123,\"firstName\":\"Jane\",\"lastName\":\"Doe\",\"klass\":1}");
        resultActions1.andExpect(MockMvcResultMatchers.content().string(matcher));
    }

    @Test
    public void testList() throws Exception {
        Student student = new Student();
        student.setLastName("Doe");
        student.setEmail("favicon.ico");
        student.setKlass(0);
        student.setId(123L);
        student.setFirstName("Jane");
        ArrayList<Student> studentList = new ArrayList<Student>();
        studentList.add(student);
        when(this.studentServiceImpl.list()).thenReturn(studentList);
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/students/list");
        ResultActions actualPerformResult = this.mockMvc.perform(requestBuilder);
        ResultActions resultActions = actualPerformResult.andExpect(MockMvcResultMatchers.status().isOk());
        ResultActions resultActions1 = resultActions
                .andExpect(MockMvcResultMatchers.content().contentType("application/json;charset=UTF-8"));
        Matcher<String> matcher = Matchers
                .containsString("[{\"id\":123,\"firstName\":\"Jane\",\"lastName\":\"Doe\",\"klass\":0}]");
        resultActions1.andExpect(MockMvcResultMatchers.content().string(matcher));
    }

    @Test
    public void testList2() throws Exception {
        when(this.studentServiceImpl.list()).thenReturn(new ArrayList<Student>());
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/students/list");
        ResultActions actualPerformResult = this.mockMvc.perform(requestBuilder);
        ResultActions resultActions = actualPerformResult.andExpect(MockMvcResultMatchers.status().isOk());
        ResultActions resultActions1 = resultActions
                .andExpect(MockMvcResultMatchers.content().contentType("application/json;charset=UTF-8"));
        Matcher<String> matcher = Matchers.containsString("[]");
        resultActions1.andExpect(MockMvcResultMatchers.content().string(matcher));
    }

    @Test
    public void testList3() throws Exception {
        Student student = new Student();
        student.setLastName("Doe");
        student.setEmail("favicon.ico");
        student.setKlass(0);
        student.setId(123L);
        student.setFirstName("Jane");
        ArrayList<Student> studentList = new ArrayList<Student>();
        studentList.add(student);
        Student student1 = new Student();
        student1.setLastName("Doe");
        student1.setEmail("favicon.ico");
        student1.setKlass(0);
        student1.setId(123L);
        student1.setFirstName("Jane");
        studentList.add(student1);
        when(this.studentServiceImpl.list()).thenReturn(studentList);
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/students/list");
        ResultActions actualPerformResult = this.mockMvc.perform(requestBuilder);
        ResultActions resultActions = actualPerformResult.andExpect(MockMvcResultMatchers.status().isOk());
        ResultActions resultActions1 = resultActions
                .andExpect(MockMvcResultMatchers.content().contentType("application/json;charset=UTF-8"));
        Matcher<String> matcher = Matchers.containsString(
                "[{\"id\":123,\"firstName\":\"Jane\",\"lastName\":\"Doe\",\"klass\":0},{\"id\":123,\"firstName\":\"Jane\",\"lastName\":\"Doe"
                        + "\",\"klass\":0}]");
        resultActions1.andExpect(MockMvcResultMatchers.content().string(matcher));
    }

    @Test
    public void testDelete() throws Exception {
        Student student = new Student();
        student.setLastName("Doe");
        student.setEmail("email");
        student.setKlass(1);
        student.setId(123L);
        student.setFirstName("Jane");
        when(this.studentServiceImpl.findById(or(isA(Long.class), isNull()))).thenReturn(student);
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.delete("/students/{sid}", 1L);
        ResultActions actualPerformResult = this.mockMvc.perform(requestBuilder);
        actualPerformResult.andExpect(MockMvcResultMatchers.status().isOk());
    }

    @Test
    public void testSave() throws Exception {
        MockHttpServletRequestBuilder getResult = MockMvcRequestBuilders.get("/students");
        ResultActions actualPerformResult = this.mockMvc
                .perform(getResult.param("dto", String.valueOf(new StudentInDTO())));
        actualPerformResult.andExpect(MockMvcResultMatchers.status().is(405));
    }
}

Тесты выглядят очень надежными и следуют лучшим практикам Spring Boot относительно тестирования.

Все сгенерированные юнит-тесты целиком можно найти в этом пулл-реквесте.

Шаг 4. Тестовое покрытие сгенерированных юнит-тестов

Инструмент покрытия кода или тестового покрытия показывает процент кода, охваченного тестами.

Запустим его на сгенерированных ранее тестах, чтобы получить представление о том, какой процент кода покрывается этими тестами.

Покрытие кода JaCoCo для сгенерированных юнит-тестов.

Мы получили довольно хороший процент покрытия по классам, для которых были сгенерированы тесты, то есть по пакетам Controller, service и util.

Стоит отметить, что это крайне простая кодовая база без сложного кода или логики, и плагин, похоже, прилично справляется с работой по созданию ряда надежных модульных тестов. В случае устаревшего кода или запутанной базы плагин может вести себя по-другому.

Демонстрация по созданию юнит-тестов с плагином Diffblue Cover

Для тех, кому интересно, я приготовил также быструю видео-демонстрацию процесса: начиная от установки плагина до генерации тестов и оценки покрытия для сгенерированных тестов.

Быстрое демо по генерации как юнит-тестов на Java с помощью Diffblue Cover

Недостатки автоматизированной генерации юнит-тестов

Несмотря на многие преимущества, которые дает генерация юнит-тестов, у нее есть несколько недостатков, которые я хотел бы вкратце затронуть.

Во-первых, я полагаю, что разработчик, который пишет юнит-тесты, лучше, чем разработчик, который этого не делает.

Во-вторых, на мой взгляд, юнит-тестирование косвенно влияет на то, как мы пишем код. Если мы пишем код, который плохо спроектирован, то модульное тестирование становится еще более неприятным процессом. К примеру, представим метод, который делает десять разных вещей, и к тому же в нем содержится много вложенных условий и довольно сложная логика. Было бы непросто написать тесты, которые охватывают все пути выполнения таких методов.

Однажды, когда я приводил аргументы в пользу юнит-тестирования, один разработчик сказал мне:

Я трачу 20% своего времени на написание кода и 80% на написание юнит-тестов для этого кода.

Если код, который вы пишете, нелегко читать и понимать, он сильно связан и мало сопряжен, то модульное тестирование окажется очень трудоемким и неэффективным процессом.

Заключение

В целом я остался доволен работой плагина и качеством сгенерированных юнит-тестов для проекта-образца.

Однако я заметил, что плагин не может генерировать тесты для определенных классов и в некоторых случаях пропускает пути выполнения (ветви). В видео-примере выше есть дополнительная информация на этот счет.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Rafiullah Hamedy: Use an AI Tool to Write Your Unit Tests