«Эта статья участвовала в мероприятии 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).
Скриншот изображения изСравнение JUnit 5 и JUnit 4
Что касается Spring, самое большое отличие заключается в том, что если вы хотите использовать контейнер Spring, вы должны использовать следующие методы:
@RunWith(SpringRunner.class) => @ExtendWith(SpringExtension.class)
@RunWith(SpringRunner.class)Его можно использовать, но он больше не может внедрять bean-компоненты, включая MockBean, как вы можете видеть в отладке ниже:
Подготовьте среду
иллюстрировать
Бизнес-сценарий — это отмеченный наградами этап работы афиши мероприятия, который упрощается и трансформируется в соответствии с существующей бизнес-логикой.
-
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("需要依赖基础设施");
}
}
Случай 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);
}
}
Вы можете видеть, что нет и не нужно запускать контейнер Spring
Случай 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
-
из-за
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;
}
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, этот класс недоступен в своем собственном каталоге пакетов, так как же имитировать этот объект?
Построй свой собственныйпакеты с одинаковым путем, затем настроить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)
Использование 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());
}
}
Случай 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());
});
}
}
Мокито Действия
непрерывное выполнение
# 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
Фиксированные параметры
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()
Различные параметры
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()Перейти к упаковке.
Есть много комбинаций сопоставителей параметров, более гибких, кому интересно, могут попробовать сами.
Дополнительные способы использования Mockito ищите самостоятельно ~
Суммировать
Модульное тестирование должно быть сосредоточено только на бизнес-логике текущего метода, а другие внешние зависимости должны быть выполнены через Mock.
@SpringBootTestЕго следует использовать для интеграционного тестирования.Не рекомендуется для второстепенного модульного тестирования.Каждый раз, когда для отладки необходимо запустить контейнер Spring, лично мне кажется, что эффективность слишком низкая (конечно, самое эффективное - не писать это (⊙︿⊙)..)
Наконец, в этой статье показан только кейс для демонстрации того, как мокать, но идея мока в основном такая же, и если есть шанс, некоторые связанные тестовые случаи будут выведены для демонстрации.
разное
покрытие юнит-тестами
IDEA поддерживает просмотр покрытия, щелкните правой кнопкой мыши тестовый каталог или тестовый класс.Run 'xxTest' with Coverage
С помощью этой операции можно написать разные тестовые случаи для разных путей тестирования.
Красный открыт, зеленый покрыт
Кроме того, когда 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();
}
полная зависимость
<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);
}
}
адрес проекта
Некоторые справочные статьи прилагаются, пожалуйста, найдите больше контента -.-
- Официальная документация Junit 5, китайская версия
- Введение в общее использование JUnit 5
- Введение в тестирование фиктивного моделирования и начало работы с Mockito