Практика модульного тестирования Spring Boot (2)

задняя часть
Практика модульного тестирования Spring Boot (2)

«Эта статья участвовала в мероприятии Haowen Convocation Order, щелкните, чтобы просмотреть:Двойные заявки на внутреннюю и внешнюю стороны, призовой фонд в 20 000 юаней ждет вас, чтобы бросить вызов!"

предисловие

в предыдущем«Практика тестирования модулей Spring Boot»в модульном тестированиивнешние зависимостиМок необходим, чтобы убедиться, что тестовый примерР (повторяемый)в общем.

Итак, как имитировать операции, которые зависят от MySQL, Redis, MQ и т. д.? Эта статья основана на Spring Boot 2.3, Junit 5 и Mockito, чтобы дать простую демонстрацию, чтобы проиллюстрировать, как сделать Mock и Stub, а также с некоторыми простыми операциями Junit5.

MocK

Мок-метод — распространенная технология в модульном тестировании, основная функция которой — симулировать некоторые объекты, которые непросто построить или более сложны в приложении, чтобы изолировать тест от объектов, находящихся за границами теста.1

Stub

Заглушка (Stub / Method Stub) относится к программному сегменту, используемому для замены части функции. Заглушки можно использовать для имитации поведения существующей программы (например, процесса на удаленной машине) или в качестве временной замены разрабатываемого кода. Таким образом, технология укладки очень полезна при переносе программ, распределенных вычислениях, общей разработке и тестировании программного обеспечения.2

упражняться

импортировать зависимости

Spring Boot 2.3.12.RELEASE, JPA, RabbitMQ, Redis, старшая версия Spring Boot обновлена ​​до Junit 5 (но все еще сохраняются зависимости от Junit 4, которые можно исключить)

полная зависимость

Junit 5

Между Junit 5 и 4 есть некоторые различия, но разница невелика, и он предоставляет более полные функции с точки зрения утверждений (по сравнению с Junit 4).

image.png Скриншот изображения изСравнение JUnit 5 и JUnit 4

Что касается Spring, самое большое отличие заключается в том, что если вы хотите использовать контейнер Spring, вы должны использовать следующие методы:

@RunWith(SpringRunner.class)  => @ExtendWith(SpringExtension.class)

image.png

@RunWith(SpringRunner.class)Его можно использовать, но он больше не может внедрять bean-компоненты, включая MockBean, как вы можете видеть в отладке ниже:image.png

image.png

Подготовьте среду

иллюстрировать

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

  • ActivityRepository: класс активного хранилища, рабочие данные
  • ActivityService: такие зависимостиActivityRepositoryа такжеRedisTemplate
  • ActivityService#award: бизнес-метод, который на этот раз необходимо протестировать, этот метод основан на базе данных и Redis.

Псевдокод для получения приза:

public void award(activityId, posterId, stageId, userId) {
    // 根据 activityId 检查活动是否存在
    // 从 Redis 获取阶段的领奖状态(Redis 以 Hash 结构存储活动海报的阶段领奖状态,key 为 posterId(一个用户在一个活动内 posterId 唯一), field 为 stageId)   
    if (status == null) {
        // 状态数据不存在,查询数据库是否有领奖记录
        if (exist) {
            // 同步至 redis 并返回
            return;
        }
    } else (status) {
        return;
    }
    /* 没有领奖则进行领奖操作 */
    // 查询阶段
    // 保存至数据库
    // 领奖状态写入 Redis     
}    

полный код

Дизайн корпуса

случай 1,2,4 являются дополнительными, как издеваться, см. случай 3

Случай 1: Зависимость от инфраструктуры

использовать@SpringBooTestAnnotation необходимо настроить связанные зависимости для запуска

@Slf4j
@SpringBootTest
public class Case1Test {

    @Test
    // 此为 Junit 5 的注解,别名
    @DisplayName("依赖基础设施测试")
    void infrastructureRequired() {
        log.info("需要依赖基础设施");
    }

}

image.png

image.png

Случай 2: Не полагаться на инфраструктуру

@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class Case2Test {
    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    @DisplayName("无需依赖基础设施测试")
    void noInfrastructureRequired() {
        log.info("无需基础设施也能运行");

        Assertions.assertNotNull(service);
        Assertions.assertNotNull(repository);
        Assertions.assertNotNull(redisTemplate);
    }
}

image.png

Вы можете видеть, что нет и не нужно запускать контейнер Spring

case2.gif

Случай 3: одиночный тест award()

упомянутый вышеaward()Метод будет запрашивать базу данных и Redis, поэтому необходимо использовать Mock и Stub для этой части операции.

Вы можете использовать Mockito для следующего кода# ----- stub {num} -----Следующее утверждение заглушено (полный метод см. В конце статьи)

Поскольку существуют операторы контроля ветвления, для модульного тестирования демонстрируется только один базовый путь, и он охватывает только большинство заглушек 1, 2, 3, 5, 6, 7.

// com.jingwu.example.service.ActivityService#award
public void award(AwardDTO dto) {
    String id = dto.getActivityId(), stageId = dto.getStageId(), userId = dto.getUserId();
    # ----- stub 1 -----
    final ActivityDO activity = repository.selectById(id);
    if (Objects.isNull(activity)) throw new RuntimeException();

    String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
    String key = String.valueOf(stageId);
    
    # ----- stub 2 -----
    Object result = redisTemplate.opsForHash().get(hashKey, key);
    if (Objects.isNull(result)) {
        # ----- stub 3 -----
        Boolean exist = repository.exist(id, stageId, userId);
        if (exist) {
            # ----- stub 4 -----
            redisTemplate.opsForHash().put(hashKey, key, true);
            redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
            return;
        }
    } else if ((Boolean) result) {
        return;
    }
    # ----- stub 5 -----
    ActivityStageDO stage = repository.selectStage(stageId, id);
    if (Objects.isNull(stage)) throw new RuntimeException();
    
    ActivityStageAwardDO entity = new ActivityStageAwardDO()
        .setActivityId(id).setStageId(stageId)
        .setUserId(userId).setStageNum(stage.getStageNum());

    # ----- stub 6 -----
    repository.saveAward(entity);
    # ----- stub 7 -----
    redisTemplate.opsForHash().put(hashKey, key, true);
    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
}

Stub 0

  • выберитеaward()метод,Ctrl+Shift+T, выберите метод, который необходимо протестировать, и нажмите Enter, чтобы создатьActivityServiceTest

key.gif

  • из-заActivityServiceдля модульного тестирования, поэтому через аннотации@Import({ActivityService.class})Вводить бобы (см.Практика модульного тестирования Spring Boot @Import глава)

  • ActivityServiceсредняя зависимостьActivityRepositoryа такжеRedisTemplate, в то время как в этом модульном тесте проблемаaward()Бизнес-логика метода не обращает внимания на то, действительно ли существуют bean-компоненты из двух или их можно внедрить в контейнер Spring, поэтому их можно пропустить черезspring boot testПредоставленная аннотация @MockBean используется для имитации зависимых bean-компонентов (количество bean-компонентов, которые необходимо имитировать, зависит от количества зависимостей bean-компонентов, в противном случае это будетIllegalStateException: Failed to load ApplicationContext)

@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {

    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;
    
}

image.png

image.png

Stub 1

award()метод будет выполнятьсяrepository.selectById(id)приговор, иrepositoryОн будет управлять базой данных, поэтому вам нужно заменить реальную операцию JDBC на макет/заглушку.

годный к употреблениюdoReturn().when()илиwhen().thenReturn()Делай заглушку.

doReturnа такжеthenReturnв таргетингеMockОбъекты имеют одинаковый эффект, отличается только синтаксис, только при использованииSpyобъект отличается (см. Случай 4)

    repository.selectById(id)  
    
=>  ActivityDO activity = mockActivity();
    doReturn(activity).when(repository).selectById(ACTIVITY_ID);
//  或者 
=>  when(repository.selectById(ACTIVITY_ID)).thenReturn(activity);    

Stub 2

    redisTemplate.opsForHash().get(hashKey, key)

потому чтоredisTemplate.opsForHash().get(hashKey, key)является цепной операцией и требует пошаговых заглушек, в то время какopsForHashвернет объект с правами доступа к пакету, т.е.DefaultHashOperations, этот класс недоступен в своем собственном каталоге пакетов, так как же имитировать этот объект?

image.png

Построй свой собственныйпакеты с одинаковым путем, затем настроитьpublicкласс для наследованияDefaultHashOperations(Вы вышли из школы?)

MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
doReturn(mockOpt).when(redisTemplate).opsForHash();
doReturn(null).when(mockOpt).get(any(), any());

когдаaward()метод выполняется дляredisTemplate.opsForHash(), возвращениеmockOpt,ПотомmockOptпозвони сноваget() метод возвращает null (чтобы войти в ветвь if)

image.png

opsForHash.gif

Использование any() см. Операцию Mockito

Кроме того, есть еще один способ инкапсулировать вспомогательные классы для выполнения операций в Redis (будетActivityServiceа такжеRedisTeamplatedecoupling), вам нужно только один раз смоделировать вспомогательный класс.


@MockBean
private RedisHelper helper;

method(){
    ...
    helper.hget(key, field);
    ...
}

@Test
method(){
    ...
    doReturn(object).when(helper).hget(any(), any());
    ...
}

Когда Mock и Stub бизнес-логики тяжело прорабатываются, могут возникнуть некоторые проблемы в структуре кода, в это время его нужно вовремя корректировать и рефакторить в небольшом диапазоне.

Stub 7

    redisTemplate.opsForHash().put(hashKey, key, true);
    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
        
=>  doNothing().when(spyOpt).put(any(), any(), any());
    doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));

voidиспользование методаdoNothingдля выполнения Stub, для передачи мок-методов см.Произвольные параметры

Заглушки 5 и 6 могут относиться к заглушке 1, заглушка 4 не входит в этот тестовый путь, а заглушка режима относится к заглушке 7.

Полное дело

@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {

    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;

    private final Fairy fairy = Fairy.create(Locale.CHINA);

    private static final String ACTIVITY_ID = "1";
    private static final String POSTER_ID = "10";
    private static final String STAGE_ID = "100";

    @SuppressWarnings("unchecked")
    @Test
    @DisplayName("活动阶段领奖测试")
    void award() {
        AwardDTO dto = new AwardDTO();
        dto.setActivityId(ACTIVITY_ID);
        dto.setStageId(STAGE_ID);
        dto.setPosterId(POSTER_ID);
        ActivityDO activity = mockActivity();

        MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
        doReturn(mockOpt).when(redisTemplate).opsForHash();
        doReturn(null).when(mockOpt).get(any(), any());
        doReturn(activity).when(repository).selectById(ACTIVITY_ID);
        doReturn(mockStage()).when(repository).selectStage(any(), any());
        doReturn(false).when(repository).exist(any(), any(), any());
        when(repository.saveAward(any())).thenReturn(true);
        doNothing().when(spyOpt).put(any(), any(), any());
        doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));

        service.award(dto);

        verify(repository, times(1)).saveAward(any());
        verify(redisTemplate, times(2)).opsForHash();
        verify(redisTemplate, times(1)).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
        verify(spyOpt, times(1)).put(any(), any(), any());
    }

}

case3.gif

Случай 4: doReturn и thenReturn

При манипулировании фиктивными объектамиdoReturnа такжеthenReturnто же самое, оно будет другим при манипулировании шпионскими объектами,thenReturnПри манипулировании объектом-шпионом вызывается реальный метод и возвращаются фиктивные данные, в то время какdoReturnвозвращает напрямую без вызова фактического метода.

public class ActivityRepository {
    public Boolean saveAward(ActivityStageAwardDO entity) {
        final boolean result = RandomUtil.randomBoolean();
        log.info("保存结果:{}", result);
        function();
        return result;
    }
    private void function() {
        log.info("抛了异常");
        throw new NullPointerException();
    }
}

public class Case4Test {
    @BeforeEach
    void setUp() {
        log.info("---- UT Start ----");
    }

    @AfterEach
    void tearDown() {
        log.info("---- UT End ----\n");
    }

    @Test
    void doReturnTest() {
        Assertions.assertDoesNotThrow(() -> {
            final ActivityRepository spy = spy(ActivityRepository.class);
            doReturn(true).when(spy).saveAward(any());
            spy.saveAward(mockStageAward());
        });
    }

    @Test
    void thenReturnTest() {
        Assertions.assertThrows(NullPointerException.class, () -> {
            final ActivityRepository spy = spy(ActivityRepository.class);
            when(spy.saveAward(any())).thenReturn(true);
            spy.saveAward(mockStageAward());
        });
    }
}

image.png

Мокито Действия

непрерывное выполнение

# stub
// 第一次执行 返回 true, 第二次执行 返回 false. 1 和 2 等价
1. doReturn(true).doReturn(false).when(repository).exist(ACTIVITY_ID);

2. when(repository.exist(ACTIVITY_ID)).thenReturn(true).thenReturn(false);
       

method(id) {
    bool r1 = repository.exist(id); // r1 = true
    // do something()
    bool r2 = repository.exist(id); // r2 = flase
}

Параметр-заглушка

сопоставитель параметров

Видетьorg.mockito.ArgumentMatchers

image.png

Фиксированные параметры

  doReturn(activity).when(repository).selectById(ACTIVITY_ID);

Приведенное выше утверждение означает, что когдаaward()выполнение методаrepository.selectById()утверждениеа такжеПараметрыACTIVITY_IDзатем возвращает макетactivityОбъект.Если переданный аргумент не равенACTIVITY_ID, без заглушки.

Произвольные параметры

  doReturn(activity).when(repository).selectById(any());

Приведенное выше утверждение означает, что когдаaward()выполнение методаrepository.selectById()Параметры операторалюбое значениевернуться, когдаactivityобъект.

Вы можете использовать сопоставители параметров для определенных типов параметров, таких как

selectById(Long id)  => anyLong()

image.png

Различные параметры

    redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
    
=>  doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
    // 或者
    doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));
    // 或者
    Long time = 2L;
    doReturn(true).when(redisTemplate).expire(anyString(), eq(time), eq(TimeUnit.HOURS));
   
    doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));   
    doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), any());
    doReturn(true).when(redisTemplate).expire(any(), anyLong(), any());
    ... 

При использовании сопоставителей параметров все параметры должны использовать метод сопоставителя, а некоторые параметры не могут быть фиксированными значениями, а некоторые параметры используют сопоставители, и необходимо использовать постоянные/фиксированные значения.eq()Перейти к упаковке.

image.png

image.png

Есть много комбинаций сопоставителей параметров, более гибких, кому интересно, могут попробовать сами.

Дополнительные способы использования Mockito ищите самостоятельно ~

Суммировать

Модульное тестирование должно быть сосредоточено только на бизнес-логике текущего метода, а другие внешние зависимости должны быть выполнены через Mock.

@SpringBootTestЕго следует использовать для интеграционного тестирования.Не рекомендуется для второстепенного модульного тестирования.Каждый раз, когда для отладки необходимо запустить контейнер Spring, лично мне кажется, что эффективность слишком низкая (конечно, самое эффективное - не писать это (⊙︿⊙)..)

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

разное

покрытие юнит-тестами

IDEA поддерживает просмотр покрытия, щелкните правой кнопкой мыши тестовый каталог или тестовый класс.Run 'xxTest' with Coverage

С помощью этой операции можно написать разные тестовые случаи для разных путей тестирования.

image.png image.png image.png

Красный открыт, зеленый покрыт

Кроме того, когда CI/CD использует Jacoco для настройки шлюзов качества, конвейеры с более низким покрытием модульными тестами не будут выполняться, а тестирование, выпуск и запуск не разрешены (поэтому для большинства проектов может быть установлено только 10 %). нельзя публиковать в сети).

Фея (фиктивные данные)

// java.util.Locale 指定区域,默认 ENGLISH
private final Fairy fairy = Fairy.create(Locale.CHINA);

@Test
void fairy() {   
    Person person = fairy.person();
    Company company = fairy.company();
    CreditCard creditCard = fairy.creditCard();
    TextProducer textProducer = fairy.textProducer();
    BaseProducer baseProducer = fairy.baseProducer();
    DateProducer dateProducer = fairy.dateProducer();
    NetworkProducer networkProducer = fairy.networkProducer();
}

jFairy by Codearte

полная зависимость

<dependencies>
     <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.codearte.jfairy</groupId>
        <artifactId>jfairy</artifactId>
        <version>0.5.9</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.3.12.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Бизнес-логика

/**
 * @author 菁芜
 * @since 2021/7/22 - 20:17
 */
@Service
public class ActivityService {

    private static final String FISSION_POSTER_AWARD = "activity:poster:award:%s";
    private final ActivityRepository repository;
    private final RedisTemplate<String, Object> redisTemplate;

    public ActivityService(ActivityRepository repository, RedisTemplate<String, Object> redisTemplate) {
        this.repository = repository;
        this.redisTemplate = redisTemplate;
    }

    public void award(AwardDTO dto) {
        String id = dto.getActivityId();
        String stageId = dto.getStageId();
        String userId = dto.getUserId();

        final ActivityDO activity = repository.selectById(id);
        if (Objects.isNull(activity)) {
            throw new RuntimeException();
        }

        String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
        String key = String.valueOf(stageId);

        Object result = redisTemplate.opsForHash().get(hashKey, key);
        if (Objects.isNull(result)) {
            Boolean exist = repository.exist(id, stageId, userId);
            if (exist) {
                redisTemplate.opsForHash().put(hashKey, key, true);
                redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
                return;
            }
        } else if ((Boolean) result) {
            return;
        }

        ActivityStageDO stage = repository.selectStage(stageId, id);
        if (Objects.isNull(stage)) {
            throw new RuntimeException();
        }
        ActivityStageAwardDO entity = new ActivityStageAwardDO()
                .setActivityId(id).setStageId(stageId)
                .setUserId(userId).setStageNum(stage.getStageNum());

        repository.saveAward(entity);

        redisTemplate.opsForHash().put(hashKey, key, true);
        redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
    }

}

адрес проекта

Junit5 из Spring Boot UT

Некоторые справочные статьи прилагаются, пожалуйста, найдите больше контента -.-

Ссылаться на